feat: complete client runtime foundation

This commit is contained in:
2026-07-22 16:25:38 +00:00
parent c11a7b5b5b
commit 1219117403
20 changed files with 1201 additions and 48 deletions
+72 -10
View File
@@ -54,10 +54,16 @@ class ServiceConfig:
return RootMapping(self.api_root, self.local_root)
def read_password(self) -> str | None:
return _secret(self.password_file, "service password") if self.password_file else None
return (
_secret(self.password_file, "service password")
if self.password_file else None
)
def read_api_key(self) -> str | None:
return _secret(self.api_key_file, "service API key") if self.api_key_file else None
return (
_secret(self.api_key_file, "service API key")
if self.api_key_file else None
)
@dataclass(frozen=True)
@@ -76,6 +82,14 @@ class JobsConfig:
stall_after: float = 30 * 60
@dataclass(frozen=True)
class BackupConfig:
interval: float = 6 * 60 * 60
recent: int = 12
daily: int = 14
weekly: int = 8
@dataclass(frozen=True)
class ClientConfig:
client_id: str
@@ -89,6 +103,7 @@ class ClientConfig:
syncthing: ServiceConfig
connection: ConnectionConfig = ConnectionConfig()
jobs: JobsConfig = JobsConfig()
backup: BackupConfig = BackupConfig()
@classmethod
def load(cls, path: Path, mode_override: str | None = None) -> "ClientConfig":
@@ -100,7 +115,7 @@ class ClientConfig:
_keys(raw, {
"client_id", "display_name", "role", "control_endpoint",
"shared_token_file", "state_db", "backup_dir", "connection",
"jobs", "qbittorrent", "syncthing",
"jobs", "backup", "qbittorrent", "syncthing",
}, "root")
role = mode_override or raw.get("role")
if role not in {"archive", "cache"}:
@@ -113,14 +128,24 @@ class ClientConfig:
raise ConfigError("display_name is invalid")
connection = _connection(raw.get("connection", {}))
jobs = _jobs(raw.get("jobs", {}))
backup = _backup(raw.get("backup", {}))
state_db = _absolute_path(raw, "state_db")
backup_dir = _absolute_path(raw, "backup_dir")
qbittorrent = _service(raw.get("qbittorrent"), "qbittorrent")
syncthing = _service(raw.get("syncthing"), "syncthing")
for protected in (qbittorrent.local_root, syncthing.local_root):
if _is_within(state_db, protected) or _is_within(
backup_dir, protected
):
raise ConfigError(
"state_db and backup_dir must be outside data roots"
)
return cls(
client_id, display_name, role,
_endpoint(raw, "control_endpoint", {"ws", "wss"}),
_absolute_path(raw, "shared_token_file"),
_absolute_path(raw, "state_db"),
_absolute_path(raw, "backup_dir"),
_service(raw.get("qbittorrent"), "qbittorrent"),
_service(raw.get("syncthing"), "syncthing"), connection, jobs,
state_db, backup_dir, qbittorrent, syncthing, connection, jobs,
backup,
)
def read_shared_token(self) -> str:
@@ -146,6 +171,12 @@ def _service(value: Any, name: str) -> ServiceConfig:
not isinstance(address, str) or not address for address in addresses
):
raise ConfigError(f"{name}.advertised_addresses must be a string array")
if name == "qbittorrent" and (
username is None or "password_file" not in value
):
raise ConfigError("qbittorrent username and password_file are required")
if name == "syncthing" and "api_key_file" not in value:
raise ConfigError("syncthing api_key_file is required")
return ServiceConfig(
_endpoint(value, "endpoint", {"http", "https"}), api_root,
_absolute_path(value, "local_root"), username,
@@ -160,7 +191,11 @@ def _service(value: Any, name: str) -> ServiceConfig:
def _connection(value: Any) -> ConnectionConfig:
if not isinstance(value, dict):
raise ConfigError("connection must be a table")
_keys(value, {"registration_timeout", "heartbeat_interval", "offline_timeout", "reconnect_initial", "reconnect_max", "reconnect_reset_after", "reconnect_jitter"}, "connection")
_keys(value, {
"registration_timeout", "heartbeat_interval", "offline_timeout",
"reconnect_initial", "reconnect_max", "reconnect_reset_after",
"reconnect_jitter",
}, "connection")
result = ConnectionConfig(
registration_timeout=_duration(value.get("registration_timeout", "10s")),
heartbeat_interval=_duration(value.get("heartbeat_interval", "15s")),
@@ -186,12 +221,30 @@ def _jobs(value: Any) -> JobsConfig:
return JobsConfig(stall_after=_duration(value.get("stall_after", "30m")))
def _backup(value: Any) -> BackupConfig:
if not isinstance(value, dict):
raise ConfigError("backup must be a table")
_keys(value, {"interval", "recent", "daily", "weekly"}, "backup")
return BackupConfig(
interval=_duration(value.get("interval", "6h")),
recent=_positive_int(value.get("recent", 12), "backup.recent"),
daily=_positive_int(value.get("daily", 14), "backup.daily"),
weekly=_positive_int(value.get("weekly", 8), "backup.weekly"),
)
def _duration(value: Any) -> float:
if not isinstance(value, str) or not (match := _DURATION.fullmatch(value)):
raise ConfigError("duration must look like 15s or 30m")
return int(match.group(1)) * _FACTORS[match.group(2)]
def _positive_int(value: Any, name: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise ConfigError(f"{name} must be a positive integer")
return value
def _string(value: dict[str, Any], key: str) -> str:
item = value.get(key)
if not isinstance(item, str) or not item:
@@ -205,11 +258,19 @@ def _path(value: dict[str, Any], key: str) -> Path:
def _absolute_path(value: dict[str, Any], key: str) -> Path:
path = _path(value, key)
if not path.is_absolute():
raise ConfigError(f"{key} must be an absolute path")
if not path.is_absolute() or ".." in path.parts:
raise ConfigError(f"{key} must be an absolute normalized path")
return path
def _is_within(path: Path, root: Path) -> bool:
try:
path.resolve(strict=False).relative_to(root.resolve(strict=False))
return True
except ValueError:
return False
def _endpoint(
value: dict[str, Any], key: str, allowed_schemes: set[str]
) -> str:
@@ -220,6 +281,7 @@ def _endpoint(
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
schemes = "/".join(sorted(allowed_schemes))