From 09a32344edab0f1dfb731b0ece94dfda31c5d38d Mon Sep 17 00:00:00 2001 From: Scott Sorell Date: Thu, 23 Jul 2026 18:29:46 -0600 Subject: [PATCH] moved compose commands to script for updating containers and compose files to new services folder structure --- README.md | 57 +++++++ __pycache__/composectl.cpython-39.pyc | Bin 0 -> 4570 bytes composectl.py | 161 ++++++++++++++++++ {media => services/media}/compose.yaml | 0 .../test_composectl.cpython-39.pyc | Bin 0 -> 2431 bytes tests/test_composectl.py | 58 +++++++ update_containers.sh | 22 +-- 7 files changed, 285 insertions(+), 13 deletions(-) create mode 100644 README.md create mode 100644 __pycache__/composectl.cpython-39.pyc create mode 100644 composectl.py rename {media => services/media}/compose.yaml (100%) create mode 100644 tests/__pycache__/test_composectl.cpython-39.pyc create mode 100644 tests/test_composectl.py 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 0000000000000000000000000000000000000000..32b9bfa51dc5a5bcd9f93ee165bffe6b908ed262 GIT binary patch literal 4570 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 yZ10aGfVU3`SOA@Z_V8;>Hh^9BcxdX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..78f96f7c9ac4aa53f37462382cb7b08818576930 GIT binary patch literal 2431 zcmbVOOK%)S5T2g*uGbEjBs>yy~2%J*k**;kFD5Y4s)Reub2<5#{w2Y>$48)LL0CZ@TaG*?6f=IQTbgil}rif znqCfc5Z)bl6JuEF4Q;onk0`L?Mj{xbua}i{&{~?6}%59D%DRb#?5#WRNT)}F8d0dGI9!Ws`9P+k>12N^kO`y;HnX& zq*)2L=Q(F|7vSe5yMhS#AS}`)>*VmO1=zM;O4|jHF^Z_!fcyb;5M%`~9}|or^G@6& z0^r(XvS%^>37G*L3`7UkpRX63WeIq1a&YTWQsjEg%+V|0B4}d*7=Po$ow7UcH^IgtI1KXhY)XA+Al`vB zP-!tU0R$-AlN5OudJF!W>bB_rXBYjStAleaZ7WLae_JHq1NF~A`1YSc_^(PYArXQV z!`CmMbCItn&Kz64Clk&Ba|6>4LAo=XIxU062B>qPXOzIwCop9& zv=aYjLK1pmFLXoK%%im;$x2G2HG%KruqiWz!0DidAB{ZQHHgRE32)&LYs;<)2A-pB uok5=I@Gg9~lR#NQqvCy>b5Z}Yjp^{q*1bD;@=L7XushPTZ+6x{49VYtK7%0u literal 0 HcmV?d00001 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