generated from coulomb/repo-seed
feat: implement OpsCatalog extension (BRIDGE-WP-0002)
Adds the OpsCatalog subsystem: a Git-backed YAML catalog of operations domains, targets, bridges, and actor classes. Includes catalog loader, cross-reference validator, bridge resolver (inline-first, catalog fallback), and new CLI commands: `bridge targets`, `bridge targets show`, `bridge catalog list/validate/show`. Updates `up/down/restart` to resolve bridge names from the catalog when not defined inline. 142 tests, all green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
src/bridge/catalog/__init__.py
Normal file
0
src/bridge/catalog/__init__.py
Normal file
142
src/bridge/catalog/loader.py
Normal file
142
src/bridge/catalog/loader.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Catalog loader — walks a catalog directory tree and parses YAML files."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import yaml
|
||||
|
||||
from bridge.catalog.models import (
|
||||
ActorClass,
|
||||
Catalog,
|
||||
CatalogBridge,
|
||||
CatalogDomain,
|
||||
CatalogTarget,
|
||||
)
|
||||
from bridge.models import HealthCheckConfig, ReconnectPolicy
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CatalogLoadError(Exception):
|
||||
"""Raised when catalog loading fails."""
|
||||
|
||||
|
||||
def load_catalog(path: Path) -> Catalog:
|
||||
"""Walk the catalog directory and return a populated Catalog."""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise CatalogLoadError(f"Catalog path not found: {path}")
|
||||
|
||||
catalog = Catalog()
|
||||
for yaml_file in sorted(path.rglob("*.yaml")):
|
||||
_load_file(yaml_file, catalog)
|
||||
return catalog
|
||||
|
||||
|
||||
def _load_file(path: Path, catalog: Catalog) -> None:
|
||||
try:
|
||||
with path.open() as f:
|
||||
data = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
raise CatalogLoadError(f"Invalid YAML in {path}: {e}") from e
|
||||
|
||||
if not isinstance(data, dict):
|
||||
log.warning("Skipping %s: not a YAML mapping", path)
|
||||
return
|
||||
|
||||
entry_type = data.get("type")
|
||||
if not entry_type:
|
||||
log.warning("Skipping %s: no 'type' field", path)
|
||||
return
|
||||
|
||||
try:
|
||||
if entry_type == "domain":
|
||||
entry = _parse_domain(data, path)
|
||||
catalog.domains[entry.id] = entry
|
||||
elif entry_type == "target":
|
||||
entry = _parse_target(data, path)
|
||||
catalog.targets[entry.id] = entry
|
||||
elif entry_type == "bridge":
|
||||
entry = _parse_bridge(data, path)
|
||||
catalog.bridges[entry.id] = entry
|
||||
elif entry_type == "actor":
|
||||
entry = _parse_actor(data, path)
|
||||
catalog.actors[entry.id] = entry
|
||||
else:
|
||||
log.warning("Skipping %s: unknown type '%s'", path, entry_type)
|
||||
except CatalogLoadError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise CatalogLoadError(f"Error parsing {path}: {e}") from e
|
||||
|
||||
|
||||
def _require(data: dict, field: str, path: Path) -> Any:
|
||||
if field not in data:
|
||||
raise CatalogLoadError(f"Missing required field '{field}' in {path}")
|
||||
return data[field]
|
||||
|
||||
|
||||
def _parse_domain(data: dict, path: Path) -> CatalogDomain:
|
||||
return CatalogDomain(
|
||||
id=str(_require(data, "id", path)),
|
||||
name=str(_require(data, "name", path)),
|
||||
description=str(data.get("description", "")),
|
||||
environment=str(data.get("environment", "")),
|
||||
)
|
||||
|
||||
|
||||
def _parse_target(data: dict, path: Path) -> CatalogTarget:
|
||||
return CatalogTarget(
|
||||
id=str(_require(data, "id", path)),
|
||||
domain=str(_require(data, "domain", path)),
|
||||
kind=str(_require(data, "kind", path)),
|
||||
description=str(data.get("description", "")),
|
||||
reachable_via=list(data.get("reachable_via") or []),
|
||||
)
|
||||
|
||||
|
||||
def _parse_bridge(data: dict, path: Path) -> CatalogBridge:
|
||||
health_check = None
|
||||
if "health_check" in data and data["health_check"]:
|
||||
hc = data["health_check"]
|
||||
health_check = HealthCheckConfig(
|
||||
url=str(_require(hc, "url", path)),
|
||||
interval_seconds=int(hc.get("interval_seconds", 30)),
|
||||
timeout_seconds=int(hc.get("timeout_seconds", 5)),
|
||||
)
|
||||
|
||||
reconnect = None
|
||||
if "reconnect" in data and data["reconnect"]:
|
||||
r = data["reconnect"]
|
||||
reconnect = ReconnectPolicy(
|
||||
max_attempts=int(r.get("max_attempts", 0)),
|
||||
backoff_initial=int(r.get("backoff_initial", 5)),
|
||||
backoff_max=int(r.get("backoff_max", 60)),
|
||||
)
|
||||
|
||||
return CatalogBridge(
|
||||
id=str(_require(data, "id", path)),
|
||||
domain=str(_require(data, "domain", path)),
|
||||
target=str(_require(data, "target", path)),
|
||||
host=str(_require(data, "host", path)),
|
||||
remote_port=int(_require(data, "remote_port", path)),
|
||||
local_port=int(_require(data, "local_port", path)),
|
||||
ssh_user=str(_require(data, "ssh_user", path)),
|
||||
ssh_key=str(_require(data, "ssh_key", path)),
|
||||
actor=str(_require(data, "actor", path)),
|
||||
description=str(data.get("description", "")),
|
||||
access_method=str(data.get("access_method", "ssh-reverse")),
|
||||
health_check=health_check,
|
||||
reconnect=reconnect,
|
||||
)
|
||||
|
||||
|
||||
def _parse_actor(data: dict, path: Path) -> ActorClass:
|
||||
return ActorClass(
|
||||
id=str(_require(data, "id", path)),
|
||||
actor_class=str(_require(data, "class", path)),
|
||||
description=str(data.get("description", "")),
|
||||
)
|
||||
69
src/bridge/catalog/models.py
Normal file
69
src/bridge/catalog/models.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Domain models for OpsCatalog."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from bridge.models import HealthCheckConfig, ReconnectPolicy, TunnelConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogDomain:
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
environment: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogTarget:
|
||||
id: str
|
||||
domain: str
|
||||
kind: str
|
||||
description: str = ""
|
||||
reachable_via: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogBridge:
|
||||
id: str
|
||||
domain: str
|
||||
target: str
|
||||
host: str
|
||||
remote_port: int
|
||||
local_port: int
|
||||
ssh_user: str
|
||||
ssh_key: str
|
||||
actor: str
|
||||
description: str = ""
|
||||
access_method: str = "ssh-reverse"
|
||||
health_check: Optional[HealthCheckConfig] = None
|
||||
reconnect: Optional[ReconnectPolicy] = None
|
||||
|
||||
def to_tunnel_config(self) -> TunnelConfig:
|
||||
return TunnelConfig(
|
||||
name=self.id,
|
||||
host=self.host,
|
||||
remote_port=self.remote_port,
|
||||
local_port=self.local_port,
|
||||
ssh_user=self.ssh_user,
|
||||
ssh_key=self.ssh_key,
|
||||
actor=self.actor,
|
||||
reconnect=self.reconnect if self.reconnect is not None else ReconnectPolicy(),
|
||||
health_check=self.health_check,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActorClass:
|
||||
id: str
|
||||
actor_class: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Catalog:
|
||||
domains: Dict[str, CatalogDomain] = field(default_factory=dict)
|
||||
targets: Dict[str, CatalogTarget] = field(default_factory=dict)
|
||||
bridges: Dict[str, CatalogBridge] = field(default_factory=dict)
|
||||
actors: Dict[str, ActorClass] = field(default_factory=dict)
|
||||
35
src/bridge/catalog/resolver.py
Normal file
35
src/bridge/catalog/resolver.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Catalog resolver — resolves a bridge name to a TunnelConfig."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from bridge.catalog.models import Catalog
|
||||
from bridge.models import TunnelConfig
|
||||
|
||||
|
||||
class BridgeNotFound(Exception):
|
||||
"""Raised when a bridge name cannot be resolved from inline config or catalog."""
|
||||
|
||||
|
||||
def resolve(
|
||||
name: str,
|
||||
catalog: Optional[Catalog],
|
||||
inline_tunnels: Dict[str, TunnelConfig],
|
||||
) -> TunnelConfig:
|
||||
"""Resolve bridge name to TunnelConfig.
|
||||
|
||||
Lookup order:
|
||||
1. inline_tunnels (from tunnels.yaml) — wins if present
|
||||
2. catalog bridges — fallback
|
||||
3. raises BridgeNotFound if neither has the name
|
||||
"""
|
||||
if name in inline_tunnels:
|
||||
return inline_tunnels[name]
|
||||
|
||||
if catalog is not None and name in catalog.bridges:
|
||||
return catalog.bridges[name].to_tunnel_config()
|
||||
|
||||
raise BridgeNotFound(
|
||||
f"Bridge '{name}' not found in inline config"
|
||||
+ (" or catalog" if catalog is not None else " (no catalog configured)")
|
||||
)
|
||||
42
src/bridge/catalog/validator.py
Normal file
42
src/bridge/catalog/validator.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Catalog validator — cross-reference checks for catalog consistency."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from bridge.catalog.models import Catalog
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Raised when catalog validation fails (used for programmatic access)."""
|
||||
|
||||
|
||||
def validate_catalog(catalog: Catalog) -> List[str]:
|
||||
"""Return a list of validation error strings (empty = valid)."""
|
||||
errors: List[str] = []
|
||||
|
||||
for target in catalog.targets.values():
|
||||
if target.domain not in catalog.domains:
|
||||
errors.append(
|
||||
f"Target '{target.id}': domain '{target.domain}' does not exist in catalog"
|
||||
)
|
||||
for bridge_id in target.reachable_via:
|
||||
if bridge_id not in catalog.bridges:
|
||||
errors.append(
|
||||
f"Target '{target.id}': reachable_via references unknown bridge '{bridge_id}'"
|
||||
)
|
||||
|
||||
for bridge in catalog.bridges.values():
|
||||
if bridge.domain not in catalog.domains:
|
||||
errors.append(
|
||||
f"Bridge '{bridge.id}': domain '{bridge.domain}' does not exist in catalog"
|
||||
)
|
||||
if bridge.target not in catalog.targets:
|
||||
errors.append(
|
||||
f"Bridge '{bridge.id}': target '{bridge.target}' does not exist in catalog"
|
||||
)
|
||||
if bridge.actor not in catalog.actors:
|
||||
errors.append(
|
||||
f"Bridge '{bridge.id}': actor '{bridge.actor}' does not exist in catalog"
|
||||
)
|
||||
|
||||
return errors
|
||||
Reference in New Issue
Block a user