diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8bc13c --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Home Services + +This repository manages home-lab application stacks using compose files and a small Python CLI that works with either Docker or Podman. + +## Repository layout + +```text +services/ + media/ + compose.yaml +composectl.py +update_containers.sh +``` + +- `services/` contains one directory per deployed stack. +- Each stack directory contains its own `compose.yaml` file. +- `composectl.py` infers the stack name, compose file path, and project name from that directory structure. +- `update_containers.sh` is a compatibility shim that delegates to the Python tool. + +## Runtime support + +The CLI detects and uses whichever runtime is available: + +- `docker compose ...` +- `podman compose ...` + +This means the same service directories can be managed from either backend without changing the compose files. + +## Common commands + +From the repository root: + +```bash +python3 composectl.py --help +python3 composectl.py --dry-run --all refresh +python3 composectl.py --stack media --dry-run up +python3 composectl.py --stack media pull +python3 composectl.py --stack media down +python3 composectl.py --stack media status +``` + +The shell entrypoint delegates to the Python tool: + +```bash +bash update_containers.sh --dry-run --all refresh +``` + +## Refresh flow + +The repo-wide refresh flow does the following for each configured stack: + +1. `pull` +2. `down` +3. `up -d` +4. `prune` + +This preserves the same maintenance behavior as the old shell updater, but moves the logic into a portable Python implementation. diff --git a/__pycache__/composectl.cpython-39.pyc b/__pycache__/composectl.cpython-39.pyc new file mode 100644 index 0000000..32b9bfa Binary files /dev/null and b/__pycache__/composectl.cpython-39.pyc differ diff --git a/composectl.py b/composectl.py new file mode 100644 index 0000000..1f50a55 --- /dev/null +++ b/composectl.py @@ -0,0 +1,161 @@ +#!/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()) diff --git a/media/compose.yaml b/services/media/compose.yaml similarity index 100% rename from media/compose.yaml rename to services/media/compose.yaml diff --git a/tests/__pycache__/test_composectl.cpython-39.pyc b/tests/__pycache__/test_composectl.cpython-39.pyc new file mode 100644 index 0000000..78f96f7 Binary files /dev/null and b/tests/__pycache__/test_composectl.cpython-39.pyc differ diff --git a/tests/test_composectl.py b/tests/test_composectl.py new file mode 100644 index 0000000..5c7e306 --- /dev/null +++ b/tests/test_composectl.py @@ -0,0 +1,58 @@ +import shutil +import unittest +from pathlib import Path + +from composectl import ( + build_compose_command, + build_update_commands, + detect_runtime, + discover_compose_files, + discover_stack_names, +) + + +class ComposeCtlTests(unittest.TestCase): + def test_detect_runtime_returns_installed_backend(self): + runtime = detect_runtime(preferred="docker") + expected = "docker" if shutil.which("docker") else "podman" + self.assertEqual(runtime, expected) + + def test_build_compose_command_uses_selected_runtime(self): + cmd = build_compose_command( + runtime="podman", + compose_file=Path("media/compose.yaml"), + project_name="media_services", + action="up", + ) + self.assertEqual( + cmd, + ["podman", "compose", "-f", "media/compose.yaml", "-p", "media_services", "up", "-d"], + ) + + def test_discover_compose_files_finds_existing_stacks(self): + stack_files = discover_compose_files(Path(".")) + self.assertIn(Path("services/media/compose.yaml"), stack_files) + + def test_discover_stack_names_returns_inferred_stack_names(self): + stack_names = discover_stack_names(Path(".")) + self.assertIn("media", stack_names) + + def test_build_update_commands_returns_full_stack_refresh_sequence(self): + commands = build_update_commands( + runtime="podman", + compose_file=Path("services/media/compose.yaml"), + project_name="media_services", + ) + self.assertEqual( + commands, + [ + ["podman", "compose", "-f", "services/media/compose.yaml", "-p", "media_services", "pull"], + ["podman", "compose", "-f", "services/media/compose.yaml", "-p", "media_services", "down"], + ["podman", "compose", "-f", "services/media/compose.yaml", "-p", "media_services", "up", "-d"], + ["podman", "image", "prune", "-f"], + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/update_containers.sh b/update_containers.sh index b5be619..c7fb74d 100644 --- a/update_containers.sh +++ b/update_containers.sh @@ -1,15 +1,11 @@ -#!/bin/bash +#!/usr/bin/env bash +set -euo pipefail -sudo -i +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" -cd /volume1/docker/git_services/compose -docker compose -p git_services pull -docker compose -p git_services down -docker compose -p git_services up -d --remove-orphans -docker image prune -f - -cd /volume1/docker/media_services/compose -docker compose -p media_services pull -docker compose -p media_services down -docker compose -p media_services up -d --remove-orphans -docker image prune -f +if [ "$#" -gt 0 ]; then + python3 composectl.py "$@" +else + python3 composectl.py refresh --all +fi