Run deployment preflight through client image

This commit is contained in:
2026-08-03 11:08:42 +00:00
parent f3517bd21b
commit 25d40daea6
2 changed files with 38 additions and 7 deletions
+2 -1
View File
@@ -14,7 +14,8 @@ It can therefore be used unchanged on cache and archive nodes.
## Requirements ## Requirements
- Run it on the Docker host being checked. - 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 Docker CLI access to the daemon.
- Have the checkout containing the script available on that host, or copy only - Have the checkout containing the script available on that host, or copy only
this script into the node's compose directory. this script into the node's compose directory.
+36 -6
View File
@@ -14,7 +14,6 @@ import os
import stat import stat
import subprocess import subprocess
import sys import sys
import tomllib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any from typing import Any
@@ -121,6 +120,33 @@ def run_client_check(container: str, config: str, flag: str) -> None:
print(result.stdout.strip()) 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: def run_service_check(container: str, config: str) -> None:
program = '''import json,sys program = '''import json,sys
from pathlib import Path from pathlib import Path
@@ -155,13 +181,17 @@ def main(argv: list[str] | None = None) -> int:
help="client.toml path inside the client container") help="client.toml path inside the client container")
args = parser.parse_args(argv) args = parser.parse_args(argv)
try: 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) client = docker_inspect(args.client_container)
syncthing = docker_inspect(args.syncthing_container) syncthing = docker_inspect(args.syncthing_container)
qbittorrent = docker_inspect(args.qbittorrent_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_qb = map_path(client, qb["local_root"])
client_route = map_path(client, route_local_path(sync)) 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) require_regular_secret(map_path(client, service[key]).source)
run_client_check(args.client_container, args.container_config, "--check-config") run_client_check(args.client_container, args.container_config, "--check-config")
run_service_check(args.client_container, args.container_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) print(f"preflight failed: {exc}", file=sys.stderr)
return 1 return 1
print("preflight passed: bind mappings, secrets, filesystem capabilities, and local APIs are healthy") print("preflight passed: bind mappings, secrets, filesystem capabilities, and local APIs are healthy")