diff --git a/docs/deployment-preflight.md b/docs/deployment-preflight.md index dc88353..aeacc3a 100644 --- a/docs/deployment-preflight.md +++ b/docs/deployment-preflight.md @@ -2,9 +2,11 @@ Run the deployment preflight on every new node after its qBittorrent and Syncthing stacks are running, but before starting the Archive Control daemon -for normal operation or creating any routes/jobs. It is read-only: it does not -contact the control daemon, alter Syncthing/qBittorrent state, or print secret -contents. +for normal operation or creating any routes/jobs. It does not contact the +control daemon, alter Syncthing/qBittorrent state, or print secret contents. +It does create and remove two unique, zero-byte probe files while proving that +the live client mount topology permits hard-link staging; no resource data or +configuration is modified. The script is [`scripts/preflight-deployment.py`](../scripts/preflight-deployment.py). It deliberately takes paths and container names as arguments rather than @@ -46,7 +48,7 @@ docker compose run -d --no-deps --name archive-control-preflight \ # 3. Discover the real dependency container names if needed. docker ps --format '{{.Names}}' -# 4. Run the read-only checks. +# 4. Run the deployment checks. They include a disposable hard-link probe. python3 /path/to/archive-clients/scripts/preflight-deployment.py \ --client-config /srv/compose/ArchiveControl-archive/client.toml \ --client-container archive-control-preflight \ @@ -84,6 +86,9 @@ python3 scripts/preflight-deployment.py ... \ - That future route path and qBittorrent content root use one client bind mount, so hard-link staging remains possible rather than silently falling back to a space-consuming copy. +- A real `link(2)` operation between a unique zero-byte file in the qB root + and one in the future automatic-route root. The probe verifies that both + names refer to the same inode and removes them unconditionally. - The token, qB password, and Syncthing API-key files are non-empty regular files with no group/world permissions. - The client image can read its configuration and reports usable permissions, diff --git a/scripts/preflight-deployment.py b/scripts/preflight-deployment.py index 767933f..df14b15 100755 --- a/scripts/preflight-deployment.py +++ b/scripts/preflight-deployment.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -"""Validate a host's Archive Control Docker deployment without changing it. +"""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. +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 @@ -168,6 +169,43 @@ raise SystemExit(0 if all(p.state == 1 for p in probes) else 1)''' print(result.stdout.strip()) +def run_hardlink_probe(container: str, qb_root: str, route_root: str) -> 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 +''' + result = subprocess.run( + ["docker", "exec", container, "python", "-c", program, qb_root, route_root], + 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 main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Read-only Archive Control Docker deployment preflight" @@ -209,6 +247,9 @@ def main(argv: list[str] | None = None) -> int: "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) + ) 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")): @@ -218,7 +259,7 @@ def main(argv: list[str] | None = None) -> int: except (CheckFailure, KeyError, OSError) 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") + print("preflight passed: bind mappings, hard-link staging, secrets, filesystem capabilities, and local APIs are healthy") return 0 diff --git a/tests/test_deployment_preflight.py b/tests/test_deployment_preflight.py index 7ac60fb..f0e596c 100644 --- a/tests/test_deployment_preflight.py +++ b/tests/test_deployment_preflight.py @@ -1,6 +1,7 @@ import importlib.util import sys import unittest +from unittest import mock from pathlib import Path @@ -49,6 +50,24 @@ class DeploymentPreflightTests(unittest.TestCase): "/data/qb/.archive-control-routes", ) + def test_runs_hardlink_probe_with_qb_and_route_roots(self): + completed = __import__("subprocess").CompletedProcess( + args=[], returncode=0, stdout="hard-link staging probe passed\n", stderr="" + ) + with mock.patch.object(preflight.subprocess, "run", return_value=completed) as run: + preflight.run_hardlink_probe("client", "/data/qb", "/data/qb/routes") + args = run.call_args.args[0] + self.assertEqual(args[:5], ["docker", "exec", "client", "python", "-c"]) + self.assertEqual(args[-2:], ["/data/qb", "/data/qb/routes"]) + + def test_hardlink_probe_failure_is_actionable(self): + completed = __import__("subprocess").CompletedProcess( + args=[], returncode=1, stdout="", stderr="[Errno 18] Invalid cross-device link" + ) + with mock.patch.object(preflight.subprocess, "run", return_value=completed): + with self.assertRaisesRegex(preflight.CheckFailure, "hard-link staging probe failed"): + preflight.run_hardlink_probe("client", "/data/qb", "/data/qb/routes") + if __name__ == "__main__": unittest.main()