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:
196
tests/test_catalog_integration.py
Normal file
196
tests/test_catalog_integration.py
Normal 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 == []
|
||||
Reference in New Issue
Block a user