Route automatic staging through qB data mounts
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user