Compare commits

..

2 Commits

2 changed files with 285 additions and 80 deletions

View File

@@ -4,9 +4,12 @@ A Python command-line application to manage Docker containers defined in subdire
## Features ## Features
- **Rich UI**: Beautiful terminal output using the `rich` library, including tables, panels, and colored status indicators.
- **List**: View currently running containers across all your projects. - **List**: View currently running containers across all your projects.
- **Describe**: Show detailed information about containers in a specific project, including descriptions. - **Diun Integration**: Automatically detects if [Diun](https://github.com/crazy-max/diun) is running and displays image versions and update availability directly in the list.
- **Volumes**: List volumes used by a specific project. - **Describe**: Show detailed information about containers in a specific project, including descriptions, ports, and a formatted table of volumes.
- **Volumes**: List all volumes used by a specific project with source and destination details.
- **Logs**: View logs for a project, with options to follow, tail, and **filter by specific container/service**.
- **Stop**: Stop containers for a specific project or all projects. - **Stop**: Stop containers for a specific project or all projects.
- **Update**: Pull the latest images and recreate containers (equivalent to `docker compose pull && docker compose up -d`). - **Update**: Pull the latest images and recreate containers (equivalent to `docker compose pull && docker compose up -d`).
- **Restart**: Restart containers for a specific project or all projects. - **Restart**: Restart containers for a specific project or all projects.
@@ -15,6 +18,10 @@ A Python command-line application to manage Docker containers defined in subdire
- Python 3 - Python 3
- Docker and Docker Compose (plugin) installed. - Docker and Docker Compose (plugin) installed.
- The `rich` Python library:
```bash
pip install rich
```
- A `~/docker/` directory containing subdirectories for each of your projects. - A `~/docker/` directory containing subdirectories for each of your projects.
- Each project subdirectory must contain a `docker-compose.yml`, `docker-compose.yaml`, `compose.yml`, or `compose.yaml` file. - Each project subdirectory must contain a `docker-compose.yml`, `docker-compose.yaml`, `compose.yml`, or `compose.yaml` file.
@@ -46,10 +53,34 @@ A Python command-line application to manage Docker containers defined in subdire
Run the application using the command name you set up (e.g., `dm`). Run the application using the command name you set up (e.g., `dm`).
### List Running Containers ### List Running Containers
Displays a table with Project, Container Name, State, Image, Version, and Update Status.
```bash ```bash
dm list dm list
``` ```
### Describe Project
Show detailed info (Service, Image, State, Ports, Description, Volumes) for a project.
```bash
dm describe project_name
```
### List Volumes
Show a table of all volumes (Bind mounts and named volumes) for a project.
```bash
dm volumes project_name
```
### View Logs
View logs for a project.
```bash
dm logs project_name
```
**Options:**
- Follow output: `dm logs project_name -f`
- Tail specific number of lines: `dm logs project_name --tail 100`
- **Filter by container**: `dm logs project_name container_name`
- Example: `dm logs media gluetun --tail 50`
### Stop Containers ### Stop Containers
Stop a specific project: Stop a specific project:
```bash ```bash

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 found_any: if not c_name:
print("No running containers found in managed projects.") 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): 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 console.print(v_table)
display_name = vol_name if vol_name else f"[{m_type}]" 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
@@ -217,7 +371,6 @@ def list_project_volumes(projects, target):
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