#!/usr/bin/env python3 """Validate a host's Archive Control Docker deployment without changing it. This intentionally relies only on the config file and the named containers on the host where it runs. It never reads secret contents or calls a remote control daemon. """ from __future__ import annotations import argparse import json import os import stat import subprocess import sys import tomllib from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import Any class CheckFailure(RuntimeError): pass @dataclass(frozen=True) class Mapping: source: Path destination: PurePosixPath def docker_inspect(container: str) -> dict[str, Any]: result = subprocess.run( ["docker", "inspect", container], check=False, text=True, capture_output=True, ) if result.returncode: raise CheckFailure(f"cannot inspect container {container!r}") try: value = json.loads(result.stdout) return value[0] except (json.JSONDecodeError, IndexError, TypeError) as exc: raise CheckFailure(f"invalid Docker inspection for {container!r}") from exc def map_path(container: dict[str, Any], path: str) -> Mapping: candidate = PurePosixPath(path) matches: list[tuple[int, Mapping]] = [] for mount in container.get("Mounts", []): if mount.get("Type") not in {"bind", "volume"}: continue source, destination = mount.get("Source"), mount.get("Destination") if not isinstance(source, str) or not isinstance(destination, str): continue destination_path = PurePosixPath(destination) try: relative = candidate.relative_to(destination_path) except ValueError: continue matches.append((len(destination_path.parts), Mapping( Path(source).joinpath(*relative.parts), destination_path ))) if not matches: raise CheckFailure(f"{path} is not backed by a Docker bind/volume mount") return max(matches, key=lambda item: item[0])[1] def require_same_path(label: str, left: Mapping, right: Mapping) -> None: if left.source.resolve(strict=False) != right.source.resolve(strict=False): raise CheckFailure( f"{label} host paths differ: {left.source} != {right.source}" ) def require_regular_secret(path: Path) -> None: try: mode = path.stat().st_mode except OSError as exc: raise CheckFailure(f"secret is unavailable: {path}") from exc if not stat.S_ISREG(mode) or stat.S_IMODE(mode) & 0o077 or path.stat().st_size == 0: raise CheckFailure(f"secret must be a non-empty mode-0600 regular file: {path}") def route_local_path(syncthing: dict[str, Any]) -> str: """Resolve the fixed, future automatic ``routes/`` path in the client.""" api_root = PurePosixPath(syncthing["api_root"]) candidate = api_root / "routes" overrides = syncthing.get("local_path_overrides", {}) if not isinstance(overrides, dict): raise CheckFailure("syncthing.local_path_overrides must be a table") matches: list[tuple[int, PurePosixPath, str]] = [] for raw_api, raw_local in overrides.items(): if not isinstance(raw_api, str) or not isinstance(raw_local, str): raise CheckFailure("syncthing.local_path_overrides entries are invalid") root = PurePosixPath(raw_api) try: relative = candidate.relative_to(root) except ValueError: continue matches.append((len(root.parts), relative, raw_local)) if matches: _, relative, local_root = max(matches, key=lambda item: item[0]) return str(Path(local_root).joinpath(*relative.parts)) try: relative = candidate.relative_to(api_root) except ValueError as exc: raise CheckFailure("future route path is outside syncthing.api_root") from exc return str(Path(syncthing["local_root"]).joinpath(*relative.parts)) def run_client_check(container: str, config: str, flag: str) -> None: result = subprocess.run( ["docker", "exec", container, "archive-client", "--config", config, flag], check=False, text=True, capture_output=True, ) if result.returncode: detail = result.stderr.strip() or result.stdout.strip() or "failed" raise CheckFailure(f"client {flag} failed: {detail}") print(result.stdout.strip()) def run_service_check(container: str, config: str) -> None: program = '''import json,sys from pathlib import Path from archive_clients.config import ClientConfig from archive_clients.probes import probe_root from archive_clients.services import probe_qbittorrent,probe_syncthing config=ClientConfig.load(Path(sys.argv[1])) sync_probe=probe_root(config.syncthing.local_root) probes=[probe_qbittorrent(config.qbittorrent),probe_syncthing(config.syncthing,sparse_supported=sync_probe.sparse_files)] print(json.dumps({"services":[{"service":p.service,"state":p.state,"detail":p.detail,"version":p.version,"device_id":p.device_id} for p in probes]},sort_keys=True)) raise SystemExit(0 if all(p.state == 1 for p in probes) else 1)''' result = subprocess.run( ["docker", "exec", container, "python", "-c", program, config], check=False, text=True, capture_output=True, ) if result.returncode: detail = result.stderr.strip() or result.stdout.strip() or "failed" raise CheckFailure(f"client local-service probe failed: {detail}") print(result.stdout.strip()) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Read-only Archive Control Docker deployment preflight" ) parser.add_argument("--client-config", type=Path, required=True, help="host path to client.toml") parser.add_argument("--client-container", required=True) parser.add_argument("--syncthing-container", required=True) parser.add_argument("--qbittorrent-container", required=True) parser.add_argument("--container-config", default="/etc/archive-control/client.toml", help="client.toml path inside the client container") args = parser.parse_args(argv) try: with args.client_config.open("rb") as source: config = tomllib.load(source) qb = config["qbittorrent"] sync = config["syncthing"] client = docker_inspect(args.client_container) syncthing = docker_inspect(args.syncthing_container) qbittorrent = docker_inspect(args.qbittorrent_container) client_qb = map_path(client, qb["local_root"]) client_route = map_path(client, route_local_path(sync)) syncthing_route = map_path( syncthing, str(PurePosixPath(sync["api_root"]) / "routes") ) qb_api = map_path(qbittorrent, qb["api_root"]) require_same_path( "future Syncthing routes/local route mapping", syncthing_route, client_route, ) require_same_path("qBittorrent api_root/local_root", qb_api, client_qb) if client_qb.destination != client_route.destination: raise CheckFailure( "qBittorrent and future route roots use separate client bind " "mounts; hard-link staging would be unavailable" ) for key in ("shared_token_file",): require_regular_secret(map_path(client, config[key]).source) for service, key in ((qb, "password_file"), (sync, "api_key_file")): require_regular_secret(map_path(client, service[key]).source) run_client_check(args.client_container, args.container_config, "--check-config") run_service_check(args.client_container, args.container_config) except (CheckFailure, KeyError, OSError, tomllib.TOMLDecodeError) as exc: print(f"preflight failed: {exc}", file=sys.stderr) return 1 print("preflight passed: bind mappings, secrets, filesystem capabilities, and local APIs are healthy") return 0 if __name__ == "__main__": raise SystemExit(main())