feat: execute durable archive transfers

This commit is contained in:
2026-07-23 07:21:19 +00:00
parent 68db3ec4c5
commit 884aed9921
14 changed files with 1618 additions and 8 deletions
+7
View File
@@ -26,6 +26,13 @@ control worktree. `E2E_WAIT_SECONDS` overrides the 360-second assertion
deadline. `down.sh` removes only the five exact Compose projects and the
labelled E2E network; it intentionally retains all bind-mounted runtime state.
Set `E2E_RUN_TRANSFER=1` to follow route verification with a real
cache-1 → archive-1 transfer followed by archive-1 → cache-2 unarchive. The
scenario creates a deterministic one-file torrent in the isolated cache
qBittorrent, submits both jobs through the test HTTP adapter, waits for all five
durable steps in each direction, and verifies target qBittorrent selections,
retained source entries, and target file digests.
The Syncthing 2.1.2 and LinuxServer qBittorrent multi-platform image indexes are
digest-pinned. Runtime secrets are generated with mode 0600 and ignored by
Git. The qBittorrent test config limits its authentication bypass to the
+1 -1
View File
@@ -8,6 +8,7 @@ services:
user: "1001:1001"
volumes:
- ./config/config.json:/etc/archive-control/config.json:ro
- ../scenarios:/e2e/scenarios:ro
- ./secrets:/run/secrets:ro
- ./runtime/state:/var/lib/archive-control
- ./runtime/backups:/var/backups/archive-control
@@ -26,4 +27,3 @@ networks:
archive-control-e2e:
external: true
name: archive-control-e2e
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Drive one real transfer through the test HTTP adapter."""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timezone
CONTROL = "http://127.0.0.1:18081/test/v1"
INFO_HASH = "330be0cb7c2201135a2de63b28e77993745ff688"
LOGICAL_BYTES = 31
def get_json(url: str):
with urllib.request.urlopen(url, timeout=5) as response:
return json.load(response)
def post_json(url: str, value: object):
encoded = json.dumps(value, separators=(",", ":")).encode()
request = urllib.request.Request(
url,
data=encoded,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=5) as response:
return json.load(response)
def qb_json(endpoint: str, path: str, **parameters: str):
query = urllib.parse.urlencode(parameters)
suffix = f"?{query}" if query else ""
return get_json(f"{endpoint}{path}{suffix}")
def wait_for(predicate, timeout: float, detail: str):
deadline = time.monotonic() + timeout
last = None
while time.monotonic() < deadline:
try:
last = predicate()
if last:
return last
except (OSError, ValueError, KeyError, IndexError) as exc:
last = f"{type(exc).__name__}: {exc}"
time.sleep(0.5)
raise RuntimeError(f"timed out waiting for {detail}; last={last!r}")
def qb_resource_is_complete(endpoint: str):
records = qb_json(endpoint, "/torrents/info", hashes=INFO_HASH)
if len(records) != 1 or float(records[0].get("progress", 0)) < 1:
return None
files = qb_json(endpoint, "/torrents/files", hash=INFO_HASH)
if (
len(files) != 1
or float(files[0].get("progress", 0)) < 1
or int(files[0].get("priority", 0)) <= 0
):
return None
return records[0]
def ready_route(cache_client: str, archive_client: str):
for route in get_json(f"{CONTROL}/routes"):
if (
route["cache_client_id"] == cache_client
and route["archive_client_id"] == archive_client
and route["state"] == "ready"
):
return route
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--operation", choices=("archive", "unarchive"), required=True
)
parser.add_argument("--source-client", required=True)
parser.add_argument("--target-client", required=True)
parser.add_argument("--source-qb", required=True)
parser.add_argument("--target-qb", required=True)
parser.add_argument("--cache-client", required=True)
parser.add_argument("--archive-client", required=True)
args = parser.parse_args()
wait_for(
lambda: qb_resource_is_complete(args.source_qb),
60,
"source qBittorrent verification",
)
route = wait_for(
lambda: ready_route(args.cache_client, args.archive_client),
60,
f"{args.cache_client}/{args.archive_client} route",
)
job_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
definition = {
"jobId": job_id,
"idempotencyKey": str(uuid.uuid4()),
"operation": (
"JOB_OPERATION_ARCHIVE"
if args.operation == "archive"
else "JOB_OPERATION_UNARCHIVE"
),
"resourceId": {"infoHashV1Hex": INFO_HASH},
"resourceDisplayName": "fixture.bin",
"createdAt": now,
"transfer": {
"sourceClientId": args.source_client,
"targetClientId": args.target_client,
"routeId": route["route_id"],
"requestedFiles": {"ranges": [{"first": 0, "last": 0}]},
"transferDeltaFiles": {"ranges": [{"first": 0, "last": 0}]},
"requestedLogicalBytes": str(LOGICAL_BYTES),
"transferDeltaLogicalBytes": str(LOGICAL_BYTES),
},
}
created = post_json(f"{CONTROL}/jobs", definition)
if created["job_id"] != job_id:
raise RuntimeError("control returned the wrong job")
def finished_job():
job = get_json(f"{CONTROL}/jobs/{job_id}")
if job["state"] == "JOB_STATE_FAILED":
raise RuntimeError(f"{args.operation} job failed: {job}")
if job["state"] == "JOB_STATE_SUCCEEDED" and job["committed"]:
return job
return None
job = wait_for(finished_job, 180, f"committed {args.operation} job")
wait_for(
lambda: qb_resource_is_complete(args.target_qb),
30,
"target qBittorrent placement",
)
if qb_resource_is_complete(args.source_qb) is None:
raise RuntimeError(
f"{args.operation} transfer did not retain its source placement"
)
print(json.dumps({
"operation": args.operation,
"job_id": job_id,
"state": job["state"],
"committed": job["committed"],
"route_id": route["route_id"],
"source_retained": True,
"target_verified": True,
}, sort_keys=True))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"transfer happy-path failed: {exc}", file=sys.stderr)
raise
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/lib.sh"
info_hash=330be0cb7c2201135a2de63b28e77993745ff688
torrent_base64=ZDQ6aW5mb2Q2Omxlbmd0aGkzMWU0Om5hbWUxMTpmaXh0dXJlLmJpbjEyOnBpZWNlIGxlbmd0aGkxNjM4NGU2OnBpZWNlczIwOlFlB05MtIUU4oem2MNz5LJW/GI6ZWU=
wait_qb_absent() {
local node=$1
local deadline=$((SECONDS + 30))
while (( SECONDS < deadline )); do
local records
records=$(compose_node "$node" exec -T qbittorrent curl -fsS \
"http://127.0.0.1:8080/api/v2/torrents/info?hashes=$info_hash")
if [[ "$records" == "[]" ]]; then
return
fi
sleep 1
done
printf 'timed out waiting for fixture removal on %s\n' "$node" >&2
exit 1
}
for node in cache-1 archive-1; do
compose_node "$node" exec -T qbittorrent curl -fsS \
-X POST http://127.0.0.1:8080/api/v2/torrents/delete \
--data-urlencode "hashes=$info_hash" \
--data-urlencode "deleteFiles=true" >/dev/null || true
wait_qb_absent "$node"
done
compose_node cache-1 exec -T qbittorrent /bin/sh -c \
'printf "archive-control-e2e-happy-path\n" > /downloads/fixture.bin'
compose_node cache-1 exec -T qbittorrent /bin/sh -c \
"printf '%s' '$torrent_base64' | base64 -d > /tmp/fixture.torrent"
compose_node cache-1 exec -T qbittorrent curl -fsS \
-X POST http://127.0.0.1:8080/api/v2/torrents/add \
-F torrents=@/tmp/fixture.torrent \
-F savepath=/downloads \
-F stopped=true >/dev/null
compose_node cache-1 exec -T qbittorrent curl -fsS \
-X POST http://127.0.0.1:8080/api/v2/torrents/recheck \
--data-urlencode "hashes=$info_hash" >/dev/null
compose_control exec -T control \
python /e2e/scenarios/archive_happy.py \
--operation archive \
--source-client cache-1 \
--target-client archive-1 \
--source-qb http://qb-cache-1:8080/api/v2 \
--target-qb http://qb-archive-1:8080/api/v2 \
--cache-client cache-1 \
--archive-client archive-1
target_digest=$(compose_node archive-1 exec -T qbittorrent \
sha256sum /downloads/fixture.bin | awk '{print $1}')
if [[ "$target_digest" != \
"2cb91dd48809bd418a6a54a8f90a9009eb9eef9259ab46052bc1fe108a317ecb" ]]; then
printf 'archive target content digest mismatch: %s\n' \
"$target_digest" >&2
exit 1
fi
printf 'archive filesystem content verified: sha256=%s\n' "$target_digest"
compose_node cache-2 exec -T qbittorrent curl -fsS \
-X POST http://127.0.0.1:8080/api/v2/torrents/delete \
--data-urlencode "hashes=$info_hash" \
--data-urlencode "deleteFiles=true" >/dev/null || true
wait_qb_absent cache-2
compose_control exec -T control \
python /e2e/scenarios/archive_happy.py \
--operation unarchive \
--source-client archive-1 \
--target-client cache-2 \
--source-qb http://qb-archive-1:8080/api/v2 \
--target-qb http://qb-cache-2:8080/api/v2 \
--cache-client cache-2 \
--archive-client archive-1
cache_digest=$(compose_node cache-2 exec -T qbittorrent \
sha256sum /downloads/fixture.bin | awk '{print $1}')
if [[ "$cache_digest" != \
"2cb91dd48809bd418a6a54a8f90a9009eb9eef9259ab46052bc1fe108a317ecb" ]]; then
printf 'unarchive target content digest mismatch: %s\n' \
"$cache_digest" >&2
exit 1
fi
printf 'unarchive filesystem content verified: sha256=%s\n' "$cache_digest"
+3
View File
@@ -21,3 +21,6 @@ for node in "${E2E_NODES[@]}"; do
compose_node "$node" up -d --build
done
"$E2E_ROOT/scripts/wait-routes.sh"
if [[ "${E2E_RUN_TRANSFER:-0}" == "1" ]]; then
"$E2E_ROOT/scripts/archive-happy.sh"
fi