feat: Enhance Docker Manager with Diun integration and improved project information display

This commit is contained in:
Peter Wood
2025-12-08 11:30:28 +00:00
parent e850221326
commit 618b979c0a

View File

@@ -37,9 +37,92 @@ import argparse
import subprocess
import json
from pathlib import Path
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.text import Text
from rich import box
# Configuration
DOCKER_ROOT = Path(os.path.expanduser("~/docker"))
console = Console()
def get_diun_container_name():
"""Find the running diun container name."""
# Try to find by image name first (most reliable if standard image is used)
cmd = ["docker", "ps", "--filter", "ancestor=crazymax/diun", "--format", "{{.Names}}"]
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode == 0 and res.stdout.strip():
return res.stdout.strip().split('\n')[0]
# Fallback to finding by name containing 'diun'
cmd = ["docker", "ps", "--filter", "name=diun", "--format", "{{.Names}}"]
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode == 0 and res.stdout.strip():
return res.stdout.strip().split('\n')[0]
return None
def get_diun_info():
"""Fetch image information from Diun."""
diun_info = {}
try:
diun_container = get_diun_container_name()
if not diun_container:
return diun_info
# Get diun image list
cmd = ["docker", "exec", diun_container, "diun", "image", "list", "--raw"]
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode != 0:
return diun_info
data = json.loads(res.stdout)
for img in data.get("images", []):
name = img.get("name", "")
latest = img.get("latest", {})
digest = latest.get("digest", "")
labels = latest.get("labels", {})
# Normalize name for matching
# Remove docker.io/ prefix
norm_name = name
if norm_name.startswith("docker.io/"):
norm_name = norm_name[10:]
# If no registry (no /), prepend library/
if "/" not in norm_name:
norm_name = f"library/{norm_name}"
diun_info[norm_name] = {
"digest": digest,
"labels": labels,
"tag": latest.get("tag", ""),
"original_name": name
}
except (subprocess.CalledProcessError, json.JSONDecodeError):
pass
return diun_info
def get_image_version(labels):
"""Extract version from image labels."""
version_keys = [
"org.opencontainers.image.version",
"version",
"org.label-schema.version",
"build_version"
]
for key in version_keys:
if key in labels:
return labels[key]
return "latest"
def get_projects():
@@ -80,26 +163,88 @@ def run_command(cmd, cwd, capture_output=False):
def list_containers(projects):
"""List running containers for all projects."""
print(f"{'Project':<20} | {'Container Name':<40} | {'State':<10}")
print("-" * 75)
table = Table(title="Docker Containers", box=box.ROUNDED)
table.add_column("Project", style="cyan", no_wrap=True)
table.add_column("Container Name", style="green")
table.add_column("State", style="yellow")
table.add_column("Image", style="blue")
table.add_column("Version", style="magenta")
table.add_column("Update", style="red")
# Get Diun info once
diun_info = get_diun_info()
found_any = False
for name, path in sorted(projects.items()):
# Get running container names
cmd = ["docker", "compose", "ps", "--format",
"{{.Names}}", "--filter", "status=running"]
cmd = ["docker", "compose", "ps", "--format", "json"]
res = run_command(cmd, path, capture_output=True)
if res and res.returncode == 0:
containers = res.stdout.strip().split('\n')
containers = [c for c in containers if c] # filter empty
if containers:
found_any = True
for container in containers:
print(f"{name:<20} | {container:<40} | Running")
lines = res.stdout.strip().split('\n')
for line in lines:
if not line:
continue
try:
container = json.loads(line)
c_name = container.get('Name', '')
state = container.get('State', '')
image = container.get('Image', '')
if not found_any:
print("No running containers found in managed projects.")
if not c_name:
continue
found_any = True
# Get version and update status
version = "unknown"
update_status = ""
# Normalize image name for lookup
norm_image = image
if norm_image.startswith("docker.io/"):
norm_image = norm_image[10:]
if "/" not in norm_image:
norm_image = f"library/{norm_image}"
# Check Diun info
if norm_image in diun_info:
info = diun_info[norm_image]
# Try to get version from Diun labels first
version = get_image_version(info.get("labels", {}))
# Check for updates
inspect_digest_cmd = ["docker", "inspect", c_name, "--format", "{{index .RepoDigests 0}}"]
inspect_digest_res = run_command(inspect_digest_cmd, path, capture_output=True)
if inspect_digest_res and inspect_digest_res.returncode == 0:
running_digest_full = inspect_digest_res.stdout.strip()
# running_digest is like name@sha256:hash
if "@" in running_digest_full:
running_digest = running_digest_full.split("@")[1]
latest_digest = info.get("digest", "")
if latest_digest and running_digest != latest_digest:
update_status = "Update Available"
# If version is still unknown, try to get from running container labels
if version == "latest" or version == "unknown":
inspect_cmd = ["docker", "inspect", c_name, "--format", "{{json .Config.Labels}}"]
inspect_res = run_command(inspect_cmd, path, capture_output=True)
if inspect_res and inspect_res.returncode == 0:
try:
labels = json.loads(inspect_res.stdout)
version = get_image_version(labels)
except:
pass
table.add_row(name, c_name, state, image, version, update_status)
except json.JSONDecodeError:
pass
if found_any:
console.print(table)
else:
console.print("[yellow]No running containers found in managed projects.[/yellow]")
def is_project_running(path):
@@ -117,11 +262,11 @@ def is_project_running(path):
def describe_project(projects, target):
"""Show detailed information about containers in a project."""
if target not in projects:
print(f"Error: Project '{target}' not found.")
console.print(f"[red]Error: Project '{target}' not found.[/red]")
return
path = projects[target]
print(f"Describing project: {target}")
console.print(Panel(f"[bold blue]Describing project: {target}[/bold blue]", expand=False))
cmd = ["docker", "compose", "ps", "--format", "json"]
res = run_command(cmd, path, capture_output=True)
@@ -136,79 +281,88 @@ def describe_project(projects, target):
container = json.loads(line)
found_any = True
name = container.get('Name', 'N/A')
print("-" * 60)
print(f"Service: {container.get('Service', 'N/A')}")
print(f"Container: {name}")
print(f"Image: {container.get('Image', 'N/A')}")
print(f"State: {container.get('State', 'N/A')}")
print(f"Status: {container.get('Status', 'N/A')}")
# Create a table for this container
table = Table(show_header=False, box=box.SIMPLE)
table.add_column("Field", style="bold cyan")
table.add_column("Value")
table.add_row("Service", container.get('Service', 'N/A'))
table.add_row("Container", name)
table.add_row("Image", container.get('Image', 'N/A'))
table.add_row("State", container.get('State', 'N/A'))
table.add_row("Status", container.get('Status', 'N/A'))
ports = container.get('Ports', '')
if ports:
print(f"Ports: {ports}")
table.add_row("Ports", ports)
# Get details via inspect for better parsing (Labels and Mounts)
# Get details via inspect
inspect_cmd = ["docker", "inspect", name, "--format", "{{json .}}"]
inspect_res = run_command(inspect_cmd, path, capture_output=True)
if inspect_res and inspect_res.returncode == 0:
try:
details = json.loads(inspect_res.stdout.strip())
# Description from labels
# Description
labels = details.get("Config", {}).get("Labels", {})
description = labels.get("org.opencontainers.image.description")
if description:
print(f"Description: {description}")
table.add_row("Description", description)
console.print(Panel(table, title=f"[bold]{name}[/bold]", border_style="green"))
# Volumes/Mounts
mounts = details.get("Mounts", [])
if mounts:
print("Volumes:")
print(f" {'Name/Type':<20} | {'Source (Local Path)':<50} | {'Destination'}")
print(f" {'-'*20} | {'-'*50} | {'-'*20}")
v_table = Table(title="Volumes", box=box.MINIMAL)
v_table.add_column("Type", style="cyan")
v_table.add_column("Source", style="yellow")
v_table.add_column("Destination", style="green")
for mount in mounts:
m_type = mount.get("Type")
vol_name = mount.get("Name", "")
source = mount.get("Source", "")
dest = mount.get("Destination", "")
v_table.add_row(m_type, source, dest)
# If it's a bind mount, use "Bind" as name, otherwise use volume name
display_name = vol_name if vol_name else f"[{m_type}]"
# Truncate source if too long for cleaner display, or just let it wrap?
# Let's just print it.
print(f" {display_name:<20} | {source:<50} | {dest}")
console.print(v_table)
console.print("") # Spacing
except json.JSONDecodeError:
pass
else:
console.print(Panel(table, title=f"[bold]{name}[/bold]", border_style="green"))
except json.JSONDecodeError:
pass
if not found_any:
print("No containers found for this project.")
console.print("[yellow]No containers found for this project.[/yellow]")
else:
print("Failed to get container info.")
console.print("[red]Failed to get container info.[/red]")
def list_project_volumes(projects, target):
"""List volumes used by containers in a project."""
if target not in projects:
print(f"Error: Project '{target}' not found.")
console.print(f"[red]Error: Project '{target}' not found.[/red]")
return
path = projects[target]
print(f"Volumes for project: {target}")
print("-" * 100)
print(f"{'Service':<20} | {'Type':<10} | {'Name/Source':<40} | {'Destination'}")
print("-" * 100)
table = Table(title=f"Volumes for project: {target}", box=box.ROUNDED)
table.add_column("Service", style="cyan")
table.add_column("Container", style="blue")
table.add_column("Type", style="magenta")
table.add_column("Source", style="yellow")
table.add_column("Destination", style="green")
cmd = ["docker", "compose", "ps", "--format", "json"]
res = run_command(cmd, path, capture_output=True)
if res and res.returncode == 0:
lines = res.stdout.strip().split('\n')
found_any = False
for line in lines:
if not line:
continue
@@ -217,7 +371,6 @@ def list_project_volumes(projects, target):
name = container.get('Name', 'N/A')
service = container.get('Service', 'N/A')
# Get details via inspect
inspect_cmd = ["docker", "inspect", name, "--format", "{{json .Mounts}}"]
inspect_res = run_command(inspect_cmd, path, capture_output=True)
@@ -225,32 +378,21 @@ def list_project_volumes(projects, target):
try:
mounts = json.loads(inspect_res.stdout.strip())
if mounts:
found_any = True
for mount in mounts:
m_type = mount.get("Type", "unknown")
source = mount.get("Source", "")
dest = mount.get("Destination", "")
name_or_source = mount.get("Name", "")
if m_type == "bind":
name_or_source = source
elif not name_or_source:
name_or_source = source
print(f"{service:<20} | {m_type:<10} | {name_or_source:<40} | {dest}")
m_type = mount.get("Type", "N/A")
source = mount.get("Source", "N/A")
dest = mount.get("Destination", "N/A")
table.add_row(service, name, m_type, source, dest)
except json.JSONDecodeError:
pass
except json.JSONDecodeError:
pass
except json.JSONDecodeError:
pass
if not found_any:
print("No volumes found for this project.")
else:
print("Failed to get container info.")
console.print(table)
def manage_project(projects, action, target, extra_args=None):
def manage_project(projects, action, target, extra_args=None, container_filter=None):
"""
Execute the specified action (stop, update, restart, logs) on target project(s).
@@ -259,6 +401,7 @@ def manage_project(projects, action, target, extra_args=None):
action (str): The action to perform ('stop', 'update', 'restart', 'logs').
target (str): The target project name or 'all'.
extra_args (list): Additional arguments for the command (e.g. for logs).
container_filter (str): Optional container/service name to filter logs.
"""
targets = []
@@ -268,30 +411,60 @@ def manage_project(projects, action, target, extra_args=None):
elif target in projects:
targets = [(target, projects[target])]
else:
print(f"Error: Project '{target}' not found in {DOCKER_ROOT}")
print("Available projects:", ", ".join(sorted(projects.keys())))
console.print(f"[red]Error: Project '{target}' not found in {DOCKER_ROOT}[/red]")
console.print(f"Available projects: {', '.join(sorted(projects.keys()))}")
return
# Logs are special, usually run on one project interactively
if action == "logs":
if len(targets) > 1:
print("Error: Logs can only be viewed for one project at a time.")
console.print("[red]Error: Logs can only be viewed for one project at a time.[/red]")
return
name, path = targets[0]
print(f"Viewing logs for {name}...")
# Filter by container/service if specified
services_to_log = []
if container_filter:
cmd_ps = ["docker", "compose", "ps", "--format", "json"]
res = run_command(cmd_ps, path, capture_output=True)
if res and res.returncode == 0:
try:
lines = res.stdout.strip().split('\n')
for line in lines:
if not line: continue
c = json.loads(line)
s_name = c.get('Service', '')
c_name = c.get('Name', '')
# Check if filter matches service or container name
if container_filter in s_name or container_filter in c_name:
services_to_log.append(s_name)
except json.JSONDecodeError:
pass
if not services_to_log:
console.print(f"[yellow]No matching service/container found for '{container_filter}'. Showing all logs.[/yellow]")
else:
services_to_log = list(set(services_to_log))
console.print(f"[blue]Showing logs for service(s): {', '.join(services_to_log)}[/blue]")
console.print(f"[blue]Viewing logs for {name}...[/blue]")
cmd = ["docker", "compose", "logs"] + (extra_args or [])
if services_to_log:
cmd.extend(services_to_log)
# For logs, we want to stream output directly to stdout, so no capture_output
# and we might want to allow Ctrl+C to exit gracefully
try:
subprocess.run(cmd, cwd=path, check=False)
except KeyboardInterrupt:
print("\nLog view stopped.")
console.print("\n[yellow]Log view stopped.[/yellow]")
return
print(f"Performing '{action}' on {len(targets)} project(s)...")
console.print(f"[bold]Performing '{action}' on {len(targets)} project(s)...[/bold]")
for name, path in targets:
print(f"\n[{name}] -> {action}...")
console.print(f"\n[bold cyan][{name}] -> {action}...[/bold cyan]")
# Execute the requested action
if action == "stop":
@@ -305,21 +478,21 @@ def manage_project(projects, action, target, extra_args=None):
was_running = is_project_running(path)
if was_running:
print(f" Stopping running containers for {name}...")
console.print(f" [yellow]Stopping running containers for {name}...[/yellow]")
run_command(["docker", "compose", "stop"], path)
print(f" Pulling latest images for {name}...")
console.print(f" [blue]Pulling latest images for {name}...[/blue]")
pull_res = run_command(["docker", "compose", "pull"], path)
if pull_res and pull_res.returncode == 0:
if was_running:
print(f" Restarting containers for {name}...")
console.print(f" [green]Restarting containers for {name}...[/green]")
run_command(["docker", "compose", "up", "-d"], path)
else:
print(
f" Project {name} was not running. Images updated, but not started.")
console.print(
f" [green]Project {name} was not running. Images updated, but not started.[/green]")
else:
print(f" Failed to pull images for {name}. Skipping update.")
console.print(f" [red]Failed to pull images for {name}. Skipping update.[/red]")
def main():
@@ -366,6 +539,7 @@ def main():
# Logs command configuration
logs_parser = subparsers.add_parser("logs", help="View container logs")
logs_parser.add_argument("project", help="Project name to view logs for")
logs_parser.add_argument("container", nargs="?", help="Container/Service name to filter logs (optional)")
logs_parser.add_argument(
"-f", "--follow", action="store_true", help="Follow log output")
logs_parser.add_argument(
@@ -394,7 +568,7 @@ def main():
if args.tail:
extra_args.extend(["--tail", args.tail])
manage_project(projects, "logs", args.project, extra_args=extra_args)
manage_project(projects, "logs", args.project, extra_args=extra_args, container_filter=args.container)
elif args.command in ["stop", "update", "restart"]:
target = args.project