"""Config loading for OpsBridge.""" from __future__ import annotations import os from dataclasses import dataclass from pathlib import Path from typing import Dict import yaml from bridge.models import ActorInfo, HealthCheckConfig, ReconnectPolicy, TunnelConfig class ConfigError(Exception): """Raised when config is invalid or missing.""" @dataclass class BridgeConfig: tunnels: Dict[str, TunnelConfig] actors: Dict[str, ActorInfo] def _default_config_path() -> Path: return Path.home() / ".config" / "bridge" / "tunnels.yaml" def load_config() -> BridgeConfig: """Load and validate tunnels.yaml. Respects BRIDGE_CONFIG env var.""" path = Path(os.environ.get("BRIDGE_CONFIG", str(_default_config_path()))) if not path.exists(): raise ConfigError(f"Config file not found: {path}") try: with path.open() as f: raw = yaml.safe_load(f) except yaml.YAMLError as e: raise ConfigError(f"Invalid YAML in {path}: {e}") from e if not isinstance(raw, dict): raise ConfigError(f"Config must be a YAML mapping, got: {type(raw)}") tunnels = _parse_tunnels(raw.get("tunnels") or {}) actors = _parse_actors(raw.get("actors") or {}) return BridgeConfig(tunnels=tunnels, actors=actors) def _parse_tunnels(raw: dict) -> Dict[str, TunnelConfig]: tunnels = {} for name, data in raw.items(): if not isinstance(data, dict): raise ConfigError(f"Tunnel '{name}' must be a mapping") tunnels[name] = _parse_tunnel(name, data) return tunnels def _parse_tunnel(name: str, data: dict) -> TunnelConfig: required = ["host", "remote_port", "local_port", "ssh_user", "ssh_key", "actor"] for field in required: if field not in data: raise ConfigError(f"Tunnel '{name}' missing required field: {field}") reconnect = ReconnectPolicy() if "reconnect" in data and data["reconnect"]: r = data["reconnect"] reconnect = ReconnectPolicy( max_attempts=r.get("max_attempts", 0), backoff_initial=r.get("backoff_initial", 5), backoff_max=r.get("backoff_max", 60), ) health_check = None if "health_check" in data and data["health_check"]: hc = data["health_check"] if "url" not in hc: raise ConfigError(f"Tunnel '{name}' health_check missing required field: url") health_check = HealthCheckConfig( url=hc["url"], interval_seconds=hc.get("interval_seconds", 30), timeout_seconds=hc.get("timeout_seconds", 5), ) return TunnelConfig( name=name, host=str(data["host"]), remote_port=int(data["remote_port"]), local_port=int(data["local_port"]), ssh_user=str(data["ssh_user"]), ssh_key=str(data["ssh_key"]), actor=str(data["actor"]), reconnect=reconnect, health_check=health_check, ) def _parse_actors(raw: dict) -> Dict[str, ActorInfo]: actors = {} for name, data in raw.items(): if not isinstance(data, dict): raise ConfigError(f"Actor '{name}' must be a mapping") if "class" not in data: raise ConfigError(f"Actor '{name}' missing required field: class") actors[name] = ActorInfo( name=name, actor_class=str(data["class"]), description=str(data.get("description", "")), ) return actors