Add deployment preflight and fix helium Syncthing root
This commit is contained in:
Executable
+163
@@ -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())
|
||||
Reference in New Issue
Block a user