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 client deliberately treats that case as copy-only and performs a full payload
free-space check. free-space check.
When a Syncthing route is physically nested below the qB root, mount the qB Automatic routes always use the Syncthing API path `routes/<route-id>`. Bind
root once and map the exact Syncthing API folder through it: that path from a dedicated directory beneath the qB data root, then map the
same path through the qB client mount:
```yaml ```yaml
# Syncthing compose project
volumes:
- /srv/syncthing-config:/var/syncthing
- /srv/downloads/.archive-control-routes:/var/syncthing/routes
# Archive Control client compose project
volumes: volumes:
- /srv/downloads:/data/qb - /srv/downloads:/data/qb
- /srv/syncthing-config:/data/sync - /srv/syncthing-config:/data/sync
@@ -38,15 +45,14 @@ volumes:
[syncthing] [syncthing]
api_root = "/var/syncthing" api_root = "/var/syncthing"
local_root = "/data/sync" 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 Do **not** mount the route directory separately into the client. The override
`/data/sync`. The override is the authoritative mapping for that folder and is the authoritative mapping and keeps qB source files and automatic route
keeps qB source files and staging destinations in one mount namespace. Use folders in one mount namespace. Existing manually configured Syncthing folders
the folder's normalized API-visible **path** as the override key (for example, below the qB tree may retain their own exact overrides. Use API-visible paths,
Syncthing `~/Downloads/Sync` becomes `/var/syncthing/Downloads/Sync`); do not not Syncthing folder IDs, as override keys.
use the folder ID.
Before starting a stack, validate its config and then run the generic host Before starting a stack, validate its config and then run the generic host
preflight with that machine's own paths and container names: 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_key_file = "/run/secrets/syncthing_api_key"
api_root = "/var/syncthing" api_root = "/var/syncthing"
local_root = "/data/sync" local_root = "/data/sync"
local_path_overrides = { "/var/syncthing/routes" = "/data/qb/.archive-control-routes" }
advertised_addresses = ["dynamic"] advertised_addresses = ["dynamic"]
+1 -1
View File
@@ -41,5 +41,5 @@ api_root = "/var/syncthing"
local_root = "/data/sync" local_root = "/data/sync"
# This existing folder is physically inside the qB data tree. Map it through # This existing folder is physically inside the qB data tree. Map it through
# that same client bind mount so source staging can use hardlinks. # 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"] 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 # `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 # root. Resolve it through /data/qb rather than a second nested bind mount so
# source staging can hardlink it. # 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"] advertised_addresses = ["dynamic"]
+10 -11
View File
@@ -78,11 +78,11 @@ python3 scripts/preflight-deployment.py ... \
## What it checks ## What it checks
- Each configured qBittorrent and Syncthing API root resolves through Docker - The future automatic `routes/<route-id>` path resolves through Docker mounts
mounts to the same host path as the matching client local root. to the same host path as its client-side mapping.
- The client uses one bind mount for its qBittorrent and Syncthing roots, so - That future route path and qBittorrent content root use one client bind
hard-link staging remains possible rather than silently falling back to a mount, so hard-link staging remains possible rather than silently falling
space-consuming copy. back to a space-consuming copy.
- 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,
@@ -90,12 +90,11 @@ python3 scripts/preflight-deployment.py ... \
- qBittorrent authentication/version compatibility and Syncthing - qBittorrent authentication/version compatibility and Syncthing
authentication/device identity are healthy from the client container. authentication/device identity are healthy from the client container.
In particular, `syncthing.api_root` must refer to the Syncthing **shared-data In particular, if `syncthing.api_root` is the Syncthing configuration root,
mount**, not its configuration volume. For example, if Syncthing mounts its bind its `routes` subdirectory from a dedicated directory beneath qB's data
config at `/var/syncthing` and data at `/var/syncthing/Sync`, use root and add a `local_path_overrides` mapping for that exact API path. This
`api_root = "/var/syncthing/Sync"` when `local_root` maps that data directory. prevents automatic route folders being created on a small configuration
This prevents a route folder being created in the config volume while the filesystem while the client expects to hardlink from the qB data mount.
client writes nonce files into the data volume.
## Failure handling ## 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}") 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: def run_client_check(container: str, config: str, flag: str) -> None:
result = subprocess.run( result = subprocess.run(
["docker", "exec", container, "archive-client", "--config", config, flag], ["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) qbittorrent = docker_inspect(args.qbittorrent_container)
client_qb = map_path(client, qb["local_root"]) client_qb = map_path(client, qb["local_root"])
client_sync = map_path(client, sync["local_root"]) client_route = map_path(client, route_local_path(sync))
syncthing_api = map_path(syncthing, sync["api_root"]) syncthing_route = map_path(
syncthing, str(PurePosixPath(sync["api_root"]) / "routes")
)
qb_api = map_path(qbittorrent, qb["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(
"future Syncthing routes/local route mapping",
syncthing_route, client_route,
)
require_same_path("qBittorrent api_root/local_root", qb_api, client_qb) 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( raise CheckFailure(
"qBittorrent and Syncthing roots use separate client bind mounts; " "qBittorrent and future route roots use separate client bind "
"hard-link staging would be unavailable" "mounts; hard-link staging would be unavailable"
) )
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)
+13
View File
@@ -36,6 +36,19 @@ class DeploymentPreflightTests(unittest.TestCase):
with self.assertRaisesRegex(preflight.CheckFailure, "not backed"): with self.assertRaisesRegex(preflight.CheckFailure, "not backed"):
preflight.map_path({"Mounts": []}, "/missing") 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__": if __name__ == "__main__":
unittest.main() unittest.main()