from __future__ import annotations import dataclasses from typing import List, Optional import yaml from . import dns_resolver class ConfigError(Exception): """Raised for any problem with the config file's contents.""" @dataclasses.dataclass class Endpoint: name: str host: str scheme: str # "http" or "https" port: int expected_status: int # DNS server used to resolve `host` — the system/router resolver is # never consulted, see app/dns_resolver.py. Optional (and unused) when # `host` is already a literal IP address, since there's nothing to # resolve in that case. dns_server: Optional[str] = None path: str = "/" timeout_seconds: Optional[float] = None verify_tls: bool = True # set False for endpoints with self-signed certs @dataclasses.dataclass class InfluxConfig: url: str token: str org: str bucket: str verify_ssl: bool = False # default off: influx uses a self-signed cert @dataclasses.dataclass class CheckConfig: interval_seconds: int = 600 timeout_seconds: float = 10.0 immediate_recheck_delay_seconds: float = 5.0 @dataclasses.dataclass class AppConfig: influx: InfluxConfig check: CheckConfig endpoints: List[Endpoint] def _require(d: dict, key: str, ctx: str): value = d.get(key) if value in (None, ""): raise ConfigError(f"Missing required field '{key}' in {ctx}") return value def load_config(path: str) -> AppConfig: try: with open(path, "r", encoding="utf-8") as f: raw = yaml.safe_load(f) except FileNotFoundError: raise ConfigError(f"Config file not found: {path}") except yaml.YAMLError as e: raise ConfigError(f"Invalid YAML in config file '{path}': {e}") if not raw: raise ConfigError(f"Config file '{path}' is empty") influx_raw = raw.get("influx") or {} influx = InfluxConfig( url=_require(influx_raw, "url", "influx"), token=_require(influx_raw, "token", "influx"), org=_require(influx_raw, "org", "influx"), bucket=_require(influx_raw, "bucket", "influx"), verify_ssl=bool(influx_raw.get("verify_ssl", False)), ) check_raw = raw.get("check") or {} check = CheckConfig( interval_seconds=int(check_raw.get("interval_seconds", 600)), timeout_seconds=float(check_raw.get("timeout_seconds", 10.0)), immediate_recheck_delay_seconds=float( check_raw.get("immediate_recheck_delay_seconds", 5.0) ), ) endpoints_raw = raw.get("endpoints") or [] if not endpoints_raw: raise ConfigError("No endpoints configured under 'endpoints'") endpoints: List[Endpoint] = [] seen_names = set() for i, ep in enumerate(endpoints_raw): ctx = f"endpoints[{i}]" name = _require(ep, "name", ctx) if name in seen_names: raise ConfigError(f"Duplicate endpoint name: '{name}'") seen_names.add(name) scheme = str(_require(ep, "scheme", ctx)).lower() if scheme not in ("http", "https"): raise ConfigError( f"{ctx} ('{name}'): scheme must be 'http' or 'https', got '{scheme}'" ) host = _require(ep, "host", ctx) dns_server = ep.get("dns_server") or None if not dns_resolver.is_ip_literal(host) and not dns_server: raise ConfigError( f"{ctx} ('{name}'): 'dns_server' is required when 'host' is not " "a literal IP address" ) timeout = ep.get("timeout_seconds") endpoints.append( Endpoint( name=name, host=host, scheme=scheme, port=int(_require(ep, "port", ctx)), dns_server=dns_server, expected_status=int(_require(ep, "expected_status", ctx)), path=ep.get("path", "/"), timeout_seconds=float(timeout) if timeout else None, verify_tls=bool(ep.get("verify_tls", True)), ) ) return AppConfig(influx=influx, check=check, endpoints=endpoints)