Route automatic staging through qB data mounts

This commit is contained in:
2026-08-03 10:55:13 +00:00
parent 300210c17d
commit f3517bd21b
7 changed files with 80 additions and 28 deletions
+15 -9
View File
@@ -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/<route-id>`. 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:
+1
View File
@@ -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"]
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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"]
+10 -11
View File
@@ -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/<route-id>` 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
+39 -6
View File
@@ -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)
+13
View File
@@ -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()