fix: recover durable route paths after restart
This commit is contained in:
@@ -89,6 +89,10 @@ python3 scripts/preflight-deployment.py ... \
|
|||||||
- A real `link(2)` operation between a unique zero-byte file in the qB root
|
- 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
|
and one in the future automatic-route root. The probe verifies that both
|
||||||
names refer to the same inode and removes them unconditionally.
|
names refer to the same inode and removes them unconditionally.
|
||||||
|
- Every existing `archive-control:*` Syncthing folder beneath `routes/` still
|
||||||
|
has its local directory. This catches a route-root bind-mount migration that
|
||||||
|
would otherwise hide an already configured folder and later fail a job with
|
||||||
|
`No such file or directory`.
|
||||||
- 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,
|
||||||
@@ -102,6 +106,12 @@ root and add a `local_path_overrides` mapping for that exact API path. This
|
|||||||
prevents automatic route folders being created on a small configuration
|
prevents automatic route folders being created on a small configuration
|
||||||
filesystem while the client expects to hardlink from the qB data mount.
|
filesystem while the client expects to hardlink from the qB data mount.
|
||||||
|
|
||||||
|
When converting an existing node, stop its client and Syncthing containers,
|
||||||
|
move each existing `routes/<route-id>` directory from the old Syncthing config
|
||||||
|
tree into the new qB-backed route-root directory, then recreate Syncthing and
|
||||||
|
run this preflight. Do not discard existing route directories: they can contain
|
||||||
|
route handshake state or an in-progress transfer namespace.
|
||||||
|
|
||||||
## Failure handling
|
## Failure handling
|
||||||
|
|
||||||
Treat a nonzero exit status as a deployment blocker. Correct the compose
|
Treat a nonzero exit status as a deployment blocker. Correct the compose
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ def map_path(container: dict[str, Any], path: str) -> Mapping:
|
|||||||
return max(matches, key=lambda item: item[0])[1]
|
return max(matches, key=lambda item: item[0])[1]
|
||||||
|
|
||||||
|
|
||||||
|
def container_user(container: dict[str, Any]) -> str | None:
|
||||||
|
value = container.get("Config", {}).get("User")
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
def require_same_path(label: str, left: Mapping, right: Mapping) -> None:
|
def require_same_path(label: str, left: Mapping, right: Mapping) -> None:
|
||||||
if left.source.resolve(strict=False) != right.source.resolve(strict=False):
|
if left.source.resolve(strict=False) != right.source.resolve(strict=False):
|
||||||
raise CheckFailure(
|
raise CheckFailure(
|
||||||
@@ -169,7 +174,9 @@ 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:
|
def run_hardlink_probe(
|
||||||
|
container: str, qb_root: str, route_root: str, user: str | None = None,
|
||||||
|
) -> None:
|
||||||
"""Prove that the daemon can hard-link from qB data into automatic routes."""
|
"""Prove that the daemon can hard-link from qB data into automatic routes."""
|
||||||
|
|
||||||
program = r'''import os,sys,tempfile
|
program = r'''import os,sys,tempfile
|
||||||
@@ -196,8 +203,12 @@ finally:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
'''
|
'''
|
||||||
|
command = ["docker", "exec"]
|
||||||
|
if user:
|
||||||
|
command.extend(["--user", user])
|
||||||
|
command.extend([container, "python", "-c", program, qb_root, route_root])
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "exec", container, "python", "-c", program, qb_root, route_root],
|
command,
|
||||||
check=False, text=True, capture_output=True,
|
check=False, text=True, capture_output=True,
|
||||||
)
|
)
|
||||||
if result.returncode:
|
if result.returncode:
|
||||||
@@ -206,6 +217,51 @@ finally:
|
|||||||
print(result.stdout.strip())
|
print(result.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def run_existing_route_directory_check(container: str, config: str) -> None:
|
||||||
|
"""Reject a mount migration that hides an already configured route folder."""
|
||||||
|
|
||||||
|
program = r'''import json,os,sys
|
||||||
|
from pathlib import Path,PurePosixPath
|
||||||
|
from archive_clients.config import ClientConfig
|
||||||
|
from archive_clients.syncthing import SyncthingHttp
|
||||||
|
config=ClientConfig.load(Path(sys.argv[1]))
|
||||||
|
transport=SyncthingHttp(config.syncthing)
|
||||||
|
route_root=config.syncthing.api_root / "routes"
|
||||||
|
missing=[]
|
||||||
|
checked=[]
|
||||||
|
for folder in transport.get_json("/rest/config").get("folders",[]):
|
||||||
|
if not isinstance(folder,dict) or not str(folder.get("label","")).startswith("archive-control:"):
|
||||||
|
continue
|
||||||
|
raw=folder.get("path")
|
||||||
|
if not isinstance(raw,str) or not raw:
|
||||||
|
continue
|
||||||
|
api=PurePosixPath(raw)
|
||||||
|
if api.parts and api.parts[0] == "~":
|
||||||
|
api=config.syncthing.api_root.joinpath(*api.parts[1:])
|
||||||
|
try:
|
||||||
|
api.relative_to(route_root)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
local=config.syncthing.roots.api_to_local(api.as_posix())
|
||||||
|
checked.append(str(local))
|
||||||
|
if not local.is_dir():
|
||||||
|
missing.append({"folder_id":folder.get("id",""),"path":str(local)})
|
||||||
|
print(json.dumps({"checked":checked,"missing":missing},sort_keys=True))
|
||||||
|
raise SystemExit(1 if missing else 0)
|
||||||
|
'''
|
||||||
|
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(
|
||||||
|
"an existing Archive Control Syncthing route directory is missing; "
|
||||||
|
f"complete the route-directory migration before starting jobs: {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"
|
||||||
@@ -248,7 +304,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
"mounts; hard-link staging would be unavailable"
|
"mounts; hard-link staging would be unavailable"
|
||||||
)
|
)
|
||||||
run_hardlink_probe(
|
run_hardlink_probe(
|
||||||
args.client_container, qb["local_root"], route_local_path(sync)
|
args.client_container, qb["local_root"], route_local_path(sync),
|
||||||
|
container_user(client),
|
||||||
|
)
|
||||||
|
run_existing_route_directory_check(
|
||||||
|
args.client_container, args.container_config
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|||||||
@@ -697,7 +697,22 @@ class ArchiveClientDaemon:
|
|||||||
decode_message(str(row["update_json"]), control_pb2.RouteUpdate())
|
decode_message(str(row["update_json"]), control_pb2.RouteUpdate())
|
||||||
)
|
)
|
||||||
await outbound.put(encode(response))
|
await outbound.put(encode(response))
|
||||||
if attempt["state"] in {"ready", "failed"}:
|
if attempt["state"] == "ready":
|
||||||
|
# Route readiness is durable, but this process-local lookup is
|
||||||
|
# not. Reconfigure (which validates the existing Syncthing folder
|
||||||
|
# and recreates its local directory if necessary) before replaying
|
||||||
|
# the stored READY updates. This is essential after a daemon or
|
||||||
|
# mount restart: a previously ready route may otherwise point at a
|
||||||
|
# path that is no longer present, and a later source-stage command
|
||||||
|
# would fail with a bare ENOENT.
|
||||||
|
configured = await asyncio.to_thread(
|
||||||
|
self.routes.configure,
|
||||||
|
spec,
|
||||||
|
time.monotonic() + spec.setup_timeout_seconds,
|
||||||
|
)
|
||||||
|
self._known_route_paths[spec.route_id] = configured.local_path
|
||||||
|
return
|
||||||
|
if attempt["state"] == "failed":
|
||||||
return
|
return
|
||||||
|
|
||||||
deadline = time.monotonic() + spec.setup_timeout_seconds
|
deadline = time.monotonic() + spec.setup_timeout_seconds
|
||||||
|
|||||||
@@ -280,6 +280,10 @@ class DaemonTransportTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
for _ in range(3)
|
for _ in range(3)
|
||||||
]
|
]
|
||||||
self.assertEqual([item.sequence for item in resumed], [1, 2, 3])
|
self.assertEqual([item.sequence for item in resumed], [1, 2, 3])
|
||||||
|
self.assertEqual(manager.configure.call_count, 2)
|
||||||
|
self.assertEqual(
|
||||||
|
restarted._route_path("route-1"), root / "routes/route-1"
|
||||||
|
)
|
||||||
|
|
||||||
async def test_slow_inventory_does_not_block_heartbeat(self):
|
async def test_slow_inventory_does_not_block_heartbeat(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|||||||
@@ -55,9 +55,11 @@ class DeploymentPreflightTests(unittest.TestCase):
|
|||||||
args=[], returncode=0, stdout="hard-link staging probe passed\n", stderr=""
|
args=[], returncode=0, stdout="hard-link staging probe passed\n", stderr=""
|
||||||
)
|
)
|
||||||
with mock.patch.object(preflight.subprocess, "run", return_value=completed) as run:
|
with mock.patch.object(preflight.subprocess, "run", return_value=completed) as run:
|
||||||
preflight.run_hardlink_probe("client", "/data/qb", "/data/qb/routes")
|
preflight.run_hardlink_probe(
|
||||||
|
"client", "/data/qb", "/data/qb/routes", "1001:1001"
|
||||||
|
)
|
||||||
args = run.call_args.args[0]
|
args = run.call_args.args[0]
|
||||||
self.assertEqual(args[:5], ["docker", "exec", "client", "python", "-c"])
|
self.assertEqual(args[:6], ["docker", "exec", "--user", "1001:1001", "client", "python"])
|
||||||
self.assertEqual(args[-2:], ["/data/qb", "/data/qb/routes"])
|
self.assertEqual(args[-2:], ["/data/qb", "/data/qb/routes"])
|
||||||
|
|
||||||
def test_hardlink_probe_failure_is_actionable(self):
|
def test_hardlink_probe_failure_is_actionable(self):
|
||||||
@@ -68,6 +70,14 @@ class DeploymentPreflightTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(preflight.CheckFailure, "hard-link staging probe failed"):
|
with self.assertRaisesRegex(preflight.CheckFailure, "hard-link staging probe failed"):
|
||||||
preflight.run_hardlink_probe("client", "/data/qb", "/data/qb/routes")
|
preflight.run_hardlink_probe("client", "/data/qb", "/data/qb/routes")
|
||||||
|
|
||||||
|
def test_existing_route_directory_failure_is_actionable(self):
|
||||||
|
completed = __import__("subprocess").CompletedProcess(
|
||||||
|
args=[], returncode=1, stdout='{"missing":[{"folder_id":"route-1"}]}', stderr=""
|
||||||
|
)
|
||||||
|
with mock.patch.object(preflight.subprocess, "run", return_value=completed):
|
||||||
|
with self.assertRaisesRegex(preflight.CheckFailure, "route directory is missing"):
|
||||||
|
preflight.run_existing_route_directory_check("client", "/config.toml")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user