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:
2026-03-12 02:05:06 +00:00
parent a7eaf59ced
commit 91d031ae20
13 changed files with 1435 additions and 50 deletions

203
tests/test_catalog_cli.py Normal file
View File

@@ -0,0 +1,203 @@
"""Tests for catalog CLI commands (targets, catalog list/validate/show)."""
import json
import textwrap
from pathlib import Path
import pytest
from typer.testing import CliRunner
from bridge.cli import app
runner = CliRunner()
# Config with catalog_path pointing to a fixture
BASE_CONFIG = textwrap.dedent("""\
tunnels: {{}}
actors: {{}}
catalog_path: {catalog_path}
""")
CONFIG_NO_CATALOG = textwrap.dedent("""\
tunnels: {}
actors: {}
""")
@pytest.fixture
def catalog_dir(tmp_path):
root = tmp_path / "opscatalog"
domain_dir = root / "domains" / "coulombcore"
(domain_dir / "targets").mkdir(parents=True)
(domain_dir / "bridges").mkdir(parents=True)
actors_dir = root / "actors"
actors_dir.mkdir(parents=True)
(domain_dir / "domain.yaml").write_text(textwrap.dedent("""\
type: domain
id: coulombcore
name: CoulombCore Infrastructure
description: Core infra
environment: production
"""))
(domain_dir / "targets" / "state-hub.yaml").write_text(textwrap.dedent("""\
type: target
id: state-hub
domain: coulombcore
kind: service
description: State coordination service
reachable_via:
- state-hub-coulombcore
"""))
(domain_dir / "bridges" / "state-hub-coulombcore.yaml").write_text(textwrap.dedent("""\
type: bridge
id: state-hub-coulombcore
domain: coulombcore
target: state-hub
description: Ops bridge for state hub
access_method: ssh-reverse
host: coulombcore.local
remote_port: 18000
local_port: 8000
ssh_user: ubuntu
ssh_key: ~/.ssh/id_ops
actor: agent.claude-coulombcore
"""))
(actors_dir / "agents.yaml").write_text(textwrap.dedent("""\
type: actor
id: agent.claude-coulombcore
class: automation
description: Claude Code agent
"""))
return root
@pytest.fixture
def config_file(tmp_path, catalog_dir):
f = tmp_path / "tunnels.yaml"
f.write_text(BASE_CONFIG.format(catalog_path=str(catalog_dir)))
return f
@pytest.fixture
def env(config_file, tmp_path):
return {
"BRIDGE_CONFIG": str(config_file),
"BRIDGE_STATE_DIR": str(tmp_path / "state"),
}
class TestTargetsCommand:
def test_targets_shows_table(self, env):
result = runner.invoke(app, ["targets"], env=env)
assert result.exit_code == 0
assert "state-hub" in result.output
def test_targets_json(self, env):
result = runner.invoke(app, ["targets", "--json"], env=env)
assert result.exit_code == 0
data = json.loads(result.output)
assert isinstance(data, list)
assert any(t["target"] == "state-hub" for t in data)
assert any(t["domain"] == "coulombcore" for t in data)
def test_targets_domain_filter(self, env):
result = runner.invoke(app, ["targets", "--domain", "coulombcore"], env=env)
assert result.exit_code == 0
assert "state-hub" in result.output
def test_targets_domain_filter_unknown(self, env):
result = runner.invoke(app, ["targets", "--domain", "nonexistent"], env=env)
assert result.exit_code == 0
# No results but no crash
def test_targets_no_catalog_configured(self, tmp_path):
f = tmp_path / "tunnels.yaml"
f.write_text(CONFIG_NO_CATALOG)
result = runner.invoke(app, ["targets"], env={"BRIDGE_CONFIG": str(f)})
assert result.exit_code == 1
assert "catalog" in result.output.lower()
def test_targets_show_subcommand(self, env):
result = runner.invoke(app, ["targets", "show", "state-hub"], env=env)
assert result.exit_code == 0
assert "state-hub" in result.output
assert "coulombcore" in result.output
def test_targets_show_unknown(self, env):
result = runner.invoke(app, ["targets", "show", "nonexistent"], env=env)
assert result.exit_code == 1
class TestCatalogCommand:
def test_catalog_list(self, env):
result = runner.invoke(app, ["catalog", "list"], env=env)
assert result.exit_code == 0
assert "coulombcore" in result.output
def test_catalog_list_json(self, env):
result = runner.invoke(app, ["catalog", "list", "--json"], env=env)
assert result.exit_code == 0
data = json.loads(result.output)
assert isinstance(data, list)
assert any(d["domain"] == "coulombcore" for d in data)
def test_catalog_validate_clean(self, env):
result = runner.invoke(app, ["catalog", "validate"], env=env)
assert result.exit_code == 0
assert "valid" in result.output.lower() or "ok" in result.output.lower() or "0" in result.output
def test_catalog_validate_with_errors(self, tmp_path):
# Catalog with dangling reference
root = tmp_path / "bad-catalog"
domain_dir = root / "domains" / "d"
(domain_dir / "targets").mkdir(parents=True)
(domain_dir / "domain.yaml").write_text(
"type: domain\nid: d\nname: D\n"
)
(domain_dir / "targets" / "t.yaml").write_text(
"type: target\nid: t\ndomain: d\nkind: service\nreachable_via:\n - missing-bridge\n"
)
f = tmp_path / "tunnels.yaml"
f.write_text(BASE_CONFIG.format(catalog_path=str(root)))
result = runner.invoke(app, ["catalog", "validate"], env={"BRIDGE_CONFIG": str(f)})
assert result.exit_code == 1
assert "missing-bridge" in result.output
def test_catalog_show(self, env):
result = runner.invoke(app, ["catalog", "show", "state-hub-coulombcore"], env=env)
assert result.exit_code == 0
assert "state-hub-coulombcore" in result.output
assert "coulombcore.local" in result.output
def test_catalog_show_unknown(self, env):
result = runner.invoke(app, ["catalog", "show", "nonexistent"], env=env)
assert result.exit_code == 1
def test_catalog_no_catalog_configured(self, tmp_path):
f = tmp_path / "tunnels.yaml"
f.write_text(CONFIG_NO_CATALOG)
result = runner.invoke(app, ["catalog", "list"], env={"BRIDGE_CONFIG": str(f)})
assert result.exit_code == 1
class TestUpWithCatalogFallback:
def test_up_resolves_catalog_bridge(self, env):
"""bridge up <catalog-bridge-name> works when name not in inline tunnels.yaml."""
from unittest.mock import MagicMock, patch
with patch("bridge.cli.TunnelManager") as mock_mgr_cls:
mock_mgr = MagicMock()
mock_mgr.is_running.return_value = False
mock_mgr_cls.return_value = mock_mgr
result = runner.invoke(app, ["up", "state-hub-coulombcore"], env=env)
assert result.exit_code == 0
mock_mgr.start.assert_called_once()
def test_up_unknown_bridge_exit_1(self, env):
result = runner.invoke(app, ["up", "totally-nonexistent"], env=env)
assert result.exit_code == 1

View File

@@ -0,0 +1,196 @@
"""Integration tests for OpsCatalog (T14-T16 from BRIDGE-WP-0002)."""
import json
import textwrap
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from typer.testing import CliRunner
from bridge.catalog.loader import load_catalog
from bridge.catalog.resolver import BridgeNotFound, resolve
from bridge.catalog.validator import validate_catalog
from bridge.cli import app
runner = CliRunner()
@pytest.fixture
def catalog_dir(tmp_path):
root = tmp_path / "opscatalog"
domain_dir = root / "domains" / "coulombcore"
(domain_dir / "targets").mkdir(parents=True)
(domain_dir / "bridges").mkdir(parents=True)
(domain_dir / "docs").mkdir(parents=True)
actors_dir = root / "actors"
actors_dir.mkdir(parents=True)
(domain_dir / "domain.yaml").write_text(textwrap.dedent("""\
type: domain
id: coulombcore
name: CoulombCore Infrastructure
description: Core infra
environment: production
"""))
(domain_dir / "targets" / "state-hub.yaml").write_text(textwrap.dedent("""\
type: target
id: state-hub
domain: coulombcore
kind: service
description: State coordination service
reachable_via:
- state-hub-coulombcore
"""))
(domain_dir / "bridges" / "state-hub-coulombcore.yaml").write_text(textwrap.dedent("""\
type: bridge
id: state-hub-coulombcore
domain: coulombcore
target: state-hub
description: Ops bridge for state hub
access_method: ssh-reverse
host: coulombcore.local
remote_port: 18000
local_port: 8000
ssh_user: ubuntu
ssh_key: ~/.ssh/id_ops
actor: agent.claude-coulombcore
reconnect:
max_attempts: 0
backoff_initial: 5
backoff_max: 60
"""))
(actors_dir / "agents.yaml").write_text(textwrap.dedent("""\
type: actor
id: agent.claude-coulombcore
class: automation
description: Claude Code agent on CoulombCore
"""))
(domain_dir / "docs" / "overview.md").write_text(
"# CoulombCore Overview\nCore infrastructure notes."
)
return root
@pytest.fixture
def config_with_catalog(tmp_path, catalog_dir):
f = tmp_path / "tunnels.yaml"
f.write_text(textwrap.dedent(f"""\
catalog_path: {catalog_dir}
tunnels: {{}}
actors: {{}}
"""))
return f
@pytest.fixture
def env(config_with_catalog, tmp_path):
return {
"BRIDGE_CONFIG": str(config_with_catalog),
"BRIDGE_STATE_DIR": str(tmp_path / "state"),
}
class TestT14CatalogLoadAndResolve:
def test_catalog_loads_all_types(self, catalog_dir):
cat = load_catalog(catalog_dir)
assert "coulombcore" in cat.domains
assert "state-hub" in cat.targets
assert "state-hub-coulombcore" in cat.bridges
assert "agent.claude-coulombcore" in cat.actors
def test_resolve_from_catalog(self, catalog_dir):
cat = load_catalog(catalog_dir)
tc = resolve("state-hub-coulombcore", catalog=cat, inline_tunnels={})
assert tc.name == "state-hub-coulombcore"
assert tc.host == "coulombcore.local"
assert tc.remote_port == 18000
def test_bridge_up_with_catalog_bridge(self, env):
with patch("bridge.cli.TunnelManager") as mock_mgr_cls:
mock_mgr = MagicMock()
mock_mgr.is_running.return_value = False
mock_mgr_cls.return_value = mock_mgr
result = runner.invoke(app, ["up", "state-hub-coulombcore"], env=env)
assert result.exit_code == 0
mock_mgr.start.assert_called_once()
# Verify TunnelManager was constructed with correct config
call_args = mock_mgr_cls.call_args
tcfg = call_args[0][0]
assert tcfg.host == "coulombcore.local"
assert tcfg.remote_port == 18000
class TestT15BridgeTargetsOutput:
def test_targets_table(self, env):
result = runner.invoke(app, ["targets"], env=env)
assert result.exit_code == 0
assert "state-hub" in result.output
assert "coulombcore" in result.output
assert "service" in result.output
def test_targets_json_structure(self, env):
result = runner.invoke(app, ["targets", "--json"], env=env)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 1
t = data[0]
assert t["target"] == "state-hub"
assert t["domain"] == "coulombcore"
assert t["kind"] == "service"
assert "state-hub-coulombcore" in t["bridges"]
def test_targets_show_includes_docs(self, env):
result = runner.invoke(app, ["targets", "show", "state-hub"], env=env)
assert result.exit_code == 0
assert "state-hub" in result.output
assert "coulombcore" in result.output
class TestT16CatalogValidate:
def test_validate_clean_catalog_exit_0(self, env):
result = runner.invoke(app, ["catalog", "validate"], env=env)
assert result.exit_code == 0
assert "ok" in result.output.lower() or "0" in result.output
def test_validate_dangling_reference_exit_1(self, tmp_path):
root = tmp_path / "bad"
domain_dir = root / "domains" / "d"
(domain_dir / "targets").mkdir(parents=True)
(domain_dir / "bridges").mkdir(parents=True)
(root / "actors").mkdir(parents=True)
(domain_dir / "domain.yaml").write_text("type: domain\nid: d\nname: D\n")
(domain_dir / "targets" / "t.yaml").write_text(
"type: target\nid: t\ndomain: d\nkind: service\n"
"reachable_via:\n - nonexistent-bridge\n"
)
(domain_dir / "bridges" / "b.yaml").write_text(
"type: bridge\nid: b\ndomain: d\ntarget: t\n"
"host: h\nremote_port: 1\nlocal_port: 2\n"
"ssh_user: u\nssh_key: k\nactor: missing-actor\n"
)
f = tmp_path / "tunnels.yaml"
f.write_text(f"catalog_path: {root}\ntunnels: {{}}\nactors: {{}}\n")
result = runner.invoke(app, ["catalog", "validate"], env={"BRIDGE_CONFIG": str(f)})
assert result.exit_code == 1
assert "nonexistent-bridge" in result.output or "missing-actor" in result.output
def test_catalog_list_shows_counts(self, env):
result = runner.invoke(app, ["catalog", "list"], env=env)
assert result.exit_code == 0
assert "coulombcore" in result.output
def test_catalog_show_bridge(self, env):
result = runner.invoke(app, ["catalog", "show", "state-hub-coulombcore"], env=env)
assert result.exit_code == 0
assert "coulombcore.local" in result.output
assert "18000" in result.output
def test_validate_using_validator_directly(self, catalog_dir):
cat = load_catalog(catalog_dir)
errors = validate_catalog(cat)
assert errors == []

View File

@@ -0,0 +1,141 @@
"""Tests for catalog loader."""
import textwrap
from pathlib import Path
import pytest
from bridge.catalog.loader import CatalogLoadError, load_catalog
from bridge.catalog.models import Catalog
@pytest.fixture
def catalog_dir(tmp_path):
"""Build a minimal valid catalog fixture."""
root = tmp_path / "opscatalog"
domain_dir = root / "domains" / "coulombcore"
(domain_dir / "targets").mkdir(parents=True)
(domain_dir / "bridges").mkdir(parents=True)
(domain_dir / "docs").mkdir(parents=True)
actors_dir = root / "actors"
actors_dir.mkdir(parents=True)
(domain_dir / "domain.yaml").write_text(textwrap.dedent("""\
type: domain
id: coulombcore
name: CoulombCore Infrastructure
description: Core infra
environment: production
"""))
(domain_dir / "targets" / "state-hub.yaml").write_text(textwrap.dedent("""\
type: target
id: state-hub
domain: coulombcore
kind: service
description: State coordination service
reachable_via:
- state-hub-coulombcore
"""))
(domain_dir / "bridges" / "state-hub-coulombcore.yaml").write_text(textwrap.dedent("""\
type: bridge
id: state-hub-coulombcore
domain: coulombcore
target: state-hub
description: Ops bridge
access_method: ssh-reverse
host: coulombcore.local
remote_port: 18000
local_port: 8000
ssh_user: ubuntu
ssh_key: ~/.ssh/id_ops
actor: agent.claude-coulombcore
health_check:
url: http://127.0.0.1:18000/health
interval_seconds: 30
timeout_seconds: 5
reconnect:
max_attempts: 0
backoff_initial: 5
backoff_max: 60
"""))
(actors_dir / "agents.yaml").write_text(textwrap.dedent("""\
type: actor
id: agent.claude-coulombcore
class: automation
description: Claude Code agent on CoulombCore
"""))
(domain_dir / "docs" / "overview.md").write_text("# Overview\nSome ops notes.")
return root
class TestLoadCatalog:
def test_loads_domain(self, catalog_dir):
cat = load_catalog(catalog_dir)
assert "coulombcore" in cat.domains
d = cat.domains["coulombcore"]
assert d.name == "CoulombCore Infrastructure"
assert d.environment == "production"
def test_loads_target(self, catalog_dir):
cat = load_catalog(catalog_dir)
assert "state-hub" in cat.targets
t = cat.targets["state-hub"]
assert t.domain == "coulombcore"
assert t.kind == "service"
assert "state-hub-coulombcore" in t.reachable_via
def test_loads_bridge(self, catalog_dir):
cat = load_catalog(catalog_dir)
assert "state-hub-coulombcore" in cat.bridges
b = cat.bridges["state-hub-coulombcore"]
assert b.host == "coulombcore.local"
assert b.remote_port == 18000
assert b.health_check is not None
assert b.health_check.url == "http://127.0.0.1:18000/health"
assert b.reconnect is not None
assert b.reconnect.max_attempts == 0
def test_loads_actor(self, catalog_dir):
cat = load_catalog(catalog_dir)
assert "agent.claude-coulombcore" in cat.actors
a = cat.actors["agent.claude-coulombcore"]
assert a.actor_class == "automation"
def test_unknown_type_skipped(self, catalog_dir):
(catalog_dir / "domains" / "coulombcore" / "unknown.yaml").write_text(
"type: mystery\nid: x\n"
)
# Should not raise
cat = load_catalog(catalog_dir)
assert isinstance(cat, Catalog)
def test_empty_catalog_dir(self, tmp_path):
root = tmp_path / "empty"
root.mkdir()
cat = load_catalog(root)
assert cat.domains == {}
assert cat.bridges == {}
def test_missing_required_field_raises(self, tmp_path):
root = tmp_path / "bad"
domain_dir = root / "domains" / "x"
domain_dir.mkdir(parents=True)
(domain_dir / "domain.yaml").write_text("type: domain\nname: X\n")
with pytest.raises(CatalogLoadError, match="id"):
load_catalog(root)
def test_nonexistent_path_raises(self, tmp_path):
with pytest.raises(CatalogLoadError, match="not found"):
load_catalog(tmp_path / "nonexistent")
def test_invalid_yaml_raises(self, tmp_path):
root = tmp_path / "bad"
domain_dir = root / "domains" / "x"
domain_dir.mkdir(parents=True)
(domain_dir / "domain.yaml").write_text("type: domain\n[\nbad: yaml")
with pytest.raises(CatalogLoadError):
load_catalog(root)

View File

@@ -0,0 +1,116 @@
"""Tests for catalog domain models."""
import pytest
from bridge.catalog.models import (
ActorClass,
Catalog,
CatalogBridge,
CatalogDomain,
CatalogTarget,
)
class TestCatalogDomain:
def test_required_fields(self):
d = CatalogDomain(id="coulombcore", name="CoulombCore Infra")
assert d.id == "coulombcore"
assert d.name == "CoulombCore Infra"
def test_optional_fields_default(self):
d = CatalogDomain(id="x", name="X")
assert d.description == ""
assert d.environment == ""
class TestCatalogTarget:
def test_required_fields(self):
t = CatalogTarget(id="state-hub", domain="coulombcore", kind="service")
assert t.id == "state-hub"
assert t.domain == "coulombcore"
assert t.kind == "service"
def test_reachable_via_defaults_empty(self):
t = CatalogTarget(id="t", domain="d", kind="service")
assert t.reachable_via == []
def test_reachable_via(self):
t = CatalogTarget(id="t", domain="d", kind="service", reachable_via=["b1", "b2"])
assert t.reachable_via == ["b1", "b2"]
class TestCatalogBridge:
def test_required_fields(self):
b = CatalogBridge(
id="state-hub-coulombcore",
domain="coulombcore",
target="state-hub",
host="coulombcore.local",
remote_port=18000,
local_port=8000,
ssh_user="ubuntu",
ssh_key="~/.ssh/id_ops",
actor="agent.claude-coulombcore",
)
assert b.id == "state-hub-coulombcore"
assert b.domain == "coulombcore"
assert b.host == "coulombcore.local"
def test_optional_fields_default(self):
b = CatalogBridge(
id="b",
domain="d",
target="t",
host="h",
remote_port=1,
local_port=2,
ssh_user="u",
ssh_key="k",
actor="a",
)
assert b.description == ""
assert b.access_method == "ssh-reverse"
assert b.health_check is None
assert b.reconnect is None
def test_to_tunnel_config(self):
from bridge.models import TunnelConfig
b = CatalogBridge(
id="state-hub-coulombcore",
domain="coulombcore",
target="state-hub",
host="coulombcore.local",
remote_port=18000,
local_port=8000,
ssh_user="ubuntu",
ssh_key="~/.ssh/id_ops",
actor="agent.claude-coulombcore",
)
tc = b.to_tunnel_config()
assert isinstance(tc, TunnelConfig)
assert tc.name == "state-hub-coulombcore"
assert tc.host == "coulombcore.local"
assert tc.remote_port == 18000
class TestActorClass:
def test_fields(self):
a = ActorClass(id="agent.claude", actor_class="automation", description="Claude agent")
assert a.id == "agent.claude"
assert a.actor_class == "automation"
def test_optional_description(self):
a = ActorClass(id="x", actor_class="human")
assert a.description == ""
class TestCatalog:
def test_empty_catalog(self):
c = Catalog()
assert c.domains == {}
assert c.targets == {}
assert c.bridges == {}
assert c.actors == {}
def test_add_entries(self):
c = Catalog()
c.domains["d"] = CatalogDomain(id="d", name="D")
assert "d" in c.domains

View File

@@ -0,0 +1,89 @@
"""Tests for catalog resolver."""
import pytest
from bridge.catalog.models import (
ActorClass,
Catalog,
CatalogBridge,
CatalogDomain,
CatalogTarget,
)
from bridge.catalog.resolver import BridgeNotFound, resolve
from bridge.models import TunnelConfig, ReconnectPolicy
@pytest.fixture
def catalog():
cat = Catalog()
cat.domains["d"] = CatalogDomain(id="d", name="D")
cat.targets["t"] = CatalogTarget(id="t", domain="d", kind="service")
cat.bridges["catalog-bridge"] = CatalogBridge(
id="catalog-bridge",
domain="d",
target="t",
host="catalog-host.local",
remote_port=19000,
local_port=9000,
ssh_user="ubuntu",
ssh_key="~/.ssh/catalog",
actor="operator.bernd",
)
cat.actors["operator.bernd"] = ActorClass(id="operator.bernd", actor_class="human")
return cat
@pytest.fixture
def inline_tunnels():
return {
"inline-bridge": TunnelConfig(
name="inline-bridge",
host="inline-host.local",
remote_port=18000,
local_port=8000,
ssh_user="ubuntu",
ssh_key="~/.ssh/inline",
actor="operator.bernd",
)
}
class TestResolve:
def test_inline_takes_precedence(self, catalog, inline_tunnels):
tc = resolve("inline-bridge", catalog=catalog, inline_tunnels=inline_tunnels)
assert tc.host == "inline-host.local"
def test_catalog_fallback(self, catalog, inline_tunnels):
tc = resolve("catalog-bridge", catalog=catalog, inline_tunnels=inline_tunnels)
assert tc.host == "catalog-host.local"
assert tc.remote_port == 19000
def test_catalog_fallback_no_inline(self, catalog):
tc = resolve("catalog-bridge", catalog=catalog, inline_tunnels={})
assert tc.name == "catalog-bridge"
def test_missing_name_raises(self, catalog, inline_tunnels):
with pytest.raises(BridgeNotFound, match="nonexistent"):
resolve("nonexistent", catalog=catalog, inline_tunnels=inline_tunnels)
def test_missing_name_no_catalog_raises(self, inline_tunnels):
with pytest.raises(BridgeNotFound):
resolve("nonexistent", catalog=None, inline_tunnels=inline_tunnels)
def test_inline_bridge_returns_tunnel_config(self, catalog, inline_tunnels):
tc = resolve("inline-bridge", catalog=catalog, inline_tunnels=inline_tunnels)
assert isinstance(tc, TunnelConfig)
def test_catalog_bridge_returns_tunnel_config(self, catalog):
tc = resolve("catalog-bridge", catalog=catalog, inline_tunnels={})
assert isinstance(tc, TunnelConfig)
def test_catalog_is_none_no_inline_raises(self):
with pytest.raises(BridgeNotFound):
resolve("any-name", catalog=None, inline_tunnels={})
def test_resolve_preserves_reconnect_policy(self, catalog):
from bridge.models import ReconnectPolicy
catalog.bridges["catalog-bridge"].reconnect = ReconnectPolicy(
max_attempts=3, backoff_initial=2, backoff_max=30
)
tc = resolve("catalog-bridge", catalog=catalog, inline_tunnels={})
assert tc.reconnect.max_attempts == 3

View File

@@ -0,0 +1,94 @@
"""Tests for catalog validator."""
import pytest
from bridge.catalog.models import (
ActorClass,
Catalog,
CatalogBridge,
CatalogDomain,
CatalogTarget,
)
from bridge.catalog.validator import ValidationError, validate_catalog
def _make_full_catalog() -> Catalog:
cat = Catalog()
cat.domains["coulombcore"] = CatalogDomain(id="coulombcore", name="CoulombCore")
cat.targets["state-hub"] = CatalogTarget(
id="state-hub",
domain="coulombcore",
kind="service",
reachable_via=["state-hub-coulombcore"],
)
cat.bridges["state-hub-coulombcore"] = CatalogBridge(
id="state-hub-coulombcore",
domain="coulombcore",
target="state-hub",
host="host.local",
remote_port=18000,
local_port=8000,
ssh_user="ubuntu",
ssh_key="~/.ssh/id_ops",
actor="agent.claude-coulombcore",
)
cat.actors["agent.claude-coulombcore"] = ActorClass(
id="agent.claude-coulombcore",
actor_class="automation",
)
return cat
class TestValidateCatalog:
def test_valid_catalog_no_errors(self):
cat = _make_full_catalog()
errors = validate_catalog(cat)
assert errors == []
def test_target_domain_must_exist(self):
cat = _make_full_catalog()
cat.targets["orphan"] = CatalogTarget(
id="orphan", domain="nonexistent-domain", kind="service"
)
errors = validate_catalog(cat)
assert any("orphan" in e and "nonexistent-domain" in e for e in errors)
def test_target_reachable_via_must_exist(self):
cat = _make_full_catalog()
cat.targets["state-hub"].reachable_via.append("nonexistent-bridge")
errors = validate_catalog(cat)
assert any("nonexistent-bridge" in e for e in errors)
def test_bridge_domain_must_exist(self):
cat = _make_full_catalog()
cat.bridges["state-hub-coulombcore"].domain = "missing-domain"
errors = validate_catalog(cat)
assert any("missing-domain" in e for e in errors)
def test_bridge_target_must_exist(self):
cat = _make_full_catalog()
cat.bridges["state-hub-coulombcore"].target = "missing-target"
errors = validate_catalog(cat)
assert any("missing-target" in e for e in errors)
def test_bridge_actor_must_exist(self):
cat = _make_full_catalog()
cat.bridges["state-hub-coulombcore"].actor = "nonexistent-actor"
errors = validate_catalog(cat)
assert any("nonexistent-actor" in e for e in errors)
def test_multiple_errors_all_reported(self):
cat = Catalog()
# Target with dangling domain and reachable_via
cat.targets["t1"] = CatalogTarget(
id="t1", domain="missing", kind="service", reachable_via=["missing-bridge"]
)
# Bridge with dangling domain + target + actor
cat.bridges["b1"] = CatalogBridge(
id="b1", domain="missing", target="missing", host="h",
remote_port=1, local_port=2, ssh_user="u", ssh_key="k", actor="missing-actor",
)
errors = validate_catalog(cat)
assert len(errors) >= 4
def test_empty_catalog_is_valid(self):
cat = Catalog()
assert validate_catalog(cat) == []