test: probe hardlink staging in deployment preflight
This commit is contained in:
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
Run the deployment preflight on every new node after its qBittorrent and
|
Run the deployment preflight on every new node after its qBittorrent and
|
||||||
Syncthing stacks are running, but before starting the Archive Control daemon
|
Syncthing stacks are running, but before starting the Archive Control daemon
|
||||||
for normal operation or creating any routes/jobs. It is read-only: it does not
|
for normal operation or creating any routes/jobs. It does not contact the
|
||||||
contact the control daemon, alter Syncthing/qBittorrent state, or print secret
|
control daemon, alter Syncthing/qBittorrent state, or print secret contents.
|
||||||
contents.
|
It does create and remove two unique, zero-byte probe files while proving that
|
||||||
|
the live client mount topology permits hard-link staging; no resource data or
|
||||||
|
configuration is modified.
|
||||||
|
|
||||||
The script is [`scripts/preflight-deployment.py`](../scripts/preflight-deployment.py).
|
The script is [`scripts/preflight-deployment.py`](../scripts/preflight-deployment.py).
|
||||||
It deliberately takes paths and container names as arguments rather than
|
It deliberately takes paths and container names as arguments rather than
|
||||||
@@ -46,7 +48,7 @@ docker compose run -d --no-deps --name archive-control-preflight \
|
|||||||
# 3. Discover the real dependency container names if needed.
|
# 3. Discover the real dependency container names if needed.
|
||||||
docker ps --format '{{.Names}}'
|
docker ps --format '{{.Names}}'
|
||||||
|
|
||||||
# 4. Run the read-only checks.
|
# 4. Run the deployment checks. They include a disposable hard-link probe.
|
||||||
python3 /path/to/archive-clients/scripts/preflight-deployment.py \
|
python3 /path/to/archive-clients/scripts/preflight-deployment.py \
|
||||||
--client-config /srv/compose/ArchiveControl-archive/client.toml \
|
--client-config /srv/compose/ArchiveControl-archive/client.toml \
|
||||||
--client-container archive-control-preflight \
|
--client-container archive-control-preflight \
|
||||||
@@ -84,6 +86,9 @@ python3 scripts/preflight-deployment.py ... \
|
|||||||
- That future route path and qBittorrent content root use one client bind
|
- That future route path and qBittorrent content root use one client bind
|
||||||
mount, so hard-link staging remains possible rather than silently falling
|
mount, so hard-link staging remains possible rather than silently falling
|
||||||
back to a space-consuming copy.
|
back to a space-consuming copy.
|
||||||
|
- A real `link(2)` operation between a unique zero-byte file in the qB root
|
||||||
|
and one in the future automatic-route root. The probe verifies that both
|
||||||
|
names refer to the same inode and removes them unconditionally.
|
||||||
- The token, qB password, and Syncthing API-key files are non-empty regular
|
- The token, qB password, and Syncthing API-key files are non-empty regular
|
||||||
files with no group/world permissions.
|
files with no group/world permissions.
|
||||||
- The client image can read its configuration and reports usable permissions,
|
- The client image can read its configuration and reports usable permissions,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Validate a host's Archive Control Docker deployment without changing it.
|
"""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
|
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
|
the host where it runs. It never reads secret contents or calls a remote
|
||||||
control daemon.
|
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
|
from __future__ import annotations
|
||||||
@@ -168,6 +169,43 @@ raise SystemExit(0 if all(p.state == 1 for p in probes) else 1)'''
|
|||||||
print(result.stdout.strip())
|
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:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Read-only Archive Control Docker deployment preflight"
|
description="Read-only Archive Control Docker deployment preflight"
|
||||||
@@ -209,6 +247,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
"qBittorrent and future route roots use separate client bind "
|
"qBittorrent and future route roots use separate client bind "
|
||||||
"mounts; hard-link staging would be unavailable"
|
"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",):
|
for key in ("shared_token_file",):
|
||||||
require_regular_secret(map_path(client, config[key]).source)
|
require_regular_secret(map_path(client, config[key]).source)
|
||||||
for service, key in ((qb, "password_file"), (sync, "api_key_file")):
|
for service, key in ((qb, "password_file"), (sync, "api_key_file")):
|
||||||
@@ -218,7 +259,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
except (CheckFailure, KeyError, OSError) 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, hard-link staging, secrets, filesystem capabilities, and local APIs are healthy")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +50,24 @@ class DeploymentPreflightTests(unittest.TestCase):
|
|||||||
"/data/qb/.archive-control-routes",
|
"/data/qb/.archive-control-routes",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_runs_hardlink_probe_with_qb_and_route_roots(self):
|
||||||
|
completed = __import__("subprocess").CompletedProcess(
|
||||||
|
args=[], returncode=0, stdout="hard-link staging probe passed\n", stderr=""
|
||||||
|
)
|
||||||
|
with mock.patch.object(preflight.subprocess, "run", return_value=completed) as run:
|
||||||
|
preflight.run_hardlink_probe("client", "/data/qb", "/data/qb/routes")
|
||||||
|
args = run.call_args.args[0]
|
||||||
|
self.assertEqual(args[:5], ["docker", "exec", "client", "python", "-c"])
|
||||||
|
self.assertEqual(args[-2:], ["/data/qb", "/data/qb/routes"])
|
||||||
|
|
||||||
|
def test_hardlink_probe_failure_is_actionable(self):
|
||||||
|
completed = __import__("subprocess").CompletedProcess(
|
||||||
|
args=[], returncode=1, stdout="", stderr="[Errno 18] Invalid cross-device link"
|
||||||
|
)
|
||||||
|
with mock.patch.object(preflight.subprocess, "run", return_value=completed):
|
||||||
|
with self.assertRaisesRegex(preflight.CheckFailure, "hard-link staging probe failed"):
|
||||||
|
preflight.run_hardlink_probe("client", "/data/qb", "/data/qb/routes")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user