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 subprocess
import json import json
from pathlib import Path 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 # Configuration
DOCKER_ROOT = Path(os.path.expanduser("~/docker")) 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(): def get_projects():
@@ -80,26 +163,88 @@ def run_command(cmd, cwd, capture_output=False):
def list_containers(projects): def list_containers(projects):
"""List running containers for all projects.""" """List running containers for all projects."""
print(f"{'Project':<20} | {'Container Name':<40} | {'State':<10}") table = Table(title="Docker Containers", box=box.ROUNDED)
print("-" * 75) 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 found_any = False
for name, path in sorted(projects.items()): for name, path in sorted(projects.items()):
# Get running container names # Get running container names
cmd = ["docker", "compose", "ps", "--format", cmd = ["docker", "compose", "ps", "--format", "json"]
"{{.Names}}", "--filter", "status=running"]
res = run_command(cmd, path, capture_output=True) res = run_command(cmd, path, capture_output=True)
if res and res.returncode == 0: if res and res.returncode == 0:
containers = res.stdout.strip().split('\n') lines = res.stdout.strip().split('\n')
containers = [c for c in containers if c] # filter empty for line in lines:
if containers: if not line:
found_any = True continue
for container in containers: try:
print(f"{name:<20} | {container:<40} | Running") 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: table.add_row(name, c_name, state, image, version, update_status)
print("No running containers found in managed projects.")
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): def is_project_running(path):
@@ -117,11 +262,11 @@ def is_project_running(path):
def describe_project(projects, target): def describe_project(projects, target):
"""Show detailed information about containers in a project.""" """Show detailed information about containers in a project."""
if target not in projects: if target not in projects:
print(f"Error: Project '{target}' not found.") console.print(f"[red]Error: Project '{target}' not found.[/red]")
return return
path = projects[target] 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"] cmd = ["docker", "compose", "ps", "--format", "json"]
res = run_command(cmd, path, capture_output=True) res = run_command(cmd, path, capture_output=True)
@@ -136,79 +281,88 @@ def describe_project(projects, target):
container = json.loads(line) container = json.loads(line)
found_any = True found_any = True
name = container.get('Name', 'N/A') name = container.get('Name', 'N/A')
print("-" * 60)
print(f"Service: {container.get('Service', 'N/A')}") # Create a table for this container
print(f"Container: {name}") table = Table(show_header=False, box=box.SIMPLE)
print(f"Image: {container.get('Image', 'N/A')}") table.add_column("Field", style="bold cyan")
print(f"State: {container.get('State', 'N/A')}") table.add_column("Value")
print(f"Status: {container.get('Status', 'N/A')}")
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', '') ports = container.get('Ports', '')
if 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_cmd = ["docker", "inspect", name, "--format", "{{json .}}"]
inspect_res = run_command(inspect_cmd, path, capture_output=True) inspect_res = run_command(inspect_cmd, path, capture_output=True)
if inspect_res and inspect_res.returncode == 0: if inspect_res and inspect_res.returncode == 0:
try: try:
details = json.loads(inspect_res.stdout.strip()) details = json.loads(inspect_res.stdout.strip())
# Description from labels # Description
labels = details.get("Config", {}).get("Labels", {}) labels = details.get("Config", {}).get("Labels", {})
description = labels.get("org.opencontainers.image.description") description = labels.get("org.opencontainers.image.description")
if 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 # Volumes/Mounts
mounts = details.get("Mounts", []) mounts = details.get("Mounts", [])
if mounts: if mounts:
print("Volumes:") v_table = Table(title="Volumes", box=box.MINIMAL)
print(f" {'Name/Type':<20} | {'Source (Local Path)':<50} | {'Destination'}") v_table.add_column("Type", style="cyan")
print(f" {'-'*20} | {'-'*50} | {'-'*20}") v_table.add_column("Source", style="yellow")
v_table.add_column("Destination", style="green")
for mount in mounts: for mount in mounts:
m_type = mount.get("Type") m_type = mount.get("Type")
vol_name = mount.get("Name", "")
source = mount.get("Source", "") source = mount.get("Source", "")
dest = mount.get("Destination", "") 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}]" console.print(v_table)
console.print("") # Spacing
# 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}")
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
else:
console.print(Panel(table, title=f"[bold]{name}[/bold]", border_style="green"))
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
if not found_any: if not found_any:
print("No containers found for this project.") console.print("[yellow]No containers found for this project.[/yellow]")
else: else:
print("Failed to get container info.") console.print("[red]Failed to get container info.[/red]")
def list_project_volumes(projects, target): def list_project_volumes(projects, target):
"""List volumes used by containers in a project.""" """List volumes used by containers in a project."""
if target not in projects: if target not in projects:
print(f"Error: Project '{target}' not found.") console.print(f"[red]Error: Project '{target}' not found.[/red]")
return return
path = projects[target] path = projects[target]
print(f"Volumes for project: {target}")
print("-" * 100) table = Table(title=f"Volumes for project: {target}", box=box.ROUNDED)
print(f"{'Service':<20} | {'Type':<10} | {'Name/Source':<40} | {'Destination'}") table.add_column("Service", style="cyan")
print("-" * 100) 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"] cmd = ["docker", "compose", "ps", "--format", "json"]
res = run_command(cmd, path, capture_output=True) res = run_command(cmd, path, capture_output=True)
if res and res.returncode == 0: if res and res.returncode == 0:
lines = res.stdout.strip().split('\n') lines = res.stdout.strip().split('\n')
found_any = False
for line in lines: for line in lines:
if not line: if not line:
continue continue
@@ -216,8 +370,7 @@ def list_project_volumes(projects, target):
container = json.loads(line) container = json.loads(line)
name = container.get('Name', 'N/A') name = container.get('Name', 'N/A')
service = container.get('Service', 'N/A') service = container.get('Service', 'N/A')
# Get details via inspect
inspect_cmd = ["docker", "inspect", name, "--format", "{{json .Mounts}}"] inspect_cmd = ["docker", "inspect", name, "--format", "{{json .Mounts}}"]
inspect_res = run_command(inspect_cmd, path, capture_output=True) inspect_res = run_command(inspect_cmd, path, capture_output=True)
@@ -225,32 +378,21 @@ def list_project_volumes(projects, target):
try: try:
mounts = json.loads(inspect_res.stdout.strip()) mounts = json.loads(inspect_res.stdout.strip())
if mounts: if mounts:
found_any = True
for mount in mounts: for mount in mounts:
m_type = mount.get("Type", "unknown") m_type = mount.get("Type", "N/A")
source = mount.get("Source", "") source = mount.get("Source", "N/A")
dest = mount.get("Destination", "") dest = mount.get("Destination", "N/A")
name_or_source = mount.get("Name", "") table.add_row(service, name, m_type, source, dest)
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}")
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
if not found_any: console.print(table)
print("No volumes found for this project.")
else:
print("Failed to get container info.")
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). 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'). action (str): The action to perform ('stop', 'update', 'restart', 'logs').
target (str): The target project name or 'all'. target (str): The target project name or 'all'.
extra_args (list): Additional arguments for the command (e.g. for logs). extra_args (list): Additional arguments for the command (e.g. for logs).
container_filter (str): Optional container/service name to filter logs.
""" """
targets = [] targets = []
@@ -268,30 +411,60 @@ def manage_project(projects, action, target, extra_args=None):
elif target in projects: elif target in projects:
targets = [(target, projects[target])] targets = [(target, projects[target])]
else: else:
print(f"Error: Project '{target}' not found in {DOCKER_ROOT}") console.print(f"[red]Error: Project '{target}' not found in {DOCKER_ROOT}[/red]")
print("Available projects:", ", ".join(sorted(projects.keys()))) console.print(f"Available projects: {', '.join(sorted(projects.keys()))}")
return return
# Logs are special, usually run on one project interactively # Logs are special, usually run on one project interactively
if action == "logs": if action == "logs":
if len(targets) > 1: 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 return
name, path = targets[0] 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 []) 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 # 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 # and we might want to allow Ctrl+C to exit gracefully
try: try:
subprocess.run(cmd, cwd=path, check=False) subprocess.run(cmd, cwd=path, check=False)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nLog view stopped.") console.print("\n[yellow]Log view stopped.[/yellow]")
return 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: for name, path in targets:
print(f"\n[{name}] -> {action}...") console.print(f"\n[bold cyan][{name}] -> {action}...[/bold cyan]")
# Execute the requested action # Execute the requested action
if action == "stop": if action == "stop":
@@ -305,21 +478,21 @@ def manage_project(projects, action, target, extra_args=None):
was_running = is_project_running(path) was_running = is_project_running(path)
if was_running: 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) 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) pull_res = run_command(["docker", "compose", "pull"], path)
if pull_res and pull_res.returncode == 0: if pull_res and pull_res.returncode == 0:
if was_running: 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) run_command(["docker", "compose", "up", "-d"], path)
else: else:
print( console.print(
f" Project {name} was not running. Images updated, but not started.") f" [green]Project {name} was not running. Images updated, but not started.[/green]")
else: 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(): def main():
@@ -366,6 +539,7 @@ def main():
# Logs command configuration # Logs command configuration
logs_parser = subparsers.add_parser("logs", help="View container logs") 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("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( logs_parser.add_argument(
"-f", "--follow", action="store_true", help="Follow log output") "-f", "--follow", action="store_true", help="Follow log output")
logs_parser.add_argument( logs_parser.add_argument(
@@ -394,7 +568,7 @@ def main():
if args.tail: if args.tail:
extra_args.extend(["--tail", 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"]: elif args.command in ["stop", "update", "restart"]:
target = args.project target = args.project