Files
backup-docker-to-local/src/baudolo/backup/compose.py
T
Kevin Veen-BirkenbachandClaude Opus 5 2129c5e362 build(lint): gate make test on a clean ruff run
ruff was never wired into this repository: no target, no CI step, no pin.
It reported 45 findings across sources and tests, so nothing enforced
what the codebase already mostly followed.

Adds `make ruff` (check + format --check), `make ruff-fix`, and `make
lint` as its alias, and makes `make test` run lint as a fourth parallel
spur. The CI workflow calls `make test`, so it is covered there too. The
linter is pinned in a `lint` extra: a ruff minor bump changes which rules
fire, and with the suite gating on a clean run an unpinned linter would
fail it on an unrelated day.

The 45 findings are fixed rather than configured away. Three needed a
decision instead of the mechanical fix:

- The generation timestamp keeps its local wall clock (DTZ005 waived).
  Generations sort by that name, and UTC would order new ones before the
  existing ones wherever the offset is positive - "newest generation" is
  what every restore path selects on.
- The per-volume `copy` closure now binds volume_name and vol_dir as
  default arguments (B023). It only worked because it is called inside
  the same iteration.
- The two CLI top-level handlers keep their blind except (BLE001
  waived): turning any failure into exit 1 is what a CLI boundary is
  for. The two in run.py did not need it and were narrowed to what they
  actually catch.

Also drops the comments that restate the code: the section banners in
restore/__main__.py, the filename repeated as line 1 of nine test files,
step narration above the statement it narrates, and a block in app.py
documenting parameters that had moved to another module. What names a
trip-wire stays - the snapshot destination rule, the mysql-binary
absence in MariaDB 11 images, the session-scoped FOREIGN_KEY_CHECKS, the
spooled temp file for multi-GB dumps, and the negative control that
loses its discriminating power if it ever passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 04:35:50 +02:00

99 lines
2.8 KiB
Python

from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
def _build_compose_cmd(project_dir: str, passthrough: list[str]) -> list[str]:
"""
Build the compose command for this project directory.
Policy:
- If `compose` wrapper exists (Infinito.Nexus): use it and delegate ALL logic to it.
- Else: use plain `docker compose` with --chdir.
- NO custom compose file/env detection in this project.
"""
pdir = Path(project_dir).resolve()
wrapper = shutil.which("compose")
if wrapper:
# "--" ensures wrapper stops parsing its own args.
return [wrapper, "--chdir", str(pdir), "--", *passthrough]
docker = shutil.which("docker")
if docker:
return [docker, "compose", "--chdir", str(pdir), *passthrough]
raise RuntimeError("Neither 'compose' nor 'docker' found in PATH")
def _find_compose_file(project_dir: str) -> Path | None:
"""
Detect a compose file in `project_dir` (case-insensitive).
Supported names:
- compose.yml / compose.yaml
- docker-compose.yml / docker-compose.yaml
"""
pdir = Path(project_dir)
if not pdir.is_dir():
return None
# Map lowercase filename -> actual Path (preserves original casing)
by_lower = {p.name.lower(): p for p in pdir.iterdir() if p.is_file()}
# Preferred order (policy decision)
candidates = [
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
]
for name in candidates:
found = by_lower.get(name)
if found is not None:
return found
return None
def hard_restart_docker_services(dir_path: str) -> None:
print(f"Hard restart compose services in: {dir_path}", flush=True)
down_cmd = _build_compose_cmd(dir_path, ["down"])
up_cmd = _build_compose_cmd(dir_path, ["up", "-d"])
print(">>> " + " ".join(down_cmd), flush=True)
subprocess.run(down_cmd, check=True)
print(">>> " + " ".join(up_cmd), flush=True)
subprocess.run(up_cmd, check=True)
def handle_docker_compose_services(
parent_directory: str,
hard_restart_required: list[str],
) -> None:
for entry in os.scandir(parent_directory):
if not entry.is_dir():
continue
dir_path = entry.path
name = os.path.basename(dir_path)
print(f"Checking directory: {dir_path}", flush=True)
compose_file = _find_compose_file(dir_path)
if compose_file is None:
print("No supported compose file found. Skipping.", flush=True)
continue
if name in hard_restart_required:
print(f"{name}: hard restart required.", flush=True)
hard_restart_docker_services(dir_path)
else:
print(f"{name}: no restart required.", flush=True)