moved compose commands to script for updating containers and compose files to new services folder structure

This commit is contained in:
2026-07-23 18:29:46 -06:00
parent f31c13f1aa
commit 09a32344ed
7 changed files with 285 additions and 13 deletions
+57
View File
@@ -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.
Binary file not shown.
+161
View File
@@ -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())
Binary file not shown.
+58
View File
@@ -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()
+9 -13
View File
@@ -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