From 618b979c0a3eb39ffaa7ec96d2f4b3a846f73197 Mon Sep 17 00:00:00 2001 From: Peter Wood Date: Mon, 8 Dec 2025 11:30:28 +0000 Subject: [PATCH] feat: Enhance Docker Manager with Diun integration and improved project information display --- docker-manager/docker-manager.py | 330 +++++++++++++++++++++++-------- 1 file changed, 252 insertions(+), 78 deletions(-) diff --git a/docker-manager/docker-manager.py b/docker-manager/docker-manager.py index 874a3c2..d943279 100755 --- a/docker-manager/docker-manager.py +++ b/docker-manager/docker-manager.py @@ -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 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 - if not found_any: - print("No running containers found in managed projects.") + 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", "") - - # 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}") + v_table.add_row(m_type, source, 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 @@ -216,8 +370,7 @@ def list_project_volumes(projects, target): container = json.loads(line) 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 - - 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