Files
home-services/composectl.py
T

162 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""Runtime-agnostic compose deployment helper for home services.
This lightweight CLI keeps compose files as the source of truth and selects
Docker or Podman as the backend at runtime.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
def detect_runtime(preferred: str | None = None) -> str:
"""Return a supported runtime name.
The preferred runtime is honored when available; otherwise the first
installed runtime from the standard Docker/Podman lookup order is used.
"""
if preferred and shutil.which(preferred):
return preferred
for runtime in ("docker", "podman"):
if shutil.which(runtime):
return runtime
raise RuntimeError("Neither Docker nor Podman is installed or available on PATH")
def build_compose_command(
runtime: str,
compose_file: Path,
project_name: str,
action: str,
detached: bool = True,
) -> list[str]:
"""Build a compose command for the chosen runtime."""
base = [runtime, "compose", "-f", str(compose_file), "-p", project_name]
if action == "up":
return [*base, "up", "-d"]
if action == "pull":
return [*base, "pull"]
if action == "down":
return [*base, "down"]
if action == "status":
return [*base, "ps"]
raise ValueError(f"Unsupported action '{action}'")
def discover_compose_files(root: Path) -> list[Path]:
"""Return compose files under the services directory."""
services_dir = root / "services"
if not services_dir.exists():
return []
return sorted(path for path in services_dir.rglob("compose.yaml") if path.is_file())
def discover_stack_names(root: Path) -> list[str]:
"""Infer stack names from the services directory layout."""
services_dir = root / "services"
if not services_dir.exists():
return []
return sorted(path.name for path in services_dir.iterdir() if path.is_dir())
def build_update_commands(runtime: str, compose_file: Path, project_name: str) -> list[list[str]]:
"""Return the maintenance sequence used for a full stack refresh."""
return [
build_compose_command(runtime=runtime, compose_file=compose_file, project_name=project_name, action="pull"),
build_compose_command(runtime=runtime, compose_file=compose_file, project_name=project_name, action="down"),
build_compose_command(runtime=runtime, compose_file=compose_file, project_name=project_name, action="up"),
[runtime, "image", "prune", "-f"],
]
def run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(command, check=False, text=True, capture_output=True)
def main() -> int:
parser = argparse.ArgumentParser(description="Manage compose stacks with Docker or Podman")
parser.add_argument("action", choices=["pull", "up", "down", "prune", "status", "refresh"])
parser.add_argument("--stack", default=None)
parser.add_argument("--compose-file", type=Path, default=None)
parser.add_argument("--project-name", default=None)
parser.add_argument("--runtime", choices=["docker", "podman"], default=None)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--all", action="store_true", help="Run the requested action for every configured stack")
args = parser.parse_args()
runtime = detect_runtime(args.runtime)
if args.action == "prune":
command = [runtime, "image", "prune", "-f"]
if args.dry_run:
print(" ".join(command))
return 0
result = run_command(command)
if result.stdout:
print(result.stdout, end="")
if result.stderr:
print(result.stderr, end="", file=sys.stderr)
return result.returncode
stack_names = discover_stack_names(Path(".")) if args.all else [args.stack or "media"]
failure_count = 0
for stack_name in stack_names:
compose_file = args.compose_file or Path(f"services/{stack_name}/compose.yaml")
project_name = args.project_name or f"{stack_name}_services"
if args.action == "refresh":
commands = build_update_commands(runtime=runtime, compose_file=compose_file, project_name=project_name)
if args.dry_run:
for command in commands:
print(" ".join(command))
continue
for command in commands:
result = run_command(command)
if result.stdout:
print(result.stdout, end="")
if result.stderr:
print(result.stderr, end="", file=sys.stderr)
if result.returncode != 0:
failure_count += 1
continue
command = build_compose_command(
runtime=runtime,
compose_file=compose_file,
project_name=project_name,
action=args.action,
)
if args.dry_run:
print(" ".join(command))
continue
result = run_command(command)
if result.stdout:
print(result.stdout, end="")
if result.stderr:
print(result.stderr, end="", file=sys.stderr)
if result.returncode != 0:
failure_count += 1
return 1 if failure_count else 0
if __name__ == "__main__":
raise SystemExit(main())