"""Lab-host shipper config — loaded from /etc/cis490/lab-host.toml.""" from __future__ import annotations import tomllib from dataclasses import dataclass, field from pathlib import Path @dataclass(frozen=True) class ReceiverEndpoint: url: str # e.g. "https://collector.wg" ca_bundle: Path | None = None client_cert: Path | None = None client_key: Path | None = None bearer_token: str | None = None verify_tls: bool = True @dataclass(frozen=True) class ShipperConfig: host_id: str data_root: Path # Lab-host data root; episodes/, outbox/, shipped/ live here. receiver: ReceiverEndpoint # Daemon mode: how often to scan for new done.marker files. scan_interval_s: float = 5.0 # PUT timeout per episode. Tarballs are bounded by max_episode_bytes; # at WG speeds this is well under 60s for a typical episode. request_timeout_s: float = 60.0 # Backoff schedule on transient (5xx / network) failures, in seconds, # capped at the last entry. The shipper's scan loop will pick the # episode up again on the next pass regardless. backoff_seconds: tuple[float, ...] = (1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 60.0, 120.0, 300.0) # Local retention before pruning data/shipped/. keep_local_for_days: int = 7 # Quarantine retention. Episodes that the receiver permanently # rejected (400/412) sit here as evidence; without an upper bound # they grow forever. Set to 0 to disable cleanup (operator # responsibility). quarantine_keep_days: int = 30 # How often the quarantine cleanup pass actually runs. Gated # because a 5-second scan tick checking mtimes against a # 30-day-old cutoff is wasteful — once an hour is plenty. quarantine_cleanup_interval_s: float = 3600.0 @property def episodes_dir(self) -> Path: return self.data_root / "episodes" @property def outbox_dir(self) -> Path: return self.data_root / "outbox" @property def shipped_dir(self) -> Path: return self.data_root / "shipped" @property def quarantine_dir(self) -> Path: # Episodes the receiver has refused permanently (4xx other than # 409 — typically 400 missing-commit or 412 not-in-window). They # don't belong in shipped/ (we have nothing to compare against) # and re-shipping them would just re-burn the queue. return self.data_root / "quarantine" @classmethod def load(cls, path: str | Path) -> "ShipperConfig": with open(path, "rb") as f: data = tomllib.load(f) host_id = data.get("host_id") if not isinstance(host_id, str) or not host_id: raise ValueError("lab-host config: host_id (string) required at top level") paths = data.get("paths", {}) data_root = Path(paths.get("data_root", "/var/lib/cis490/data")).resolve() rcv = data.get("receiver", {}) url = rcv.get("url") if not isinstance(url, str) or not url: raise ValueError("lab-host config: receiver.url required") receiver = ReceiverEndpoint( url=url.rstrip("/"), ca_bundle=_optional_path(rcv.get("ca_bundle")), client_cert=_optional_path(rcv.get("client_cert")), client_key=_optional_path(rcv.get("client_key")), bearer_token=rcv.get("bearer_token"), verify_tls=bool(rcv.get("verify_tls", True)), ) retention = data.get("retention", {}) return cls( host_id=host_id, data_root=data_root, receiver=receiver, scan_interval_s=float(data.get("shipper", {}).get("scan_interval_s", 5.0)), request_timeout_s=float(data.get("shipper", {}).get("request_timeout_s", 60.0)), keep_local_for_days=int(retention.get("keep_local_for_days", 7)), quarantine_keep_days=int(retention.get("quarantine_keep_days", 30)), ) def _optional_path(v: object) -> Path | None: if v in (None, ""): return None if isinstance(v, str): return Path(v).expanduser() raise TypeError(f"expected path string, got {type(v).__name__}")