Initial implementation: docker-update + docker-update-fzf
Rebuilt as a hybrid Python + Bash project to fix TTY/fzf conflicts over SSH.
Python (docker-update):
- `docker-update` — update all stacks with Rich progress (plain fallback)
- `docker-update log` — exec into fzf helper, or plain-text fallback with warning
- `docker-update debug` — dry-run discovery + optional fzf demo with fake data
- Writes .log + .json sidecar per run to /var/log/docker-update/
- Summary line + "docker-update log" hint printed after every update run
Bash (docker-update-fzf):
- Level 1: log-file picker (fzf, newest first, preview shows log content)
- Level 2: project browser grouped by outcome (✓ ⚠ ✗) with live ●/○ status dots
and live docker compose logs in preview pane
- Level 3: context-aware action menu (Start / Update / Stop)
- Proper < /dev/tty TTY handling throughout — no freezes over SSH
- Debug mode — all actions print what they would run but execute nothing
- Rich and fzf both have plain-text fallbacks with upfront user warning
- Temp files cleaned up via EXIT trap
README.md explains dependencies, installation paths, and usage.
https://claude.ai/code/session_01LtPxA1zDET2JQn6NYDDxKn
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# docker-update
|
||||
|
||||
Docker Compose stack manager for **sammons-server** (Ubuntu Server 24.04).
|
||||
|
||||
Discovers every Compose project on the host, updates it (git pull or registry
|
||||
pull as appropriate), logs results, and provides an interactive fzf browser for
|
||||
reviewing past runs and managing containers.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `docker-update` | Update every Compose stack, save log |
|
||||
| `docker-update log` | Browse past update logs in fzf |
|
||||
| `docker-update debug` | Dry-run — shows what would happen, no changes |
|
||||
|
||||
### docker-update
|
||||
|
||||
Detects whether each project is tracked by a git repo or uses registry images,
|
||||
then runs the appropriate update chain:
|
||||
|
||||
| Type | Steps |
|
||||
|---|---|
|
||||
| git repo | `git pull` → `docker compose up -d --build` |
|
||||
| registry | `docker compose down` → `docker compose pull` → `docker compose up -d` |
|
||||
|
||||
Pull failures are **non-fatal** — the service is restarted with its existing
|
||||
image and the run is marked `⚠ Failed to pull (restarted)`.
|
||||
Stop failures **abort** the chain for that project.
|
||||
|
||||
Rich progress bars are shown when `rich` is installed; plain-text output is
|
||||
used as a fallback.
|
||||
|
||||
Logs are saved to `/var/log/docker-update/docker-update-YYYY-MM-DD_HH-MM-SS.log`
|
||||
with a companion `.json` sidecar used by the fzf browser.
|
||||
|
||||
At the end of every run the summary line is printed and a reminder to use
|
||||
`docker-update log` is shown.
|
||||
|
||||
### docker-update log
|
||||
|
||||
Opens a two-level fzf menu:
|
||||
|
||||
1. **Log file picker** — lists past runs (newest first) with a preview of the
|
||||
log file on the right. Press **Enter** to open a run.
|
||||
|
||||
2. **Project browser** — shows every project from that run grouped by outcome,
|
||||
with live status dots (`●` running / `○` stopped). Hover over any project
|
||||
to see its live container logs in the preview pane. Press **Enter** to open
|
||||
the action menu.
|
||||
|
||||
3. **Action menu** — context-aware actions for the selected project:
|
||||
|
||||
| Action | Shown when |
|
||||
|---|---|
|
||||
| `▶ Start` | container is stopped |
|
||||
| `↺ Update` | always |
|
||||
| `■ Stop` | container is running |
|
||||
|
||||
After an action completes, press any key to return to the project browser
|
||||
(status dots refresh automatically).
|
||||
|
||||
Press **Esc** at any level to go back one level.
|
||||
|
||||
`fzf` and `docker-update-fzf` must both be in `PATH`. If either is missing,
|
||||
a plain-text fallback is used automatically after a warning.
|
||||
|
||||
### docker-update debug
|
||||
|
||||
Discovers all projects and prints what would happen without making any changes.
|
||||
Optionally opens the fzf browser with a fake log (randomised outcomes) so you
|
||||
can test the interface. All Start / Update / Stop actions in debug mode print
|
||||
what they *would* run but do nothing.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Notes |
|
||||
|---|---|
|
||||
| Python 3.8+ | must be at `/usr/bin/env python3` |
|
||||
| `rich` | optional — plain-text fallback if missing |
|
||||
| `fzf` | optional — plain-text fallback if missing |
|
||||
| Docker Compose v2 plugin | `docker compose` (not `docker-compose`) |
|
||||
|
||||
Install optional dependencies on Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt install fzf
|
||||
pip3 install rich
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### 1 — Copy scripts to PATH
|
||||
|
||||
```bash
|
||||
sudo cp docker-update /usr/local/bin/docker-update
|
||||
sudo cp docker-update-fzf /usr/local/bin/docker-update-fzf
|
||||
```
|
||||
|
||||
### 2 — Make them executable
|
||||
|
||||
```bash
|
||||
sudo chmod +x /usr/local/bin/docker-update
|
||||
sudo chmod +x /usr/local/bin/docker-update-fzf
|
||||
```
|
||||
|
||||
### 3 — Create the log directory
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/log/docker-update
|
||||
sudo chmod 755 /var/log/docker-update
|
||||
```
|
||||
|
||||
If you want a non-root user to run `docker-update` and write logs:
|
||||
|
||||
```bash
|
||||
sudo chown YOUR_USER:YOUR_USER /var/log/docker-update
|
||||
```
|
||||
|
||||
### 4 — Verify
|
||||
|
||||
```bash
|
||||
docker-update debug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File layout after installation
|
||||
|
||||
```
|
||||
/usr/local/bin/docker-update ← Python main script
|
||||
/usr/local/bin/docker-update-fzf ← Bash fzf helper (called by docker-update)
|
||||
/var/log/docker-update/ ← Log directory (created automatically)
|
||||
docker-update-YYYY-MM-DD_HH-MM-SS.log ← human-readable run log
|
||||
docker-update-YYYY-MM-DD_HH-MM-SS.json ← machine-readable sidecar (for fzf)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
sudo rm /usr/local/bin/docker-update /usr/local/bin/docker-update-fzf
|
||||
sudo rm -rf /var/log/docker-update # optional — deletes all logs
|
||||
```
|
||||
Executable
+563
@@ -0,0 +1,563 @@
|
||||
#!/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()
|
||||
Executable
+435
@@ -0,0 +1,435 @@
|
||||
#!/bin/bash
|
||||
# docker-update-fzf — fzf TUI helper for docker-update
|
||||
#
|
||||
# Called by docker-update for interactive log browsing and project management.
|
||||
# Do not invoke directly unless you know what you're doing.
|
||||
#
|
||||
# Usage:
|
||||
# docker-update-fzf log <log_dir>
|
||||
# docker-update-fzf debug <json_file>
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# ── global temp dir (cleaned up on exit) ───────────────────────────────────
|
||||
_DU_TMPDIR=""
|
||||
|
||||
_setup_tmpdir() {
|
||||
_DU_TMPDIR=$(mktemp -d /tmp/du-fzf-XXXXXX)
|
||||
trap '_cleanup' EXIT
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
[[ -n "$_DU_TMPDIR" && -d "$_DU_TMPDIR" ]] && rm -rf "$_DU_TMPDIR"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'docker-update-fzf: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── build_log_list ────────────────────────────────────────────────────────
|
||||
# Outputs tab-delimited lines:
|
||||
# DISPLAY_TEXT \t FULL_LOG_PATH
|
||||
# for every .log file in log_dir (newest first).
|
||||
build_log_list() {
|
||||
local log_dir="$1"
|
||||
python3 - "$log_dir" << 'PYEOF'
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
|
||||
log_dir = Path(sys.argv[1])
|
||||
logs = sorted(log_dir.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
for log in logs:
|
||||
# Parse run_id from filename: docker-update-YYYY-MM-DD_HH-MM-SS.log
|
||||
stem = log.stem # docker-update-YYYY-MM-DD_HH-MM-SS
|
||||
ts_raw = stem.replace("docker-update-", "", 1)
|
||||
# YYYY-MM-DD_HH-MM-SS -> "YYYY-MM-DD HH:MM:SS"
|
||||
if "_" in ts_raw:
|
||||
date_part, time_part = ts_raw.split("_", 1)
|
||||
time_fmt = time_part.replace("-", ":")
|
||||
ts_display = f"{date_part} {time_fmt}"
|
||||
else:
|
||||
ts_display = ts_raw
|
||||
|
||||
# Count projects from JSON sidecar if available
|
||||
json_path = log.with_suffix(".json")
|
||||
count = ""
|
||||
if json_path.exists():
|
||||
try:
|
||||
data = json.loads(json_path.read_text())
|
||||
n = len(data.get("projects", []))
|
||||
count = f" ({n} projects)"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
display = f" {ts_display}{count}"
|
||||
print(f"{display}\t{log}")
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ── build_project_list ────────────────────────────────────────────────────
|
||||
# Outputs tab-delimited lines:
|
||||
# DISPLAY_TEXT \t NAME \t WORKDIR \t OUTCOME \t IS_GIT
|
||||
# Section-header lines use NAME=HEADER and empty WORKDIR/OUTCOME/IS_GIT.
|
||||
build_project_list() {
|
||||
local json_file="$1"
|
||||
python3 - "$json_file" << 'PYEOF'
|
||||
import json, subprocess, sys
|
||||
|
||||
json_file = sys.argv[1]
|
||||
with open(json_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Get currently running projects in one fast call
|
||||
running_names: set[str] = set()
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "compose", "ls", "--format", "json"],
|
||||
capture_output=True, text=True, timeout=6
|
||||
)
|
||||
for p in json.loads(r.stdout or "[]"):
|
||||
if "running" in p.get("Status", "").lower():
|
||||
running_names.add(p.get("Name", ""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
OUTCOMES: dict[str, tuple[str, str, str]] = {
|
||||
"succeeded": ("✓", "\033[0;32m", "Succeeded"),
|
||||
"failed_pull": ("⚠", "\033[0;33m", "Failed to pull (restarted)"),
|
||||
"failed_stop": ("✗", "\033[0;31m", "Failed to stop"),
|
||||
"failed_restart": ("✗", "\033[0;31m", "Failed to restart"),
|
||||
}
|
||||
ORDER = ["succeeded", "failed_pull", "failed_stop", "failed_restart"]
|
||||
RESET = "\033[0m"
|
||||
DIM = "\033[2m"
|
||||
BOLD = "\033[1m"
|
||||
|
||||
# Group projects by outcome
|
||||
groups: dict[str, list] = {o: [] for o in ORDER}
|
||||
for p in data.get("projects", []):
|
||||
outcome = p.get("outcome", "succeeded")
|
||||
if outcome in groups:
|
||||
groups[outcome].append(p)
|
||||
|
||||
for outcome in ORDER:
|
||||
items = groups[outcome]
|
||||
if not items:
|
||||
continue
|
||||
|
||||
icon, color, label = OUTCOMES[outcome]
|
||||
|
||||
# Section header (not selectable — bash loop detects NAME==HEADER)
|
||||
bar = "─" * max(0, 44 - len(label))
|
||||
header = f" {DIM}── {label} {bar}{RESET}"
|
||||
print(f"{header}\tHEADER\t\t\t")
|
||||
|
||||
for p in items:
|
||||
name = p["name"]
|
||||
workdir = p.get("workdir", "")
|
||||
is_git = "true" if p.get("is_git", False) else "false"
|
||||
dot = "●" if name in running_names else "○"
|
||||
dot_c = f"{DIM}{dot}{RESET}"
|
||||
name_c = f"{color}{name}{RESET}"
|
||||
icon_c = f"{color}{icon}{RESET}"
|
||||
display = f" {icon_c} {dot_c} {name_c}"
|
||||
print(f"{display}\t{name}\t{workdir}\t{outcome}\t{is_git}")
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ── write_preview_script ──────────────────────────────────────────────────
|
||||
# Writes a small bash script to $_DU_TMPDIR/preview.sh that fzf can call.
|
||||
# The script receives the full tab-delimited line as $1 and streams docker logs.
|
||||
write_preview_script() {
|
||||
local script="$_DU_TMPDIR/preview.sh"
|
||||
cat > "$script" << 'PREVIEW_EOF'
|
||||
#!/bin/bash
|
||||
line="$1"
|
||||
name=$(printf '%s' "$line" | cut -f2)
|
||||
workdir=$(printf '%s' "$line" | cut -f3)
|
||||
|
||||
if [[ "$name" == "HEADER" || -z "$workdir" ]]; then
|
||||
printf '\n (hover over a project to see its live container logs)\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -d "$workdir" ]]; then
|
||||
printf 'Workdir not found: %s\n' "$workdir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '\033[1m── live logs: %s ──\033[0m\n\n' "$name"
|
||||
cd "$workdir" && docker compose logs --tail=80 --no-color 2>&1 \
|
||||
|| printf 'No logs available\n'
|
||||
PREVIEW_EOF
|
||||
chmod +x "$script"
|
||||
echo "$script"
|
||||
}
|
||||
|
||||
# ── write_log_preview_script ──────────────────────────────────────────────
|
||||
# Receives tab-delimited log-list line ($1) and cats the log file.
|
||||
write_log_preview_script() {
|
||||
local script="$_DU_TMPDIR/log_preview.sh"
|
||||
cat > "$script" << 'LOGPREVIEW_EOF'
|
||||
#!/bin/bash
|
||||
path=$(printf '%s' "$1" | cut -f2)
|
||||
if [[ -z "$path" || ! -f "$path" ]]; then
|
||||
printf '(no log file found)\n'
|
||||
exit 1
|
||||
fi
|
||||
cat "$path"
|
||||
LOGPREVIEW_EOF
|
||||
chmod +x "$script"
|
||||
echo "$script"
|
||||
}
|
||||
|
||||
# ── show_action_menu ──────────────────────────────────────────────────────
|
||||
# Args: name workdir is_git is_debug
|
||||
# Echoes the chosen action line; returns 1 on Esc.
|
||||
show_action_menu() {
|
||||
local name="$1" workdir="$2" is_git="$3" is_debug="$4"
|
||||
|
||||
# Live status check
|
||||
local running_count stopped_count
|
||||
running_count=$(cd "$workdir" 2>/dev/null \
|
||||
&& docker compose ps -q --status running 2>/dev/null | wc -l \
|
||||
|| echo 0)
|
||||
stopped_count=$(cd "$workdir" 2>/dev/null \
|
||||
&& docker compose ps -q 2>/dev/null | wc -l \
|
||||
|| echo 0)
|
||||
|
||||
local dot="○"
|
||||
[[ "$running_count" -gt 0 ]] && dot="●"
|
||||
|
||||
local actions=""
|
||||
[[ "$running_count" -eq 0 ]] && actions+=" ▶ Start\n"
|
||||
actions+=" ↺ Update\n"
|
||||
[[ "$running_count" -gt 0 ]] && actions+=" ■ Stop\n"
|
||||
|
||||
local debug_note=""
|
||||
[[ "$is_debug" == "true" ]] && debug_note=" \033[33m[DEBUG — no real changes]\033[0m"
|
||||
|
||||
local selected
|
||||
selected=$(printf "%b" "$actions" | \
|
||||
fzf \
|
||||
--reverse \
|
||||
--no-info \
|
||||
--ansi \
|
||||
--prompt=" Action > " \
|
||||
--header="$(printf " %s %s%s" "$dot" "$name" "$debug_note")" \
|
||||
--bind="esc:abort" \
|
||||
< /dev/tty) || return 1
|
||||
|
||||
echo "$selected"
|
||||
}
|
||||
|
||||
# ── execute_action ────────────────────────────────────────────────────────
|
||||
# Args: action_line name workdir is_git is_debug
|
||||
execute_action() {
|
||||
local action_line="$1" name="$2" workdir="$3" is_git="$4" is_debug="$5"
|
||||
|
||||
# Extract verb from display line " ▶ Start" → "Start"
|
||||
local verb
|
||||
verb=$(echo "$action_line" | awk '{print $NF}')
|
||||
|
||||
printf '\n\033[2m%s\033[0m\n' "$(printf '─%.0s' {1..60})"
|
||||
|
||||
if [[ "$is_debug" == "true" ]]; then
|
||||
printf ' \033[33m[DEBUG]\033[0m Would run: \033[1m%s\033[0m on \033[1m%s\033[0m\n\n' \
|
||||
"$verb" "$name"
|
||||
case "$verb" in
|
||||
Start)
|
||||
printf ' \033[2mcd %s && docker compose up -d\033[0m\n' "$workdir"
|
||||
;;
|
||||
Stop)
|
||||
printf ' \033[2mcd %s && docker compose down\033[0m\n' "$workdir"
|
||||
;;
|
||||
Update)
|
||||
if [[ "$is_git" == "true" ]]; then
|
||||
printf ' \033[2mcd %s && git pull && docker compose up -d --build\033[0m\n' "$workdir"
|
||||
else
|
||||
printf ' \033[2mcd %s && docker compose down && docker compose pull && docker compose up -d\033[0m\n' "$workdir"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
else
|
||||
printf ' \033[1m%s\033[0m → \033[1m%s\033[0m\n\n' "$name" "$verb"
|
||||
case "$verb" in
|
||||
Start)
|
||||
(cd "$workdir" && docker compose up -d 2>&1) || true
|
||||
;;
|
||||
Stop)
|
||||
(cd "$workdir" && docker compose down 2>&1) || true
|
||||
;;
|
||||
Update)
|
||||
if [[ "$is_git" == "true" ]]; then
|
||||
printf ' \033[2m→ git pull\033[0m\n'
|
||||
(cd "$workdir" && git pull 2>&1) || true
|
||||
printf '\n \033[2m→ docker compose up -d --build\033[0m\n'
|
||||
(cd "$workdir" && docker compose up -d --build 2>&1) || true
|
||||
else
|
||||
printf ' \033[2m→ docker compose down\033[0m\n'
|
||||
(cd "$workdir" && docker compose down 2>&1) || true
|
||||
printf '\n \033[2m→ docker compose pull\033[0m\n'
|
||||
(cd "$workdir" && docker compose pull 2>&1) || true
|
||||
printf '\n \033[2m→ docker compose up -d\033[0m\n'
|
||||
(cd "$workdir" && docker compose up -d 2>&1) || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
printf '\n\033[2m%s\033[0m\n' "$(printf '─%.0s' {1..60})"
|
||||
printf ' Press any key to return…'
|
||||
read -r -s -n1 < /dev/tty
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
# ── show_project_list ─────────────────────────────────────────────────────
|
||||
# Args: json_file is_debug
|
||||
# Runs the project selection + action loop. Returns when user presses Esc.
|
||||
show_project_list() {
|
||||
local json_file="$1" is_debug="$2"
|
||||
local preview_script
|
||||
preview_script=$(write_preview_script)
|
||||
|
||||
while true; do
|
||||
local list
|
||||
list=$(build_project_list "$json_file") || {
|
||||
printf 'Failed to build project list\n' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ -z "$list" ]]; then
|
||||
printf '\n No projects found in log.\n'
|
||||
printf ' Press any key…'
|
||||
read -r -s -n1 < /dev/tty
|
||||
return 0
|
||||
fi
|
||||
|
||||
local selected
|
||||
selected=$(printf '%s\n' "$list" | \
|
||||
fzf \
|
||||
--reverse \
|
||||
--ansi \
|
||||
--no-info \
|
||||
--delimiter=$'\t' \
|
||||
--with-nth=1 \
|
||||
--prompt=" Project > " \
|
||||
--header=$' \033[2mEnter: manage Esc: back\033[0m' \
|
||||
--preview="$preview_script {}" \
|
||||
--preview-window="right:50%:wrap" \
|
||||
--bind="esc:abort" \
|
||||
< /dev/tty) || return 0 # Esc → back
|
||||
|
||||
local item_type
|
||||
item_type=$(printf '%s' "$selected" | cut -f2)
|
||||
# Skip section-header lines
|
||||
[[ "$item_type" == "HEADER" ]] && continue
|
||||
|
||||
local name workdir outcome is_git
|
||||
name=$(printf '%s' "$selected" | cut -f2)
|
||||
workdir=$(printf '%s' "$selected" | cut -f3)
|
||||
outcome=$(printf '%s' "$selected" | cut -f4)
|
||||
is_git=$(printf '%s' "$selected" | cut -f5)
|
||||
|
||||
[[ -z "$name" || -z "$workdir" ]] && continue
|
||||
|
||||
local action
|
||||
action=$(show_action_menu "$name" "$workdir" "$is_git" "$is_debug") || continue
|
||||
|
||||
execute_action "$action" "$name" "$workdir" "$is_git" "$is_debug"
|
||||
# Loop: refresh list and show again
|
||||
done
|
||||
}
|
||||
|
||||
# ── tui_log ───────────────────────────────────────────────────────────────
|
||||
tui_log() {
|
||||
local log_dir="${1:-/var/log/docker-update}"
|
||||
|
||||
[[ -d "$log_dir" ]] || die "Log directory not found: $log_dir"
|
||||
|
||||
local log_preview_script
|
||||
log_preview_script=$(write_log_preview_script)
|
||||
|
||||
while true; do
|
||||
local log_list
|
||||
log_list=$(build_log_list "$log_dir") || break
|
||||
|
||||
if [[ -z "$log_list" ]]; then
|
||||
printf '\n No log files found in %s\n' "$log_dir"
|
||||
break
|
||||
fi
|
||||
|
||||
local selected_log_line
|
||||
selected_log_line=$(printf '%s\n' "$log_list" | \
|
||||
fzf \
|
||||
--reverse \
|
||||
--ansi \
|
||||
--no-info \
|
||||
--delimiter=$'\t' \
|
||||
--with-nth=1 \
|
||||
--prompt=" Log file > " \
|
||||
--header=$' \033[2mEnter: browse projects Esc: exit\033[0m' \
|
||||
--preview="$log_preview_script {}" \
|
||||
--preview-window="right:60%:wrap" \
|
||||
--bind="esc:abort" \
|
||||
< /dev/tty) || break # Esc → exit
|
||||
|
||||
local log_path
|
||||
log_path=$(printf '%s' "$selected_log_line" | cut -f2)
|
||||
local json_path="${log_path%.log}.json"
|
||||
|
||||
if [[ ! -f "$json_path" ]]; then
|
||||
printf '\n \033[33mNote:\033[0m No JSON sidecar found for %s\n' \
|
||||
"$(basename "$log_path")"
|
||||
printf ' (Log was created before JSON sidecars were supported.)\n'
|
||||
printf ' Press any key…'
|
||||
read -r -s -n1 < /dev/tty
|
||||
printf '\n'
|
||||
continue
|
||||
fi
|
||||
|
||||
# Level 2+: browse projects from this log
|
||||
show_project_list "$json_path" "false"
|
||||
done
|
||||
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
# ── tui_debug ─────────────────────────────────────────────────────────────
|
||||
tui_debug() {
|
||||
local json_file="$1"
|
||||
[[ -f "$json_file" ]] || die "JSON file not found: $json_file"
|
||||
|
||||
printf '\n'
|
||||
printf ' \033[33m┌──────────────────────────────────────────────────┐\033[0m\n'
|
||||
printf ' \033[33m│ DEBUG MODE — no real actions will be performed │\033[0m\n'
|
||||
printf ' \033[33m└──────────────────────────────────────────────────┘\033[0m\n'
|
||||
printf '\n'
|
||||
sleep 1
|
||||
|
||||
show_project_list "$json_file" "true"
|
||||
|
||||
# Clean up temp JSON written by docker-update debug
|
||||
rm -f "$json_file"
|
||||
}
|
||||
|
||||
# ── main ──────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
local mode="${1:-}"
|
||||
shift || true
|
||||
|
||||
_setup_tmpdir
|
||||
|
||||
case "$mode" in
|
||||
log) tui_log "$@" ;;
|
||||
debug) tui_debug "$@" ;;
|
||||
*)
|
||||
printf 'Usage: docker-update-fzf {log|debug} [args]\n' >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user