162 lines
5.3 KiB
Python
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())
|