197 lines
7.9 KiB
Python
Executable File
197 lines
7.9 KiB
Python
Executable File
#!/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 route_local_path(syncthing: dict[str, Any]) -> str:
|
|
"""Resolve the fixed, future automatic ``routes/`` path in the client."""
|
|
|
|
api_root = PurePosixPath(syncthing["api_root"])
|
|
candidate = api_root / "routes"
|
|
overrides = syncthing.get("local_path_overrides", {})
|
|
if not isinstance(overrides, dict):
|
|
raise CheckFailure("syncthing.local_path_overrides must be a table")
|
|
matches: list[tuple[int, PurePosixPath, str]] = []
|
|
for raw_api, raw_local in overrides.items():
|
|
if not isinstance(raw_api, str) or not isinstance(raw_local, str):
|
|
raise CheckFailure("syncthing.local_path_overrides entries are invalid")
|
|
root = PurePosixPath(raw_api)
|
|
try:
|
|
relative = candidate.relative_to(root)
|
|
except ValueError:
|
|
continue
|
|
matches.append((len(root.parts), relative, raw_local))
|
|
if matches:
|
|
_, relative, local_root = max(matches, key=lambda item: item[0])
|
|
return str(Path(local_root).joinpath(*relative.parts))
|
|
try:
|
|
relative = candidate.relative_to(api_root)
|
|
except ValueError as exc:
|
|
raise CheckFailure("future route path is outside syncthing.api_root") from exc
|
|
return str(Path(syncthing["local_root"]).joinpath(*relative.parts))
|
|
|
|
|
|
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_route = map_path(client, route_local_path(sync))
|
|
syncthing_route = map_path(
|
|
syncthing, str(PurePosixPath(sync["api_root"]) / "routes")
|
|
)
|
|
qb_api = map_path(qbittorrent, qb["api_root"])
|
|
require_same_path(
|
|
"future Syncthing routes/local route mapping",
|
|
syncthing_route, client_route,
|
|
)
|
|
require_same_path("qBittorrent api_root/local_root", qb_api, client_qb)
|
|
if client_qb.destination != client_route.destination:
|
|
raise CheckFailure(
|
|
"qBittorrent and future route 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())
|