feat: complete client runtime foundation
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user