#!/usr/bin/env python3 """ docker-update — Docker Compose stack manager for sammons-server Usage: docker-update Update all stacks docker-update log Browse past update logs (fzf or plain-text fallback) docker-update debug Dry-run: show what would happen, no changes made """ from __future__ import annotations import argparse import json import os import shutil import subprocess import sys import textwrap from datetime import datetime from pathlib import Path from typing import Any # ── optional rich ────────────────────────────────────────────────────────── try: from rich.console import Console from rich.progress import ( BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, ) from rich.table import Table from rich.markup import escape as rich_escape HAS_RICH = True console = Console() except ImportError: HAS_RICH = False console = None # type: ignore[assignment] # ── constants ────────────────────────────────────────────────────────────── LOG_DIR = Path("/var/log/docker-update") FZF_HELPER = "docker-update-fzf" SUCCEEDED = "succeeded" FAILED_PULL = "failed_pull" FAILED_STOP = "failed_stop" FAILED_RESTART = "failed_restart" OUTCOME_ORDER = [SUCCEEDED, FAILED_PULL, FAILED_STOP, FAILED_RESTART] OUTCOME_LABELS: dict[str, str] = { SUCCEEDED: "Succeeded", FAILED_PULL: "Failed to pull (restarted)", FAILED_STOP: "Failed to stop", FAILED_RESTART: "Failed to restart", } OUTCOME_ICONS: dict[str, str] = { SUCCEEDED: "✓", FAILED_PULL: "⚠", FAILED_STOP: "✗", FAILED_RESTART: "✗", } OUTCOME_COLORS: dict[str, str] = { SUCCEEDED: "green", FAILED_PULL: "yellow", FAILED_STOP: "red", FAILED_RESTART: "red", } # ── subprocess helper ────────────────────────────────────────────────────── def _run(cmd: list[str], cwd: str | None = None) -> subprocess.CompletedProcess[str]: return subprocess.run( cmd, cwd=cwd, capture_output=True, text=True ) # ── project discovery ────────────────────────────────────────────────────── def discover_projects() -> list[dict[str, Any]]: """Return sorted list of compose projects found on this host.""" try: r = _run(["docker", "compose", "ls", "--all", "--format", "json"]) except FileNotFoundError: sys.exit("error: 'docker' not found in PATH") if r.returncode != 0: sys.exit(f"error: docker compose ls failed:\n{r.stderr.strip()}") try: raw: list[dict[str, Any]] = json.loads(r.stdout or "[]") except json.JSONDecodeError: sys.exit(f"error: could not parse docker compose ls output:\n{r.stdout[:200]}") projects: list[dict[str, Any]] = [] for item in raw: name = item.get("Name", "").strip() config = item.get("ConfigFiles", "").strip() if not name or not config: continue workdir = str(Path(config.split(",")[0]).parent) projects.append({ "name": name, "workdir": workdir, "is_git": Path(workdir, ".git").is_dir(), "status": item.get("Status", ""), }) return sorted(projects, key=lambda p: p["name"].lower()) # ── update logic ─────────────────────────────────────────────────────────── def _container_logs(workdir: str) -> str: r = _run(["docker", "compose", "logs", "--tail=100", "--no-color"], cwd=workdir) return (r.stdout or r.stderr or "(no logs available)").strip() def update_project_git(project: dict[str, Any]) -> dict[str, Any]: workdir = project["workdir"] log_lines: list[str] = [] r = _run(["git", "pull"], cwd=workdir) log_lines.append(f"=== git pull (exit {r.returncode}) ===\n{r.stdout}{r.stderr}") pull_ok = r.returncode == 0 r = _run(["docker", "compose", "up", "-d", "--build"], cwd=workdir) log_lines.append( f"=== docker compose up -d --build (exit {r.returncode}) ===\n{r.stdout}{r.stderr}" ) if r.returncode != 0: log_lines.append( f"\nContainer logs ({project['name']}):\n{_container_logs(workdir)}" ) outcome = FAILED_RESTART elif not pull_ok: outcome = FAILED_PULL else: outcome = SUCCEEDED return {**project, "outcome": outcome, "log_lines": log_lines} def update_project_registry(project: dict[str, Any]) -> dict[str, Any]: workdir = project["workdir"] log_lines: list[str] = [] r = _run(["docker", "compose", "down"], cwd=workdir) log_lines.append(f"=== docker compose down (exit {r.returncode}) ===\n{r.stdout}{r.stderr}") if r.returncode != 0: log_lines.append(f"\nStep 'stop' failed (exit {r.returncode}):") log_lines.append(r.stderr) return {**project, "outcome": FAILED_STOP, "log_lines": log_lines} r = _run(["docker", "compose", "pull"], cwd=workdir) log_lines.append(f"=== docker compose pull (exit {r.returncode}) ===\n{r.stdout}{r.stderr}") pull_ok = r.returncode == 0 if not pull_ok: log_lines.append(f"\nStep 'pull' failed (exit {r.returncode}) — restarting with existing image:") log_lines.append(r.stderr) r = _run(["docker", "compose", "up", "-d"], cwd=workdir) log_lines.append( f"=== docker compose up -d (exit {r.returncode}) ===\n{r.stdout}{r.stderr}" ) if r.returncode != 0: log_lines.append( f"\nContainer logs ({project['name']}):\n{_container_logs(workdir)}" ) outcome = FAILED_RESTART elif not pull_ok: outcome = FAILED_PULL else: outcome = SUCCEEDED return {**project, "outcome": outcome, "log_lines": log_lines} # ── rich runner ──────────────────────────────────────────────────────────── def _run_update_rich(projects: list[dict[str, Any]]) -> list[dict[str, Any]]: results: list[dict[str, Any]] = [] with Progress( SpinnerColumn(), TextColumn("[bold]{task.description:<32}"), BarColumn(), TaskProgressColumn(), transient=False, console=console, ) as prog: task = prog.add_task("Updating stacks…", total=len(projects)) for project in projects: prog.update(task, description=f" {project['name']}") result = ( update_project_git(project) if project["is_git"] else update_project_registry(project) ) results.append(result) prog.advance(task) color = OUTCOME_COLORS[result["outcome"]] icon = OUTCOME_ICONS[result["outcome"]] prog.console.print( f" [{color}]{icon}[/{color}] {rich_escape(result['name'])}" ) return results # ── plain runner ─────────────────────────────────────────────────────────── def _run_update_plain(projects: list[dict[str, Any]]) -> list[dict[str, Any]]: results: list[dict[str, Any]] = [] total = len(projects) for i, project in enumerate(projects, 1): print(f"[{i}/{total}] Updating {project['name']}…", flush=True) result = ( update_project_git(project) if project["is_git"] else update_project_registry(project) ) results.append(result) icon = OUTCOME_ICONS[result["outcome"]] label = OUTCOME_LABELS[result["outcome"]] print(f" {icon} {result['name']} ({label})") return results # ── summary printers ─────────────────────────────────────────────────────── def _group_by_outcome( results: list[dict[str, Any]], ) -> dict[str, list[dict[str, Any]]]: groups: dict[str, list[dict[str, Any]]] = {k: [] for k in OUTCOME_ORDER} for r in results: groups[r["outcome"]].append(r) return groups def _print_summary_rich(groups: dict[str, list[dict[str, Any]]]) -> None: console.print() console.rule("[bold]Summary") console.print() parts: list[str] = [] if groups[SUCCEEDED]: parts.append(f"[green]✓ {len(groups[SUCCEEDED])} succeeded[/green]") if groups[FAILED_PULL]: parts.append(f"[yellow]⚠ {len(groups[FAILED_PULL])} failed to pull[/yellow]") if groups[FAILED_STOP]: parts.append(f"[red]✗ {len(groups[FAILED_STOP])} failed to stop[/red]") if groups[FAILED_RESTART]: parts.append(f"[red]✗ {len(groups[FAILED_RESTART])} failed to restart[/red]") console.print(" " + " ".join(parts)) console.print() def _print_summary_plain(groups: dict[str, list[dict[str, Any]]]) -> None: print() print("=" * 60) for outcome in OUTCOME_ORDER: items = groups[outcome] if items: icon = OUTCOME_ICONS[outcome] label = OUTCOME_LABELS[outcome] print(f"{icon} {label}: {len(items)}") for r in items: print(f" - {r['name']}") print("=" * 60) # ── log writers ──────────────────────────────────────────────────────────── def _write_text_log( run_id: str, results: list[dict[str, Any]], path: Path, ) -> None: groups = _group_by_outcome(results) lines: list[str] = [ f"docker-update run: {run_id}", "=" * 60, "", ] for outcome in OUTCOME_ORDER: items = groups[outcome] if items: icon = OUTCOME_ICONS[outcome] label = OUTCOME_LABELS[outcome] lines.append(f"{icon} {label}: {len(items)}") for r in items: lines.append(f" - {r['name']}") failures = [r for r in results if r["outcome"] != SUCCEEDED] if failures: lines += ["", "=" * 60, "FAILURE DETAILS", "=" * 60] for r in failures: sep = "─" * 60 lines += [ "", sep, f" {r['name']} [{OUTCOME_LABELS[r['outcome']]}]", sep, "", ] for chunk in r.get("log_lines", []): lines.append(chunk) path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _write_json_log( run_id: str, results: list[dict[str, Any]], path: Path, ) -> None: data = { "run_id": run_id, "projects": [ { "name": r["name"], "workdir": r["workdir"], "is_git": r["is_git"], "outcome": r["outcome"], } for r in results ], } path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") # ── cmd_update ───────────────────────────────────────────────────────────── def cmd_update() -> None: LOG_DIR.mkdir(parents=True, exist_ok=True) run_id = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") log_path = LOG_DIR / f"docker-update-{run_id}.log" json_path = LOG_DIR / f"docker-update-{run_id}.json" projects = discover_projects() if not projects: print("No Docker Compose projects found.") return results = _run_update_rich(projects) if HAS_RICH else _run_update_plain(projects) groups = _group_by_outcome(results) _print_summary_rich(groups) if HAS_RICH else _print_summary_plain(groups) _write_text_log(run_id, results, log_path) _write_json_log(run_id, results, json_path) print(f"Log saved to: {log_path}") print() print("To browse logs: docker-update log") # ── cmd_log ──────────────────────────────────────────────────────────────── def cmd_log() -> None: if not LOG_DIR.exists() or not any(LOG_DIR.glob("*.log")): print(f"No log files found in {LOG_DIR}") return fzf_bin = shutil.which("fzf") helper_bin = shutil.which(FZF_HELPER) missing: list[str] = [] if not fzf_bin: missing.append("fzf") if not helper_bin: missing.append(FZF_HELPER) if missing: print(f"Warning: {', '.join(missing)} not found — falling back to plain-text log viewer.") print() _cmd_log_plain() return os.execvp(helper_bin, [helper_bin, "log", str(LOG_DIR)]) def _cmd_log_plain() -> None: logs = sorted(LOG_DIR.glob("*.log"), reverse=True) if not logs: print("No logs found.") return print("Available logs (newest first):") print() for i, log in enumerate(logs, 1): print(f" {i:3d}. {log.name}") print() try: raw = input("Enter number to view (or Enter to exit): ").strip() except (EOFError, KeyboardInterrupt): print() return if not raw: return try: selected = logs[int(raw) - 1] print() print("─" * 60) print(selected.read_text(encoding="utf-8")) except (ValueError, IndexError): print("Invalid selection.") # ── cmd_debug ────────────────────────────────────────────────────────────── def cmd_debug() -> None: """Dry-run: discover projects and show what would happen, no changes.""" projects = discover_projects() if not projects: print("No Docker Compose projects found.") return if HAS_RICH: _debug_rich(projects) else: _debug_plain(projects) print() try: answer = input("Show example log in fzf? [y/N] ").strip().lower() except (EOFError, KeyboardInterrupt): print() return if answer != "y": return fzf_bin = shutil.which("fzf") helper_bin = shutil.which(FZF_HELPER) missing = [] if not fzf_bin: missing.append("fzf") if not helper_bin: missing.append(FZF_HELPER) if missing: print(f"Cannot open fzf demo: {', '.join(missing)} not found.") return import random import tempfile # Assign fake outcomes weighted towards success outcome_pool = ( [SUCCEEDED] * 6 + [FAILED_PULL] * 2 + [FAILED_STOP] + [FAILED_RESTART] ) fake_projects = [ { "name": p["name"], "workdir": p["workdir"], "is_git": p["is_git"], "outcome": random.choice(outcome_pool), } for p in projects ] fake_data = { "run_id": "debug-" + datetime.now().strftime("%Y-%m-%d_%H-%M-%S"), "debug": True, "projects": fake_projects, } tmp = tempfile.NamedTemporaryFile( mode="w", suffix=".json", prefix="du-debug-", delete=False ) json.dump(fake_data, tmp, indent=2) tmp.flush() tmp.close() os.execvp(helper_bin, [helper_bin, "debug", tmp.name]) def _debug_rich(projects: list[dict[str, Any]]) -> None: console.print() console.rule("[bold cyan]Debug Mode — no changes will be made") console.print() tbl = Table(show_header=True, header_style="bold") tbl.add_column("Project", style="cyan", no_wrap=True) tbl.add_column("Workdir", style="dim") tbl.add_column("Type") tbl.add_column("Update steps") for p in projects: ptype = "[blue]git[/blue]" if p["is_git"] else "[magenta]registry[/magenta]" steps = "git pull → up -d --build" if p["is_git"] else "down → pull → up -d" tbl.add_row(p["name"], p["workdir"], ptype, steps) console.print(tbl) console.print() console.print(f" [dim]{len(projects)} project(s) would be updated[/dim]") def _debug_plain(projects: list[dict[str, Any]]) -> None: print() print("Debug Mode — no changes will be made") print("=" * 60) for p in projects: ptype = "git" if p["is_git"] else "registry" steps = "git pull → up -d --build" if p["is_git"] else "down → pull → up -d" print(f" {p['name']}") print(f" workdir : {p['workdir']}") print(f" type : {ptype}") print(f" steps : {steps}") print() print(f"{len(projects)} project(s) would be updated") # ── entrypoint ───────────────────────────────────────────────────────────── def main() -> None: parser = argparse.ArgumentParser( prog="docker-update", description="Docker Compose stack manager", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=textwrap.dedent("""\ commands: (none) update all stacks log browse past update logs debug dry-run — show what would run without making changes """), ) parser.add_argument( "command", nargs="?", choices=["log", "debug"], default=None, ) args = parser.parse_args() if args.command == "log": cmd_log() elif args.command == "debug": cmd_debug() else: cmd_update() if __name__ == "__main__": main()