diff --git a/deploy/production/README.md b/deploy/production/README.md index 4b760a7..15d4ada 100644 --- a/deploy/production/README.md +++ b/deploy/production/README.md @@ -48,13 +48,21 @@ the folder's normalized API-visible **path** as the override key (for example, Syncthing `~/Downloads/Sync` becomes `/var/syncthing/Downloads/Sync`); do not use the folder ID. -Before starting a stack, validate it with: +Before starting a stack, validate its config and then run the generic host +preflight with that machine's own paths and container names: ```sh docker compose config docker compose run --rm archive-client --check-config +python3 scripts/preflight-deployment.py --help ``` The check is fail-fast and performs local permission, filesystem, sparse-file, hard-link, and reflink probes. Normal startup additionally probes the local qBittorrent and Syncthing APIs before registration. + +`scripts/preflight-deployment.py` is deliberately topology-agnostic: pass the +host's `client.toml` and its client, qBittorrent, and Syncthing container names. +It resolves Docker mounts rather than assuming project names or host paths. It +also catches a common fatal error: a Syncthing `api_root` that names its config +volume rather than the mounted shared-data tree. diff --git a/deploy/production/helium/README.md b/deploy/production/helium/README.md index 62347ef..b691b1f 100644 --- a/deploy/production/helium/README.md +++ b/deploy/production/helium/README.md @@ -16,3 +16,19 @@ placements can hardlink rather than make a full copy. `/media/Data2/Downloading` already contains unrelated files. The fresh qB instance must not import or manage them; only Archive Control-created torrents are managed. + +Before the first client start, run the repository preflight on the target host +(replace container names if that host uses different compose project names): + +```sh +python3 scripts/preflight-deployment.py \ + --client-config ~/Repositories/compose/ArchiveControl-archive/client.toml \ + --client-container archive-control-helium-archive-client-1 \ + --syncthing-container helium-syncthing-syncthing-1 \ + --qbittorrent-container helium-qbittorrent-qbittorrent-1 +``` + +It is read-only: it checks the configured API roots resolve to the same host +paths as the client roots, that both data roots share one client bind mount for +hardlinks, secret-file permissions, filesystem capabilities, and the local qB +and Syncthing APIs. It does not send the control token or modify any container. diff --git a/deploy/production/helium/client.toml b/deploy/production/helium/client.toml index f9f50d2..0313e60 100644 --- a/deploy/production/helium/client.toml +++ b/deploy/production/helium/client.toml @@ -37,6 +37,6 @@ local_root = "/data/storage/Downloading" [syncthing] endpoint = "http://127.0.0.1:8384" api_key_file = "/run/secrets/syncthing_api_key" -api_root = "/var/syncthing" +api_root = "/var/syncthing/Sync" local_root = "/data/storage/Sync" advertised_addresses = ["dynamic"] diff --git a/scripts/preflight-deployment.py b/scripts/preflight-deployment.py new file mode 100755 index 0000000..6d66b7b --- /dev/null +++ b/scripts/preflight-deployment.py @@ -0,0 +1,163 @@ +#!/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()) diff --git a/tests/test_deployment_preflight.py b/tests/test_deployment_preflight.py new file mode 100644 index 0000000..8e5f817 --- /dev/null +++ b/tests/test_deployment_preflight.py @@ -0,0 +1,41 @@ +import importlib.util +import sys +import unittest +from pathlib import Path + + +_SCRIPT = Path(__file__).parents[1] / "scripts" / "preflight-deployment.py" +_SPEC = importlib.util.spec_from_file_location("deployment_preflight", _SCRIPT) +assert _SPEC is not None and _SPEC.loader is not None +preflight = importlib.util.module_from_spec(_SPEC) +sys.modules[_SPEC.name] = preflight +_SPEC.loader.exec_module(preflight) + + +class DeploymentPreflightTests(unittest.TestCase): + def test_maps_nested_container_path_to_host_source(self): + container = { + "Mounts": [ + {"Type": "bind", "Source": "/srv/data", "Destination": "/data"}, + {"Type": "bind", "Source": "/srv/sync", "Destination": "/data/Sync"}, + ] + } + mapping = preflight.map_path(container, "/data/Sync/routes/route-1") + self.assertEqual(mapping.source, Path("/srv/sync/routes/route-1")) + self.assertEqual(str(mapping.destination), "/data/Sync") + + def test_rejects_api_and_client_roots_from_different_host_paths(self): + with self.assertRaisesRegex(preflight.CheckFailure, "host paths differ"): + preflight.require_same_path( + "Syncthing api_root/local_root", + preflight.Mapping(Path("/srv/syncthing-config/routes"), Path("/var/syncthing")), + preflight.Mapping(Path("/srv/sync/routes"), Path("/data/storage")), + ) + + def test_rejects_unmounted_container_path(self): + with self.assertRaisesRegex(preflight.CheckFailure, "not backed"): + preflight.map_path({"Mounts": []}, "/missing") + + +if __name__ == "__main__": + unittest.main()