268 lines
11 KiB
Python
Executable File
268 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""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. 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
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
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 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
|
|
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 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"
|
|
)
|
|
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:
|
|
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))
|
|
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"
|
|
)
|
|
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")):
|
|
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) as exc:
|
|
print(f"preflight failed: {exc}", file=sys.stderr)
|
|
return 1
|
|
print("preflight passed: bind mappings, hard-link staging, secrets, filesystem capabilities, and local APIs are healthy")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|