diff --git a/deploy/production/README.md b/deploy/production/README.md index 15d4ada..b40e665 100644 --- a/deploy/production/README.md +++ b/deploy/production/README.md @@ -25,10 +25,17 @@ have different mount IDs and `link(2)` may return `EXDEV` across them. The client deliberately treats that case as copy-only and performs a full payload free-space check. -When a Syncthing route is physically nested below the qB root, mount the qB -root once and map the exact Syncthing API folder through it: +Automatic routes always use the Syncthing API path `routes/`. Bind +that path from a dedicated directory beneath the qB data root, then map the +same path through the qB client mount: ```yaml +# Syncthing compose project +volumes: + - /srv/syncthing-config:/var/syncthing + - /srv/downloads/.archive-control-routes:/var/syncthing/routes + +# Archive Control client compose project volumes: - /srv/downloads:/data/qb - /srv/syncthing-config:/data/sync @@ -38,15 +45,14 @@ volumes: [syncthing] api_root = "/var/syncthing" local_root = "/data/sync" -local_path_overrides = { "/var/syncthing/Downloads/Sync" = "/data/qb/Sync" } +local_path_overrides = { "/var/syncthing/routes" = "/data/qb/.archive-control-routes" } ``` -Do **not** additionally mount `/srv/downloads/Sync` at a path beneath -`/data/sync`. The override is the authoritative mapping for that folder and -keeps qB source files and staging destinations in one mount namespace. Use -the folder's normalized API-visible **path** as the override key (for example, -Syncthing `~/Downloads/Sync` becomes `/var/syncthing/Downloads/Sync`); do not -use the folder ID. +Do **not** mount the route directory separately into the client. The override +is the authoritative mapping and keeps qB source files and automatic route +folders in one mount namespace. Existing manually configured Syncthing folders +below the qB tree may retain their own exact overrides. Use API-visible paths, +not Syncthing folder IDs, as override keys. Before starting a stack, validate its config and then run the generic host preflight with that machine's own paths and container names: diff --git a/deploy/production/lithium/client.toml b/deploy/production/lithium/client.toml index 1b95b24..5fb2dd1 100644 --- a/deploy/production/lithium/client.toml +++ b/deploy/production/lithium/client.toml @@ -39,4 +39,5 @@ endpoint = "http://syncthing:8384" api_key_file = "/run/secrets/syncthing_api_key" api_root = "/var/syncthing" local_root = "/data/sync" +local_path_overrides = { "/var/syncthing/routes" = "/data/qb/.archive-control-routes" } advertised_addresses = ["dynamic"] diff --git a/deploy/production/x1/client.toml b/deploy/production/x1/client.toml index 9f11110..f0d9c8e 100644 --- a/deploy/production/x1/client.toml +++ b/deploy/production/x1/client.toml @@ -41,5 +41,5 @@ api_root = "/var/syncthing" local_root = "/data/sync" # This existing folder is physically inside the qB data tree. Map it through # that same client bind mount so source staging can use hardlinks. -local_path_overrides = { "/var/syncthing/DownloadsSync" = "/data/qb/Sync" } +local_path_overrides = { "/var/syncthing/DownloadsSync" = "/data/qb/Sync", "/var/syncthing/routes" = "/data/qb/.archive-control-routes" } advertised_addresses = ["dynamic"] diff --git a/deploy/production/x2/client.toml b/deploy/production/x2/client.toml index e1eee49..ad48c98 100644 --- a/deploy/production/x2/client.toml +++ b/deploy/production/x2/client.toml @@ -42,5 +42,5 @@ local_root = "/data/sync" # `DownloadsSync-X2` is ~/Downloads/Sync on the host, nested below the qB # root. Resolve it through /data/qb rather than a second nested bind mount so # source staging can hardlink it. -local_path_overrides = { "/var/syncthing/Downloads/Sync" = "/data/qb/Sync" } +local_path_overrides = { "/var/syncthing/Downloads/Sync" = "/data/qb/Sync", "/var/syncthing/routes" = "/data/qb/.archive-control-routes" } advertised_addresses = ["dynamic"] diff --git a/docs/deployment-preflight.md b/docs/deployment-preflight.md index ff0f8ca..331cf70 100644 --- a/docs/deployment-preflight.md +++ b/docs/deployment-preflight.md @@ -78,11 +78,11 @@ python3 scripts/preflight-deployment.py ... \ ## What it checks -- Each configured qBittorrent and Syncthing API root resolves through Docker - mounts to the same host path as the matching client local root. -- The client uses one bind mount for its qBittorrent and Syncthing roots, so - hard-link staging remains possible rather than silently falling back to a - space-consuming copy. +- The future automatic `routes/` path resolves through Docker mounts + to the same host path as its client-side mapping. +- That future route path and qBittorrent content root use one client bind + mount, so hard-link staging remains possible rather than silently falling + back to a space-consuming copy. - The token, qB password, and Syncthing API-key files are non-empty regular files with no group/world permissions. - The client image can read its configuration and reports usable permissions, @@ -90,12 +90,11 @@ python3 scripts/preflight-deployment.py ... \ - qBittorrent authentication/version compatibility and Syncthing authentication/device identity are healthy from the client container. -In particular, `syncthing.api_root` must refer to the Syncthing **shared-data -mount**, not its configuration volume. For example, if Syncthing mounts its -config at `/var/syncthing` and data at `/var/syncthing/Sync`, use -`api_root = "/var/syncthing/Sync"` when `local_root` maps that data directory. -This prevents a route folder being created in the config volume while the -client writes nonce files into the data volume. +In particular, if `syncthing.api_root` is the Syncthing configuration root, +bind its `routes` subdirectory from a dedicated directory beneath qB's data +root and add a `local_path_overrides` mapping for that exact API path. This +prevents automatic route folders being created on a small configuration +filesystem while the client expects to hardlink from the qB data mount. ## Failure handling diff --git a/scripts/preflight-deployment.py b/scripts/preflight-deployment.py index 6d66b7b..5e8c72e 100755 --- a/scripts/preflight-deployment.py +++ b/scripts/preflight-deployment.py @@ -82,6 +82,34 @@ def require_regular_secret(path: Path) -> None: 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], @@ -136,15 +164,20 @@ def main(argv: list[str] | None = None) -> int: 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"]) + 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("Syncthing api_root/local_root", syncthing_api, client_sync) + 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_sync.destination: + if client_qb.destination != client_route.destination: raise CheckFailure( - "qBittorrent and Syncthing roots use separate client bind mounts; " - "hard-link staging would be unavailable" + "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) diff --git a/tests/test_deployment_preflight.py b/tests/test_deployment_preflight.py index 8e5f817..7ac60fb 100644 --- a/tests/test_deployment_preflight.py +++ b/tests/test_deployment_preflight.py @@ -36,6 +36,19 @@ class DeploymentPreflightTests(unittest.TestCase): with self.assertRaisesRegex(preflight.CheckFailure, "not backed"): preflight.map_path({"Mounts": []}, "/missing") + def test_maps_future_route_through_syncthing_override(self): + config = { + "api_root": "/var/syncthing", + "local_root": "/data/sync", + "local_path_overrides": { + "/var/syncthing/routes": "/data/qb/.archive-control-routes", + }, + } + self.assertEqual( + preflight.route_local_path(config), + "/data/qb/.archive-control-routes", + ) + if __name__ == "__main__": unittest.main()