release: prepare production client deployment
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
# Production deployment
|
||||||
|
|
||||||
|
These files are the non-secret, host-specific deployment manifests for the
|
||||||
|
initial x1/x2/lithium topology.
|
||||||
|
|
||||||
|
- Install the x1 and x2 files as
|
||||||
|
`~/compose/ArchiveControl-cache/{compose.yaml,client.toml}`.
|
||||||
|
- Install the lithium files as
|
||||||
|
`~/compose/ArchiveControl-archive/{compose.yaml,client.toml}`.
|
||||||
|
- Create sibling `state`, `backups`, and `secrets` directories owned by the
|
||||||
|
configured container UID/GID.
|
||||||
|
- Secret files are never committed. Each `secrets` directory contains
|
||||||
|
`archive_control_token`, `qb_password`, and `syncthing_api_key`, each a
|
||||||
|
regular non-empty file with mode `0600`.
|
||||||
|
|
||||||
|
The Syncthing mounts intentionally reproduce each instance's `/var/syncthing`
|
||||||
|
layout, including nested data binds. This lets route discovery and route
|
||||||
|
provisioning use one safe API-to-local path mapping without altering an
|
||||||
|
existing Syncthing configuration.
|
||||||
|
|
||||||
|
Before starting a stack, validate it with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose config
|
||||||
|
docker compose run --rm archive-client --check-config
|
||||||
|
```
|
||||||
|
|
||||||
|
The check is fail-fast and performs local permission, filesystem, sparse-file,
|
||||||
|
hard-link, and reflink probes. Normal startup additionally probes the local
|
||||||
|
qBittorrent and Syncthing APIs before registration.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
client_id = "lithium-archive"
|
||||||
|
display_name = "lithium archive"
|
||||||
|
role = "archive"
|
||||||
|
control_endpoint = "ws://bot.everdream.xyz:8766/archive_control"
|
||||||
|
shared_token_file = "/run/secrets/archive_control_token"
|
||||||
|
state_db = "/var/lib/archive-control/client.db"
|
||||||
|
backup_dir = "/var/backups/archive-control"
|
||||||
|
|
||||||
|
[connection]
|
||||||
|
registration_timeout = "10s"
|
||||||
|
heartbeat_interval = "15s"
|
||||||
|
offline_timeout = "45s"
|
||||||
|
reconnect_initial = "1s"
|
||||||
|
reconnect_max = "60s" # Retry forever, capped at this delay.
|
||||||
|
reconnect_reset_after = "60s"
|
||||||
|
reconnect_jitter = true
|
||||||
|
|
||||||
|
[jobs]
|
||||||
|
stall_after = "30m" # Status warning only; it does not fail the job.
|
||||||
|
verification_timeout = "30m"
|
||||||
|
poll_interval = "1s"
|
||||||
|
free_space_reserve_bytes = 1073741824 # Rechecked immediately before work.
|
||||||
|
|
||||||
|
[backup]
|
||||||
|
interval = "6h"
|
||||||
|
recent = 12
|
||||||
|
daily = 14
|
||||||
|
weekly = 8
|
||||||
|
|
||||||
|
[qbittorrent]
|
||||||
|
endpoint = "http://qb:18081"
|
||||||
|
username = "${QB_USER}"
|
||||||
|
password_file = "/run/secrets/qb_password"
|
||||||
|
api_root = "/media/Data"
|
||||||
|
local_root = "/data/qb"
|
||||||
|
|
||||||
|
[syncthing]
|
||||||
|
endpoint = "http://syncthing:8384"
|
||||||
|
api_key_file = "/run/secrets/syncthing_api_key"
|
||||||
|
api_root = "/var/syncthing"
|
||||||
|
local_root = "/data/sync"
|
||||||
|
advertised_addresses = ["dynamic"]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: archive-control-archive
|
||||||
|
|
||||||
|
services:
|
||||||
|
archive-client:
|
||||||
|
image: sodium/archive-clients:v0.1.0
|
||||||
|
user: "1000:1000"
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["--config", "/etc/archive-control/client.toml"]
|
||||||
|
environment:
|
||||||
|
QB_USER: admin
|
||||||
|
networks:
|
||||||
|
- archive-services
|
||||||
|
volumes:
|
||||||
|
- ./client.toml:/etc/archive-control/client.toml:ro
|
||||||
|
- ./secrets:/run/secrets:ro
|
||||||
|
- ./state:/var/lib/archive-control
|
||||||
|
- ./backups:/var/backups/archive-control
|
||||||
|
- /media/Data:/data/qb
|
||||||
|
- /home/cyz/Repositories/syncthing/st_home:/data/sync
|
||||||
|
- /home/cyz/Sync:/data/sync/Sync
|
||||||
|
- /home/cyz/cache_pool:/data/sync/cache_pool
|
||||||
|
|
||||||
|
networks:
|
||||||
|
archive-services:
|
||||||
|
external: true
|
||||||
|
name: qb-frpc-v2ray_default
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
client_id = "x1-cache"
|
||||||
|
display_name = "x1 cache"
|
||||||
|
role = "cache"
|
||||||
|
control_endpoint = "ws://bot.everdream.xyz:8766/archive_control"
|
||||||
|
shared_token_file = "/run/secrets/archive_control_token"
|
||||||
|
state_db = "/var/lib/archive-control/client.db"
|
||||||
|
backup_dir = "/var/backups/archive-control"
|
||||||
|
|
||||||
|
[connection]
|
||||||
|
registration_timeout = "10s"
|
||||||
|
heartbeat_interval = "15s"
|
||||||
|
offline_timeout = "45s"
|
||||||
|
reconnect_initial = "1s"
|
||||||
|
reconnect_max = "60s" # Retry forever, capped at this delay.
|
||||||
|
reconnect_reset_after = "60s"
|
||||||
|
reconnect_jitter = true
|
||||||
|
|
||||||
|
[jobs]
|
||||||
|
stall_after = "30m" # Status warning only; it does not fail the job.
|
||||||
|
verification_timeout = "30m"
|
||||||
|
poll_interval = "1s"
|
||||||
|
free_space_reserve_bytes = 1073741824 # Rechecked immediately before work.
|
||||||
|
|
||||||
|
[backup]
|
||||||
|
interval = "6h"
|
||||||
|
recent = 12
|
||||||
|
daily = 14
|
||||||
|
weekly = 8
|
||||||
|
|
||||||
|
[qbittorrent]
|
||||||
|
endpoint = "http://127.0.0.1:8081"
|
||||||
|
username = "${QB_USER}"
|
||||||
|
password_file = "/run/secrets/qb_password"
|
||||||
|
api_root = "/downloads"
|
||||||
|
local_root = "/data/qb"
|
||||||
|
|
||||||
|
[syncthing]
|
||||||
|
endpoint = "http://127.0.0.1:8384"
|
||||||
|
api_key_file = "/run/secrets/syncthing_api_key"
|
||||||
|
api_root = "/var/syncthing"
|
||||||
|
local_root = "/data/sync"
|
||||||
|
advertised_addresses = ["dynamic"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: archive-control-cache
|
||||||
|
|
||||||
|
services:
|
||||||
|
archive-client:
|
||||||
|
image: sodium/archive-clients:v0.1.0
|
||||||
|
user: "1001:1001"
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
command: ["--config", "/etc/archive-control/client.toml"]
|
||||||
|
environment:
|
||||||
|
QB_USER: admin
|
||||||
|
volumes:
|
||||||
|
- ./client.toml:/etc/archive-control/client.toml:ro
|
||||||
|
- ./secrets:/run/secrets:ro
|
||||||
|
- ./state:/var/lib/archive-control
|
||||||
|
- ./backups:/var/backups/archive-control
|
||||||
|
- /home/ubuntu/Downloads:/data/qb
|
||||||
|
- /home/ubuntu/compose/syncthing/st_home:/data/sync
|
||||||
|
- /home/ubuntu/Downloads/Sync:/data/sync/DownloadsSync
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
client_id = "x2-cache"
|
||||||
|
display_name = "x2 cache"
|
||||||
|
role = "cache"
|
||||||
|
control_endpoint = "ws://bot.everdream.xyz:8766/archive_control"
|
||||||
|
shared_token_file = "/run/secrets/archive_control_token"
|
||||||
|
state_db = "/var/lib/archive-control/client.db"
|
||||||
|
backup_dir = "/var/backups/archive-control"
|
||||||
|
|
||||||
|
[connection]
|
||||||
|
registration_timeout = "10s"
|
||||||
|
heartbeat_interval = "15s"
|
||||||
|
offline_timeout = "45s"
|
||||||
|
reconnect_initial = "1s"
|
||||||
|
reconnect_max = "60s" # Retry forever, capped at this delay.
|
||||||
|
reconnect_reset_after = "60s"
|
||||||
|
reconnect_jitter = true
|
||||||
|
|
||||||
|
[jobs]
|
||||||
|
stall_after = "30m" # Status warning only; it does not fail the job.
|
||||||
|
verification_timeout = "30m"
|
||||||
|
poll_interval = "1s"
|
||||||
|
free_space_reserve_bytes = 1073741824 # Rechecked immediately before work.
|
||||||
|
|
||||||
|
[backup]
|
||||||
|
interval = "6h"
|
||||||
|
recent = 12
|
||||||
|
daily = 14
|
||||||
|
weekly = 8
|
||||||
|
|
||||||
|
[qbittorrent]
|
||||||
|
endpoint = "http://127.0.0.1:8081"
|
||||||
|
username = "${QB_USER}"
|
||||||
|
password_file = "/run/secrets/qb_password"
|
||||||
|
api_root = "/downloads"
|
||||||
|
local_root = "/data/qb"
|
||||||
|
|
||||||
|
[syncthing]
|
||||||
|
endpoint = "http://127.0.0.1:8384"
|
||||||
|
api_key_file = "/run/secrets/syncthing_api_key"
|
||||||
|
api_root = "/var/syncthing"
|
||||||
|
local_root = "/data/sync"
|
||||||
|
advertised_addresses = ["dynamic"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: archive-control-cache
|
||||||
|
|
||||||
|
services:
|
||||||
|
archive-client:
|
||||||
|
image: sodium/archive-clients:v0.1.0
|
||||||
|
user: "1001:1001"
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
command: ["--config", "/etc/archive-control/client.toml"]
|
||||||
|
environment:
|
||||||
|
QB_USER: admin
|
||||||
|
volumes:
|
||||||
|
- ./client.toml:/etc/archive-control/client.toml:ro
|
||||||
|
- ./secrets:/run/secrets:ro
|
||||||
|
- ./state:/var/lib/archive-control
|
||||||
|
- ./backups:/var/backups/archive-control
|
||||||
|
- /home/ubuntu/Downloads:/data/qb
|
||||||
|
- /home/ubuntu/compose/syncthing/st_home:/data/sync
|
||||||
|
- /home/ubuntu/Downloads/Sync:/data/sync/Downloads/Sync
|
||||||
@@ -105,7 +105,7 @@ follow-up task.
|
|||||||
### Actions
|
### Actions
|
||||||
|
|
||||||
1. Implement qBittorrent cookie authentication and capability adapters for
|
1. Implement qBittorrent cookie authentication and capability adapters for
|
||||||
supported 4.5–4.6 and 5.x APIs.
|
supported 4.4–4.6 and 5.x APIs.
|
||||||
2. Implement on-demand torrent summaries, scoped hash lookup, lazy complete
|
2. Implement on-demand torrent summaries, scoped hash lookup, lazy complete
|
||||||
content trees, v1/v2 identity validation, torrent export, stopped add,
|
content trees, v1/v2 identity validation, torrent export, stopped add,
|
||||||
selection application, recheck monitoring, and entry-only deletion.
|
selection application, recheck monitoring, and entry-only deletion.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ shapes.
|
|||||||
|
|
||||||
## qBittorrent Web API
|
## qBittorrent Web API
|
||||||
|
|
||||||
The implementation targets supported qBittorrent 4.5–4.6 and 5.x releases and
|
The implementation targets supported qBittorrent 4.4–4.6 and 5.x releases and
|
||||||
detects the application and Web API versions at startup. The authoritative
|
detects the application and Web API versions at startup. The authoritative
|
||||||
references are the official [5.0 WebUI API](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-%28qBittorrent-5.0%29)
|
references are the official [5.0 WebUI API](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-%28qBittorrent-5.0%29)
|
||||||
and [4.1-compatible WebUI API](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-%28qBittorrent-4.1%29).
|
and [4.1-compatible WebUI API](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-%28qBittorrent-4.1%29).
|
||||||
@@ -83,6 +83,12 @@ The folder's native ID becomes `route_id`. Multi-device, send-only,
|
|||||||
receive-only, out-of-root, or ambiguous folders are reported as unsupported and
|
receive-only, out-of-root, or ambiguous folders are reported as unsupported and
|
||||||
left unchanged.
|
left unchanged.
|
||||||
|
|
||||||
|
Syncthing may serialize a folder path relative to its home as `~/...`. The
|
||||||
|
client normalizes that notation beneath the configured API-visible sync root
|
||||||
|
before applying the API-to-local root mapping. Deployments must mirror
|
||||||
|
Syncthing's nested bind mounts into the client so the normalized API path and
|
||||||
|
the client filesystem path refer to the same bytes.
|
||||||
|
|
||||||
Provisioning uses idempotent device and folder configuration updates. Each
|
Provisioning uses idempotent device and folder configuration updates. Each
|
||||||
client receives its peer device ID and optional advertised addresses
|
client receives its peer device ID and optional advertised addresses
|
||||||
(`dynamic` by default). It changes only the specific device/folder objects
|
(`dynamic` by default). It changes only the specific device/folder objects
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "archive-clients"
|
name = "archive-clients"
|
||||||
version = "0.1.0.dev0"
|
version = "0.1.0"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["protobuf==7.35.1", "websockets==16.0"]
|
dependencies = ["protobuf==7.35.1", "websockets==16.0"]
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ def discover_routes(
|
|||||||
if not isinstance(api_path, str):
|
if not isinstance(api_path, str):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
local_path = roots.api_to_local(api_path)
|
normalized_api_path = _normalize_api_path(api_path, roots)
|
||||||
relative = PurePosixPath(api_path).relative_to(roots.api_root)
|
local_path = roots.api_to_local(normalized_api_path)
|
||||||
|
relative = normalized_api_path.relative_to(roots.api_root)
|
||||||
except (ConfigError, ValueError):
|
except (ConfigError, ValueError):
|
||||||
continue
|
continue
|
||||||
if not _lexically_within(local_path, roots.local_root):
|
if not _lexically_within(local_path, roots.local_root):
|
||||||
@@ -93,3 +94,14 @@ def _lexically_within(path: Path, root: Path) -> bool:
|
|||||||
return True
|
return True
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_api_path(
|
||||||
|
api_path: str, roots: RootMapping
|
||||||
|
) -> PurePosixPath:
|
||||||
|
"""Resolve Syncthing's home-relative folder notation under api_root."""
|
||||||
|
|
||||||
|
candidate = PurePosixPath(api_path)
|
||||||
|
if candidate.parts and candidate.parts[0] == "~":
|
||||||
|
candidate = roots.api_root.joinpath(*candidate.parts[1:])
|
||||||
|
return candidate
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ def _supported_qb_version(version: str) -> bool:
|
|||||||
if match is None:
|
if match is None:
|
||||||
return False
|
return False
|
||||||
major, minor = int(match.group(1)), int(match.group(2))
|
major, minor = int(match.group(1)), int(match.group(2))
|
||||||
return major == 5 or (major == 4 and minor in {5, 6})
|
return major == 5 or (major == 4 and minor in {4, 5, 6})
|
||||||
|
|
||||||
|
|
||||||
def _text_get(
|
def _text_get(
|
||||||
|
|||||||
@@ -40,6 +40,28 @@ class RouteDiscoveryTests(unittest.TestCase):
|
|||||||
self.assertEqual(routes[1].local_relative_path, "routes/b")
|
self.assertEqual(routes[1].local_relative_path, "routes/b")
|
||||||
self.assertFalse(routes[1].archive_control_created)
|
self.assertFalse(routes[1].archive_control_created)
|
||||||
|
|
||||||
|
def test_home_relative_paths_are_normalized_under_api_root(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
roots = RootMapping(PurePosixPath("/var/syncthing"), Path(directory))
|
||||||
|
routes = discover_routes(
|
||||||
|
{"folders": [{
|
||||||
|
"id": "DownloadsSync",
|
||||||
|
"path": "~/DownloadsSync",
|
||||||
|
"type": "sendreceive",
|
||||||
|
"devices": [
|
||||||
|
{"deviceID": "LOCAL"},
|
||||||
|
{"deviceID": "ARCHIVE"},
|
||||||
|
],
|
||||||
|
}]},
|
||||||
|
"LOCAL",
|
||||||
|
roots,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(routes), 1)
|
||||||
|
self.assertEqual(routes[0].route_id, "DownloadsSync")
|
||||||
|
self.assertEqual(routes[0].local_relative_path, "DownloadsSync")
|
||||||
|
self.assertEqual(routes[0].state, route_pb2.ROUTE_STATE_DISCOVERED)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+13
-1
@@ -6,7 +6,11 @@ from pathlib import Path, PurePosixPath
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from archive_clients.config import ServiceConfig
|
from archive_clients.config import ServiceConfig
|
||||||
from archive_clients.services import probe_qbittorrent, probe_syncthing
|
from archive_clients.services import (
|
||||||
|
_supported_qb_version,
|
||||||
|
probe_qbittorrent,
|
||||||
|
probe_syncthing,
|
||||||
|
)
|
||||||
from archive_control.v1 import common_pb2
|
from archive_control.v1 import common_pb2
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +43,14 @@ class _Opener:
|
|||||||
|
|
||||||
|
|
||||||
class ServiceProbeTests(unittest.TestCase):
|
class ServiceProbeTests(unittest.TestCase):
|
||||||
|
def test_supported_qbittorrent_versions_include_live_4_4_api(self):
|
||||||
|
for version in ("v4.4.5", "v4.5.5", "v4.6.7", "v5.2.3"):
|
||||||
|
with self.subTest(version=version):
|
||||||
|
self.assertTrue(_supported_qb_version(version))
|
||||||
|
for version in ("v4.3.9", "v6.0.0", "invalid"):
|
||||||
|
with self.subTest(version=version):
|
||||||
|
self.assertFalse(_supported_qb_version(version))
|
||||||
|
|
||||||
def test_qbittorrent_versions_are_normalized(self):
|
def test_qbittorrent_versions_are_normalized(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
|
|||||||
Reference in New Issue
Block a user