#!/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 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_sync = map_path(client, sync["local_root"]) syncthing_api = map_path(syncthing, sync["api_root"]) qb_api = map_path(qbittorrent, qb["api_root"]) require_same_path("Syncthing api_root/local_root", syncthing_api, client_sync) require_same_path("qBittorrent api_root/local_root", qb_api, client_qb) if client_qb.destination != client_sync.destination: raise CheckFailure( "qBittorrent and Syncthing 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())