Files
shell/docker-manager/docker-manager.py

425 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Docker Manager Application
This script manages Docker containers defined in subdirectories of `~/docker/`.
Each subdirectory is treated as a project and must contain a `docker-compose.yml`, `docker-compose.yaml`, `compose.yml`, or `compose.yaml`.
Usage Examples:
1. List all running containers:
$ dm list
2. Update a specific project (pulls latest images and recreates containers):
$ dm update media-server
3. Stop all projects (with confirmation prompt):
$ dm stop
4. Restart all projects without confirmation:
$ dm restart --all
5. Stop a specific project:
$ dm stop web-app
Purpose:
- Simplifies management of multiple Docker Compose projects.
- Provides a unified interface for common operations (list, stop, update, restart).
- Eliminates the need to navigate to specific directories to run docker commands.
To make the command available as dm from any directory, run:
chmod +x ~/shell/docker-manager/docker-manager.py
mkdir -p ~/.local/bin
ln -s ~/shell/docker-manager/docker-manager.py ~/.local/bin/dm
"""
import os
import argparse
import subprocess
import json
from pathlib import Path
# Configuration
DOCKER_ROOT = Path(os.path.expanduser("~/docker"))
def get_projects():
"""Scan DOCKER_ROOT for subdirectories with compose files."""
projects = {}
if not DOCKER_ROOT.exists():
print(f"Error: Directory {DOCKER_ROOT} does not exist.")
return projects
for item in DOCKER_ROOT.iterdir():
if item.is_dir():
if (item / "docker-compose.yml").exists() or \
(item / "docker-compose.yaml").exists() or \
(item / "compose.yml").exists() or \
(item / "compose.yaml").exists():
projects[item.name] = item
return projects
def run_command(cmd, cwd, capture_output=False):
"""Run a shell command in a specific directory."""
try:
result = subprocess.run(
cmd,
cwd=cwd,
check=True,
text=True,
capture_output=capture_output
)
return result
except subprocess.CalledProcessError as e:
if capture_output:
return e
print(f"Error running command: {' '.join(cmd)}")
print(f"cwd: {cwd}")
return None
def list_containers(projects):
"""List running containers for all projects."""
print(f"{'Project':<20} | {'Container Name':<40} | {'State':<10}")
print("-" * 75)
found_any = False
for name, path in sorted(projects.items()):
# Get running container names
cmd = ["docker", "compose", "ps", "--format",
"{{.Names}}", "--filter", "status=running"]
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")
if not found_any:
print("No running containers found in managed projects.")
def is_project_running(path):
"""Check if any containers in the project are running."""
cmd = ["docker", "compose", "ps", "--format",
"json", "--filter", "status=running"]
res = run_command(cmd, path, capture_output=True)
if res and res.returncode == 0:
# If output is not empty/just brackets, something is running
output = res.stdout.strip()
return output and output != "[]"
return False
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.")
return
path = projects[target]
print(f"Describing project: {target}")
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
try:
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')}")
ports = container.get('Ports', '')
if ports:
print(f"Ports: {ports}")
# Get details via inspect for better parsing (Labels and Mounts)
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
labels = details.get("Config", {}).get("Labels", {})
description = labels.get("org.opencontainers.image.description")
if description:
print(f"Description: {description}")
# 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}")
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}")
except json.JSONDecodeError:
pass
except json.JSONDecodeError:
pass
if not found_any:
print("No containers found for this project.")
else:
print("Failed to get container info.")
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.")
return
path = projects[target]
print(f"Volumes for project: {target}")
print("-" * 100)
print(f"{'Service':<20} | {'Type':<10} | {'Name/Source':<40} | {'Destination'}")
print("-" * 100)
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
try:
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)
if inspect_res and inspect_res.returncode == 0:
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}")
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.")
def manage_project(projects, action, target, extra_args=None):
"""
Execute the specified action (stop, update, restart, logs) on target project(s).
Args:
projects (dict): Dictionary of project names to their paths.
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).
"""
targets = []
# Determine which projects to target
if target == "all":
targets = sorted(projects.items())
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())))
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.")
return
name, path = targets[0]
print(f"Viewing logs for {name}...")
cmd = ["docker", "compose", "logs"] + (extra_args or [])
# 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.")
return
print(f"Performing '{action}' on {len(targets)} project(s)...")
for name, path in targets:
print(f"\n[{name}] -> {action}...")
# Execute the requested action
if action == "stop":
# Stop containers without removing them
run_command(["docker", "compose", "stop"], path)
elif action == "restart":
# Restart containers
run_command(["docker", "compose", "restart"], path)
elif action == "update":
# Update process: Check running -> Stop -> Pull -> Start if was running
was_running = is_project_running(path)
if was_running:
print(f" Stopping running containers for {name}...")
run_command(["docker", "compose", "stop"], path)
print(f" Pulling latest images for {name}...")
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}...")
run_command(["docker", "compose", "up", "-d"], path)
else:
print(
f" Project {name} was not running. Images updated, but not started.")
else:
print(f" Failed to pull images for {name}. Skipping update.")
def main():
"""Main entry point for the application."""
parser = argparse.ArgumentParser(
description="Manage Docker containers in ~/docker/")
subparsers = parser.add_subparsers(
dest="command", help="Command to execute")
# List command configuration
subparsers.add_parser("list", help="List running containers")
# Describe command configuration
describe_parser = subparsers.add_parser("describe", help="Show details of a project's containers")
describe_parser.add_argument("project", help="Project name to describe")
# Volumes command configuration
volumes_parser = subparsers.add_parser("volumes", help="List volumes used by a project")
volumes_parser.add_argument("project", help="Project name to list volumes for")
# Stop command configuration
stop_parser = subparsers.add_parser("stop", help="Stop containers")
stop_parser.add_argument("project", nargs="?", default=None,
help="Project name (optional). If omitted, asks for confirmation to stop ALL.")
stop_parser.add_argument("--all", action="store_true",
help="Stop all projects without confirmation prompt if specified")
# Update command configuration
update_parser = subparsers.add_parser(
"update", help="Pull and update containers")
update_parser.add_argument("project", nargs="?", default=None,
help="Project name (optional). If omitted, asks for confirmation to update ALL.")
update_parser.add_argument("--all", action="store_true",
help="Update all projects without confirmation prompt if specified")
# Restart command configuration
restart_parser = subparsers.add_parser(
"restart", help="Restart containers")
restart_parser.add_argument("project", nargs="?", default=None,
help="Project name (optional). If omitted, asks for confirmation to restart ALL.")
restart_parser.add_argument("--all", action="store_true",
help="Restart all projects without confirmation prompt if specified")
# 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(
"-f", "--follow", action="store_true", help="Follow log output")
logs_parser.add_argument(
"--tail", default="all", help="Number of lines to show from the end of the logs (default: all)")
args = parser.parse_args()
# Scan for projects
projects = get_projects()
if not projects:
print(f"No docker projects found in {DOCKER_ROOT}")
return
# Dispatch commands
if args.command == "list":
list_containers(projects)
elif args.command == "describe":
describe_project(projects, args.project)
elif args.command == "volumes":
list_project_volumes(projects, args.project)
elif args.command == "logs":
# Prepare extra args for logs
extra_args = []
if args.follow:
extra_args.append("-f")
if args.tail:
extra_args.extend(["--tail", args.tail])
manage_project(projects, "logs", args.project, extra_args=extra_args)
elif args.command in ["stop", "update", "restart"]:
target = args.project
# Handle "all" logic safely with user confirmation
if target is None:
if args.all:
target = "all"
else:
# Interactive check if user wants to apply to all
print(
f"No project specified. Do you want to {args.command} ALL projects? (y/N)")
choice = input().lower()
if choice == 'y':
target = "all"
else:
print("Operation cancelled. Specify a project name or use --all.")
return
manage_project(projects, args.command, target)
else:
# Show help if no command provided
parser.print_help()
if __name__ == "__main__":
main()