Files
Docker-Update/docker-update
Claude 61b472bbe8 Swap in richer progress UI; fix fzf preview to use docker label filter
docker-update (Python):
- Add compose_binary detection (docker compose v2 plugin or legacy docker-compose)
- is_git_repo() now uses `git -C dir rev-parse` instead of .git dir check
- Rich runner: Panel header showing project count / git / registry split,
  per-project sub-task with step labels (git pull, down, pull, up --build…),
  BarColumn(30) + TimeElapsedColumn, bold summary line
- Plain runner: same header line, passes compose_cmd through
- Summary uses bold green/yellow/red instead of plain colours
- All update functions accept compose_cmd parameter

docker-update-fzf (Bash):
- project_status_by_name(): check running state via
  `docker ps --filter label=com.docker.compose.project=NAME` — works from
  any directory, no workdir required
- Preview script rebuilt: shows container table + per-container logs via
  docker ps/logs --filter label=…, includes stopped containers fallback
- show_action_menu() now uses project_status_by_name() for live dot / actions
- Trap extended to INT/TERM for cleaner SSH exit

https://claude.ai/code/session_01LtPxA1zDET2JQn6NYDDxKn
2026-04-11 10:51:27 +00:00

654 lines
21 KiB
Python
Executable File

#!/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.panel import Panel
from rich.progress import (
BarColumn,
Progress,
SpinnerColumn,
TaskProgressColumn,
TextColumn,
TimeElapsedColumn,
)
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 helpers ─────────────────────────────────────────────────────
def _run(cmd: list[str], cwd: str | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
cmd, cwd=cwd, capture_output=True, text=True
)
def _get_compose_cmd() -> list[str]:
"""Detect docker compose v2 plugin or legacy docker-compose."""
if _run(["docker", "compose", "version"]).returncode == 0:
return ["docker", "compose"]
if shutil.which("docker-compose"):
return ["docker-compose"]
sys.exit("error: neither 'docker compose' nor 'docker-compose' found in PATH")
def _is_git_repo(directory: str) -> bool:
return _run(["git", "-C", directory, "rev-parse", "--is-inside-work-tree"]).returncode == 0
# ── project discovery ──────────────────────────────────────────────────────
def discover_projects(compose_cmd: list[str]) -> list[dict[str, Any]]:
"""Return sorted list of compose projects found on this host."""
try:
r = _run(compose_cmd + ["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": _is_git_repo(workdir),
"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], compose_cmd: list[str]) -> dict[str, Any]:
workdir = project["workdir"]
log_lines: list[str] = []
r = _run(["git", "-C", workdir, "pull"])
log_lines.append(f"=== git pull (exit {r.returncode}) ===\n{r.stdout}{r.stderr}")
pull_ok = r.returncode == 0
r = _run(compose_cmd + ["up", "-d", "--build"], cwd=workdir)
log_lines.append(
f"=== 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], compose_cmd: list[str]) -> dict[str, Any]:
workdir = project["workdir"]
log_lines: list[str] = []
r = _run(compose_cmd + ["down"], cwd=workdir)
log_lines.append(f"=== 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(compose_cmd + ["pull"], cwd=workdir)
log_lines.append(f"=== 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(compose_cmd + ["up", "-d"], cwd=workdir)
log_lines.append(
f"=== 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]], compose_cmd: list[str]) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
git_count = sum(1 for p in projects if p["is_git"])
reg_count = len(projects) - git_count
console.print(Panel.fit(
f"[bold cyan]docker-update[/bold cyan] "
f"[dim]{len(projects)} project(s) · {git_count} git · {reg_count} registry[/dim]",
border_style="cyan",
))
console.print()
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(bar_width=30),
TaskProgressColumn(),
TimeElapsedColumn(),
console=console,
transient=False,
) as progress:
overall = progress.add_task("[bold white]Overall", total=len(projects))
for project in projects:
name = project["name"]
workdir = project["workdir"]
is_git = project["is_git"]
mode_tag = "[magenta]git[/magenta]" if is_git else "[blue]reg[/blue]"
n_steps = 2 if is_git else 3
task = progress.add_task(
f"[cyan]{name}[/cyan] {mode_tag}",
total=n_steps,
)
if is_git:
step_cmds = [
("git pull", ["git", "-C", workdir, "pull"]),
("up --build", compose_cmd + ["up", "-d", "--build"]),
]
else:
step_cmds = [
("down", compose_cmd + ["down"]),
("pull", compose_cmd + ["pull"]),
("up", compose_cmd + ["up", "-d"]),
]
steps: dict[str, dict[str, Any]] = {}
pull_failed = False
outcome = SUCCEEDED
for label, cmd in step_cmds:
progress.update(
task,
description=f"[cyan]{name}[/cyan] {mode_tag} [yellow]{label}[/yellow]",
)
r = subprocess.run(cmd, cwd=workdir, capture_output=True, text=True)
rc, out, err = r.returncode, r.stdout, r.stderr
steps[label] = {"rc": rc, "out": out, "err": err}
progress.advance(task)
if rc != 0:
if label in ("git pull", "pull"):
pull_failed = True
elif label == "down":
outcome = FAILED_STOP
break
elif label in ("up", "up --build"):
outcome = FAILED_RESTART
break
if outcome == SUCCEEDED and pull_failed:
outcome = FAILED_PULL
color = OUTCOME_COLORS[outcome]
progress.update(
task,
description=f"[{color}]{OUTCOME_ICONS[outcome]} {name}[/{color}] {mode_tag}",
)
# Build log_lines for the text log writer
log_lines: list[str] = []
for step_label, sd in steps.items():
log_lines.append(
f"=== {step_label} (exit {sd['rc']}) ===\n{sd['out']}{sd['err']}"
)
if outcome != SUCCEEDED:
log_lines.append(
f"\nContainer logs ({name}):\n{_container_logs(workdir)}"
)
results.append({**project, "outcome": outcome, "log_lines": log_lines})
progress.advance(overall)
return results
# ── plain runner ───────────────────────────────────────────────────────────
def _run_update_plain(projects: list[dict[str, Any]], compose_cmd: list[str]) -> list[dict[str, Any]]:
total = len(projects)
git_count = sum(1 for p in projects if p["is_git"])
reg_count = total - git_count
print(f"docker-update — {total} project(s) · {git_count} git · {reg_count} registry\n")
results: list[dict[str, Any]] = []
for i, project in enumerate(projects, 1):
mode = "git" if project["is_git"] else "registry"
print(f"[{i}/{total}] {project['name']} ({mode}) — updating…", flush=True)
result = (
update_project_git(project, compose_cmd)
if project["is_git"]
else update_project_registry(project, compose_cmd)
)
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()
parts: list[str] = []
if groups[SUCCEEDED]:
parts.append(f"[bold green]✓ {len(groups[SUCCEEDED])} succeeded[/bold green]")
if groups[FAILED_PULL]:
parts.append(f"[bold yellow]⚠ {len(groups[FAILED_PULL])} failed to pull[/bold yellow]")
if groups[FAILED_STOP]:
parts.append(f"[bold red]✗ {len(groups[FAILED_STOP])} failed to stop[/bold red]")
if groups[FAILED_RESTART]:
parts.append(f"[bold red]✗ {len(groups[FAILED_RESTART])} failed to restart[/bold 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"
compose_cmd = _get_compose_cmd()
projects = discover_projects(compose_cmd)
if not projects:
print("No Docker Compose projects found.")
return
results = (
_run_update_rich(projects, compose_cmd)
if HAS_RICH
else _run_update_plain(projects, compose_cmd)
)
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."""
compose_cmd = _get_compose_cmd()
projects = discover_projects(compose_cmd)
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()