From 25d40daea6d0af5775e425d2c9a3f71c3b0b0b90 Mon Sep 17 00:00:00 2001 From: Cabbagec Date: Mon, 3 Aug 2026 11:08:42 +0000 Subject: [PATCH] Run deployment preflight through client image --- docs/deployment-preflight.md | 3 ++- scripts/preflight-deployment.py | 42 ++++++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/docs/deployment-preflight.md b/docs/deployment-preflight.md index 331cf70..dc88353 100644 --- a/docs/deployment-preflight.md +++ b/docs/deployment-preflight.md @@ -14,7 +14,8 @@ It can therefore be used unchanged on cache and archive nodes. ## Requirements - Run it on the Docker host being checked. -- Use Python 3.11 or newer (the script uses the standard-library `tomllib`). +- Use Python 3.8 or newer. The script reads the mounted configuration through + the client image, so it does not depend on the host Python TOML library. - Have Docker CLI access to the daemon. - Have the checkout containing the script available on that host, or copy only this script into the node's compose directory. diff --git a/scripts/preflight-deployment.py b/scripts/preflight-deployment.py index 5e8c72e..767933f 100755 --- a/scripts/preflight-deployment.py +++ b/scripts/preflight-deployment.py @@ -14,7 +14,6 @@ import os import stat import subprocess import sys -import tomllib from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import Any @@ -121,6 +120,33 @@ def run_client_check(container: str, config: str, flag: str) -> None: 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 @@ -155,13 +181,17 @@ def main(argv: list[str] | None = None) -> int: 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) + 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)) @@ -185,7 +215,7 @@ def main(argv: list[str] | None = None) -> int: 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: + 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")