#!/usr/bin/env python3 """Validate a host's Archive Control Docker deployment before it is used. 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. The hard-link probe creates uniquely named, empty files in the configured qB and automatic-route directories and removes them afterward. """ from __future__ import annotations import argparse import json import os import stat import subprocess import sys 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 container_user(container: dict[str, Any]) -> str | None: value = container.get("Config", {}).get("User") return value if isinstance(value, str) and value else None 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 mounted_config(container: str, config: str) -> dict[str, Any]: """Read normalized non-secret config through the client image itself.""" program = '''import json,sys from pathlib import Path from archive_clients.config import ClientConfig config=ClientConfig.load(Path(sys.argv[1])) value={"shared_token_file":str(config.shared_token_file)} value["qbittorrent"]={"api_root":str(config.qbittorrent.api_root),"local_root":str(config.qbittorrent.local_root),"password_file":str(config.qbittorrent.password_file)} value["syncthing"]={"api_root":str(config.syncthing.api_root),"local_root":str(config.syncthing.local_root),"api_key_file":str(config.syncthing.api_key_file),"local_path_overrides":{str(api):str(local) for api,local in config.syncthing.local_path_overrides}} print(json.dumps(value,sort_keys=True))''' 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"cannot load mounted client config: {detail}") try: value = json.loads(result.stdout) except json.JSONDecodeError as exc: raise CheckFailure("mounted client config output is invalid") from exc if not isinstance(value, dict): raise CheckFailure("mounted client config is invalid") return value 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 run_hardlink_probe( container: str, qb_root: str, route_root: str, user: str | None = None, ) -> None: """Prove that the daemon can hard-link from qB data into automatic routes.""" program = r'''import os,sys,tempfile source_root,route_root=sys.argv[1:] source_path=None destination_path=None try: source_fd,source_path=tempfile.mkstemp(prefix=".archive-control-preflight-",dir=source_root) os.close(source_fd) destination_fd,destination_path=tempfile.mkstemp(prefix=".archive-control-preflight-",dir=route_root) os.close(destination_fd) os.unlink(destination_path) os.link(source_path,destination_path) source_stat=os.stat(source_path) destination_stat=os.stat(destination_path) if source_stat.st_dev != destination_stat.st_dev or source_stat.st_ino != destination_stat.st_ino: raise RuntimeError("link(2) did not produce the same filesystem inode") print("hard-link staging probe passed") finally: for path in (destination_path,source_path): if path: try: os.unlink(path) except FileNotFoundError: pass ''' command = ["docker", "exec"] if user: command.extend(["--user", user]) command.extend([container, "python", "-c", program, qb_root, route_root]) result = subprocess.run( command, check=False, text=True, capture_output=True, ) if result.returncode: detail = result.stderr.strip() or result.stdout.strip() or "failed" raise CheckFailure(f"hard-link staging probe failed: {detail}") print(result.stdout.strip()) def run_existing_route_directory_check(container: str, config: str) -> None: """Reject a mount migration that hides an already configured route folder.""" program = r'''import json,os,sys from pathlib import Path,PurePosixPath from archive_clients.config import ClientConfig from archive_clients.syncthing import SyncthingHttp config=ClientConfig.load(Path(sys.argv[1])) transport=SyncthingHttp(config.syncthing) route_root=config.syncthing.api_root / "routes" missing=[] checked=[] for folder in transport.get_json("/rest/config").get("folders",[]): if not isinstance(folder,dict) or not str(folder.get("label","")).startswith("archive-control:"): continue raw=folder.get("path") if not isinstance(raw,str) or not raw: continue api=PurePosixPath(raw) if api.parts and api.parts[0] == "~": api=config.syncthing.api_root.joinpath(*api.parts[1:]) try: api.relative_to(route_root) except ValueError: continue local=config.syncthing.roots.api_to_local(api.as_posix()) checked.append(str(local)) if not local.is_dir(): missing.append({"folder_id":folder.get("id",""),"path":str(local)}) print(json.dumps({"checked":checked,"missing":missing},sort_keys=True)) raise SystemExit(1 if missing else 0) ''' 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( "an existing Archive Control Syncthing route directory is missing; " f"complete the route-directory migration before starting jobs: {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: client = docker_inspect(args.client_container) syncthing = docker_inspect(args.syncthing_container) qbittorrent = docker_inspect(args.qbittorrent_container) configured_host_path = map_path(client, args.container_config).source if configured_host_path.resolve(strict=False) != args.client_config.resolve(strict=False): raise CheckFailure( "--client-config does not match the file mounted into the client" ) config = mounted_config(args.client_container, args.container_config) qb = config["qbittorrent"] sync = config["syncthing"] 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" ) run_hardlink_probe( args.client_container, qb["local_root"], route_local_path(sync), container_user(client), ) run_existing_route_directory_check( args.client_container, args.container_config ) 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) as exc: print(f"preflight failed: {exc}", file=sys.stderr) return 1 print("preflight passed: bind mappings, hard-link staging, secrets, filesystem capabilities, and local APIs are healthy") return 0 if __name__ == "__main__": raise SystemExit(main())