generated from coulomb/repo-seed
feat: implement OpsBridge CLI (BRIDGE-WP-0001)
Full TDD implementation of the `bridge` CLI tool covering all phases from BRIDGE-WP-0001: project scaffolding, config loading, state management, audit logging, health checks, tunnel lifecycle manager, and all CLI commands (up/down/restart/status/logs). 77 tests, all green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
109
src/bridge/config.py
Normal file
109
src/bridge/config.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user