Add deployment preflight and fix helium Syncthing root
This commit is contained in:
@@ -48,13 +48,21 @@ the folder's normalized API-visible **path** as the override key (for example,
|
|||||||
Syncthing `~/Downloads/Sync` becomes `/var/syncthing/Downloads/Sync`); do not
|
Syncthing `~/Downloads/Sync` becomes `/var/syncthing/Downloads/Sync`); do not
|
||||||
use the folder ID.
|
use the folder ID.
|
||||||
|
|
||||||
Before starting a stack, validate it with:
|
Before starting a stack, validate its config and then run the generic host
|
||||||
|
preflight with that machine's own paths and container names:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose config
|
docker compose config
|
||||||
docker compose run --rm archive-client --check-config
|
docker compose run --rm archive-client --check-config
|
||||||
|
python3 scripts/preflight-deployment.py --help
|
||||||
```
|
```
|
||||||
|
|
||||||
The check is fail-fast and performs local permission, filesystem, sparse-file,
|
The check is fail-fast and performs local permission, filesystem, sparse-file,
|
||||||
hard-link, and reflink probes. Normal startup additionally probes the local
|
hard-link, and reflink probes. Normal startup additionally probes the local
|
||||||
qBittorrent and Syncthing APIs before registration.
|
qBittorrent and Syncthing APIs before registration.
|
||||||
|
|
||||||
|
`scripts/preflight-deployment.py` is deliberately topology-agnostic: pass the
|
||||||
|
host's `client.toml` and its client, qBittorrent, and Syncthing container names.
|
||||||
|
It resolves Docker mounts rather than assuming project names or host paths. It
|
||||||
|
also catches a common fatal error: a Syncthing `api_root` that names its config
|
||||||
|
volume rather than the mounted shared-data tree.
|
||||||
|
|||||||
@@ -16,3 +16,19 @@ placements can hardlink rather than make a full copy.
|
|||||||
`/media/Data2/Downloading` already contains unrelated files. The fresh qB
|
`/media/Data2/Downloading` already contains unrelated files. The fresh qB
|
||||||
instance must not import or manage them; only Archive Control-created torrents
|
instance must not import or manage them; only Archive Control-created torrents
|
||||||
are managed.
|
are managed.
|
||||||
|
|
||||||
|
Before the first client start, run the repository preflight on the target host
|
||||||
|
(replace container names if that host uses different compose project names):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/preflight-deployment.py \
|
||||||
|
--client-config ~/Repositories/compose/ArchiveControl-archive/client.toml \
|
||||||
|
--client-container archive-control-helium-archive-client-1 \
|
||||||
|
--syncthing-container helium-syncthing-syncthing-1 \
|
||||||
|
--qbittorrent-container helium-qbittorrent-qbittorrent-1
|
||||||
|
```
|
||||||
|
|
||||||
|
It is read-only: it checks the configured API roots resolve to the same host
|
||||||
|
paths as the client roots, that both data roots share one client bind mount for
|
||||||
|
hardlinks, secret-file permissions, filesystem capabilities, and the local qB
|
||||||
|
and Syncthing APIs. It does not send the control token or modify any container.
|
||||||
|
|||||||
@@ -37,6 +37,6 @@ local_root = "/data/storage/Downloading"
|
|||||||
[syncthing]
|
[syncthing]
|
||||||
endpoint = "http://127.0.0.1:8384"
|
endpoint = "http://127.0.0.1:8384"
|
||||||
api_key_file = "/run/secrets/syncthing_api_key"
|
api_key_file = "/run/secrets/syncthing_api_key"
|
||||||
api_root = "/var/syncthing"
|
api_root = "/var/syncthing/Sync"
|
||||||
local_root = "/data/storage/Sync"
|
local_root = "/data/storage/Sync"
|
||||||
advertised_addresses = ["dynamic"]
|
advertised_addresses = ["dynamic"]
|
||||||
|
|||||||
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())
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_SCRIPT = Path(__file__).parents[1] / "scripts" / "preflight-deployment.py"
|
||||||
|
_SPEC = importlib.util.spec_from_file_location("deployment_preflight", _SCRIPT)
|
||||||
|
assert _SPEC is not None and _SPEC.loader is not None
|
||||||
|
preflight = importlib.util.module_from_spec(_SPEC)
|
||||||
|
sys.modules[_SPEC.name] = preflight
|
||||||
|
_SPEC.loader.exec_module(preflight)
|
||||||
|
|
||||||
|
|
||||||
|
class DeploymentPreflightTests(unittest.TestCase):
|
||||||
|
def test_maps_nested_container_path_to_host_source(self):
|
||||||
|
container = {
|
||||||
|
"Mounts": [
|
||||||
|
{"Type": "bind", "Source": "/srv/data", "Destination": "/data"},
|
||||||
|
{"Type": "bind", "Source": "/srv/sync", "Destination": "/data/Sync"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mapping = preflight.map_path(container, "/data/Sync/routes/route-1")
|
||||||
|
self.assertEqual(mapping.source, Path("/srv/sync/routes/route-1"))
|
||||||
|
self.assertEqual(str(mapping.destination), "/data/Sync")
|
||||||
|
|
||||||
|
def test_rejects_api_and_client_roots_from_different_host_paths(self):
|
||||||
|
with self.assertRaisesRegex(preflight.CheckFailure, "host paths differ"):
|
||||||
|
preflight.require_same_path(
|
||||||
|
"Syncthing api_root/local_root",
|
||||||
|
preflight.Mapping(Path("/srv/syncthing-config/routes"), Path("/var/syncthing")),
|
||||||
|
preflight.Mapping(Path("/srv/sync/routes"), Path("/data/storage")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_unmounted_container_path(self):
|
||||||
|
with self.assertRaisesRegex(preflight.CheckFailure, "not backed"):
|
||||||
|
preflight.map_path({"Mounts": []}, "/missing")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user