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
|
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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user