feat: add transfer service adapters
This commit is contained in:
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
@@ -12,6 +13,11 @@ from typing import Any, Protocol
|
||||
from urllib import error, parse, request
|
||||
|
||||
from archive_clients.config import ConfigError, ServiceConfig
|
||||
from archive_clients.transfer import (
|
||||
TransferIntegrityError,
|
||||
UnsafeTransferPath,
|
||||
load_published_transfer,
|
||||
)
|
||||
from archive_control.v1 import route_pb2
|
||||
|
||||
|
||||
@@ -91,6 +97,98 @@ class ConfiguredRoute:
|
||||
created_folder: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SyncthingTransferStatus:
|
||||
fraction_complete: float
|
||||
bytes_complete: int
|
||||
bytes_total: int
|
||||
complete: bool
|
||||
needed_items: int
|
||||
|
||||
|
||||
class SyncthingTransferObserver:
|
||||
"""Observe one exact Archive Control job prefix within a route folder."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transport: SyncthingTransport,
|
||||
folder_id: str,
|
||||
job_relative_path: str,
|
||||
local_job_directory: Path,
|
||||
):
|
||||
relative = PurePosixPath(job_relative_path)
|
||||
if (
|
||||
not job_relative_path
|
||||
or relative.is_absolute()
|
||||
or any(part in {"", ".", ".."} for part in relative.parts)
|
||||
):
|
||||
raise UnsafeTransferPath("Syncthing job prefix is unsafe")
|
||||
self.transport = transport
|
||||
self.folder_id = folder_id
|
||||
self.job_relative_path = relative.as_posix()
|
||||
self.local_job_directory = local_job_directory
|
||||
|
||||
def rescan(self) -> None:
|
||||
self.transport.post(
|
||||
"/rest/db/scan?"
|
||||
+ parse.urlencode({
|
||||
"folder": self.folder_id,
|
||||
"sub": self.job_relative_path,
|
||||
})
|
||||
)
|
||||
|
||||
def status(self) -> SyncthingTransferStatus:
|
||||
published = load_published_transfer(self.local_job_directory)
|
||||
total = 0
|
||||
for entry in published.manifest.files:
|
||||
total += _verified_job_file_size(
|
||||
self.local_job_directory,
|
||||
entry.payload_relative_path,
|
||||
entry.logical_bytes,
|
||||
)
|
||||
for artifact in published.manifest.artifacts:
|
||||
total += _verified_job_file_size(
|
||||
self.local_job_directory,
|
||||
artifact.payload_relative_path,
|
||||
artifact.logical_bytes,
|
||||
)
|
||||
|
||||
completion = self.transport.get_json(
|
||||
"/rest/db/completion?"
|
||||
+ parse.urlencode({"folder": self.folder_id})
|
||||
)
|
||||
raw_fraction = completion.get("completion")
|
||||
if (
|
||||
isinstance(raw_fraction, bool)
|
||||
or not isinstance(raw_fraction, (int, float))
|
||||
or not 0 <= float(raw_fraction) <= 100
|
||||
):
|
||||
raise RouteSetupError("Syncthing completion response is invalid")
|
||||
need = self.transport.get_json(
|
||||
"/rest/db/need?"
|
||||
+ parse.urlencode({
|
||||
"folder": self.folder_id,
|
||||
"page": 1,
|
||||
"perpage": 1000,
|
||||
})
|
||||
)
|
||||
needed_names = _needed_names(need)
|
||||
prefix = self.job_relative_path.rstrip("/") + "/"
|
||||
relevant = {
|
||||
name for name in needed_names
|
||||
if name == self.job_relative_path or name.startswith(prefix)
|
||||
}
|
||||
fraction = float(raw_fraction) / 100
|
||||
complete = fraction == 1 and not relevant
|
||||
return SyncthingTransferStatus(
|
||||
fraction,
|
||||
total if complete else int(total * fraction),
|
||||
total,
|
||||
complete,
|
||||
len(relevant),
|
||||
)
|
||||
|
||||
|
||||
class SyncthingRouteManager:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -315,6 +413,47 @@ def _by_key(
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def _verified_job_file_size(
|
||||
job_directory: Path,
|
||||
relative_path: str,
|
||||
expected_bytes: int,
|
||||
) -> int:
|
||||
relative = PurePosixPath(relative_path)
|
||||
if (
|
||||
not relative_path
|
||||
or relative.is_absolute()
|
||||
or any(part in {"", ".", ".."} for part in relative.parts)
|
||||
):
|
||||
raise TransferIntegrityError("manifest payload path is unsafe")
|
||||
current = job_directory
|
||||
for component in relative.parts[:-1]:
|
||||
current = current / component
|
||||
metadata = current.lstat()
|
||||
if not stat.S_ISDIR(metadata.st_mode):
|
||||
raise TransferIntegrityError(
|
||||
"manifest payload parent is not a directory"
|
||||
)
|
||||
metadata = (current / relative.name).lstat()
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size != expected_bytes:
|
||||
raise TransferIntegrityError(
|
||||
"manifest payload does not match its declared size"
|
||||
)
|
||||
return metadata.st_size
|
||||
|
||||
|
||||
def _needed_names(value: dict[str, Any]) -> set[str]:
|
||||
result: set[str] = set()
|
||||
for key in ("progress", "queued", "rest"):
|
||||
items = value.get(key, [])
|
||||
if not isinstance(items, list):
|
||||
raise RouteSetupError("Syncthing need response is invalid")
|
||||
for item in items:
|
||||
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
|
||||
raise RouteSetupError("Syncthing need item is invalid")
|
||||
result.add(item["name"])
|
||||
return result
|
||||
|
||||
|
||||
def _nonce_name(client_id: str) -> str:
|
||||
return f".archive-control-route-nonce.{client_id}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user