generated from coulomb/repo-seed
Implement ops inventory probe evidence slice
This commit is contained in:
44
tests/test_ops_event_types.py
Normal file
44
tests/test_ops_event_types.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from activity_core.event_type_registry import parse_event_type_file
|
||||
|
||||
_EVENT_DIR = Path(__file__).parent.parent / "event-types"
|
||||
_OPS_EVENT_TYPES = {
|
||||
"ops-service-observed",
|
||||
"ops-endpoint-verified",
|
||||
"ops-access-path-checked",
|
||||
"ops-backup-verified",
|
||||
"ops-inventory-drift",
|
||||
}
|
||||
|
||||
|
||||
def test_ops_event_type_definitions_parse_and_expose_required_fields() -> None:
|
||||
for type_id in _OPS_EVENT_TYPES:
|
||||
path = _EVENT_DIR / f"{type_id}.md"
|
||||
event_type = parse_event_type_file(path)
|
||||
|
||||
assert event_type.type_id == type_id
|
||||
assert event_type.publisher == "activity-core"
|
||||
assert event_type.status == "active"
|
||||
assert event_type.attribute_schema["activity_core_run_id"]["required"] is True
|
||||
assert event_type.attribute_schema["idempotency_key"]["required"] is True
|
||||
assert event_type.attribute_schema["service_id"]["required"] is True
|
||||
assert event_type.attribute_schema["observed_status"]["required"] is True
|
||||
assert "raw response" in event_type.raw_md
|
||||
assert "unredacted URL query strings" in event_type.raw_md
|
||||
|
||||
|
||||
def test_endpoint_event_contract_captures_probe_result_fields() -> None:
|
||||
event_type = parse_event_type_file(_EVENT_DIR / "ops-endpoint-verified.md")
|
||||
|
||||
for field in (
|
||||
"endpoint_id",
|
||||
"endpoint_url",
|
||||
"expected_status",
|
||||
"status_code",
|
||||
"matched_expected_status",
|
||||
"matched_expected_signal",
|
||||
):
|
||||
assert field in event_type.attribute_schema
|
||||
195
tests/test_ops_evidence_sinks.py
Normal file
195
tests/test_ops_evidence_sinks.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from activity_core.ops_evidence_sinks import persist_ops_inventory_evidence
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(self, payload: Any) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> Any:
|
||||
return self.payload
|
||||
|
||||
|
||||
def _payload(sinks: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return {
|
||||
"activity_id": "activity-1",
|
||||
"run_id": "12345678-aaaa-bbbb-cccc-123456789abc",
|
||||
"scheduled_for": "2026-06-05T10:15:00+00:00",
|
||||
"version_used": 1,
|
||||
"context_sources": [
|
||||
{
|
||||
"type": "ops-inventory",
|
||||
"query": "probe_services",
|
||||
"bind_to": "context.ops_probe",
|
||||
"params": {"evidence_sinks": sinks},
|
||||
}
|
||||
],
|
||||
"context": {
|
||||
"ops_probe": {
|
||||
"generated_at": "2026-06-05T10:15:01+00:00",
|
||||
"inventory_path": "/tmp/service-inventory.yml",
|
||||
"summary": {"ok": 1, "degraded": 0, "down": 0, "skipped": 1},
|
||||
"services": [
|
||||
{
|
||||
"service_id": "state-hub",
|
||||
"name": "State Hub",
|
||||
"kind": "coordination-service",
|
||||
"environment": "local",
|
||||
"lifecycle_state": "observed",
|
||||
"declared_health_status": "unknown",
|
||||
"owner_repos": ["state-hub"],
|
||||
"endpoint_count": 1,
|
||||
"access_path_count": 1,
|
||||
}
|
||||
],
|
||||
"endpoints": [
|
||||
{
|
||||
"service_id": "state-hub",
|
||||
"service_name": "State Hub",
|
||||
"endpoint_id": "state-hub-health",
|
||||
"endpoint_type": "http",
|
||||
"url": "http://user:pass@state-hub.test/health?token=secret",
|
||||
"expected_status": 200,
|
||||
"expected_signal_present": True,
|
||||
"widget_ref": "ops:endpoint:state-hub-health",
|
||||
"status": "ok",
|
||||
"status_code": 200,
|
||||
"matched_expected_status": True,
|
||||
"matched_expected_signal": True,
|
||||
"response_body": "secret response body",
|
||||
"headers": {"Authorization": "Bearer secret"},
|
||||
}
|
||||
],
|
||||
"access_paths": [
|
||||
{
|
||||
"service_id": "state-hub",
|
||||
"service_name": "State Hub",
|
||||
"access_path_id": "state-hub-access-1",
|
||||
"access_path_type": "k8s",
|
||||
"declared_status": "unknown",
|
||||
"status": "skipped",
|
||||
"reason": "unsupported_access_path_type",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_state_hub_progress_sink_posts_compact_probe_summary(monkeypatch) -> None:
|
||||
posts: list[dict[str, Any]] = []
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
||||
assert url == "http://state-hub.test/progress/"
|
||||
return DummyResponse([])
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> DummyResponse:
|
||||
posts.append({"url": url, **kwargs})
|
||||
return DummyResponse({"id": "progress-1"})
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
monkeypatch.setattr(httpx, "post", fake_post)
|
||||
|
||||
result = persist_ops_inventory_evidence(
|
||||
_payload([
|
||||
{
|
||||
"type": "state-hub-progress",
|
||||
"state_hub_url": "http://state-hub.test",
|
||||
"event_type": "ops_inventory_probe",
|
||||
"workstream_id": "workstream-1",
|
||||
"task_id": "task-1",
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{
|
||||
"type": "state-hub-progress",
|
||||
"status": "posted",
|
||||
"event_type": "ops_inventory_probe",
|
||||
"progress_id": "progress-1",
|
||||
"idempotency_key": "12345678-aaaa-bbbb-cccc-123456789abc:ops_probe:ops_inventory_probe",
|
||||
"context_key": "ops_probe",
|
||||
}
|
||||
]
|
||||
body = posts[0]["json"]
|
||||
assert body["summary"] == "Ops inventory probe: 1 ok, 0 degraded, 0 down, 1 skipped"
|
||||
assert body["workstream_id"] == "workstream-1"
|
||||
assert body["task_id"] == "task-1"
|
||||
assert body["detail"]["activity_core_run_id"] == _run_id()
|
||||
assert body["detail"]["idempotency_key"] == result[0]["idempotency_key"]
|
||||
assert body["detail"]["probe"]["endpoints"][0]["url"] == "http://state-hub.test/health"
|
||||
|
||||
serialized = json.dumps(body, sort_keys=True)
|
||||
assert "secret response body" not in serialized
|
||||
assert "Authorization" not in serialized
|
||||
assert "user:pass" not in serialized
|
||||
assert "token=secret" not in serialized
|
||||
|
||||
|
||||
def test_state_hub_progress_sink_is_idempotent(monkeypatch) -> None:
|
||||
idempotency_key = f"{_run_id()}:ops_probe:ops_inventory_probe"
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
||||
return DummyResponse([
|
||||
{
|
||||
"event_type": "ops_inventory_probe",
|
||||
"detail": {"idempotency_key": idempotency_key},
|
||||
}
|
||||
])
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> DummyResponse:
|
||||
raise AssertionError("post should not be called")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
monkeypatch.setattr(httpx, "post", fake_post)
|
||||
|
||||
result = persist_ops_inventory_evidence(
|
||||
_payload([
|
||||
{
|
||||
"type": "state-hub-progress",
|
||||
"state_hub_url": "http://state-hub.test",
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
assert result[0]["status"] == "exists"
|
||||
assert result[0]["idempotency_key"] == idempotency_key
|
||||
|
||||
|
||||
def test_inter_hub_sink_skips_cleanly_when_config_missing(monkeypatch) -> None:
|
||||
monkeypatch.delenv("INTER_HUB_URL", raising=False)
|
||||
monkeypatch.delenv("OPS_HUB_KEY", raising=False)
|
||||
|
||||
result = persist_ops_inventory_evidence(
|
||||
_payload([{"type": "inter-hub-interaction-event"}])
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{
|
||||
"type": "inter-hub-interaction-event",
|
||||
"status": "skipped",
|
||||
"reason": "missing_inter_hub_config",
|
||||
"missing": ["INTER_HUB_URL", "OPS_HUB_KEY", "widget_mapping"],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_no_evidence_sinks_returns_no_results() -> None:
|
||||
payload = _payload([])
|
||||
payload["context_sources"][0]["params"] = {}
|
||||
|
||||
assert persist_ops_inventory_evidence(payload) == []
|
||||
|
||||
|
||||
def _run_id() -> str:
|
||||
return "12345678-aaaa-bbbb-cccc-123456789abc"
|
||||
283
tests/test_ops_inventory_context_resolver.py
Normal file
283
tests/test_ops_inventory_context_resolver.py
Normal file
@@ -0,0 +1,283 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from activity_core.context_resolvers.ops_inventory import OpsInventoryContextResolver
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(self, status_code: int, text: str = "") -> None:
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
|
||||
|
||||
def _write_inventory(tmp_path: Path, services: str) -> Path:
|
||||
path = tmp_path / "service-inventory.yml"
|
||||
path.write_text(
|
||||
f"""
|
||||
version: 1
|
||||
last_reviewed: "2026-06-05"
|
||||
environments: []
|
||||
hosts: []
|
||||
clusters: []
|
||||
services:
|
||||
{services}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def test_probe_services_reports_ok_endpoint_and_skipped_access_path(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
inventory = _write_inventory(
|
||||
tmp_path,
|
||||
"""
|
||||
- id: state-hub
|
||||
name: State Hub
|
||||
kind: coordination-service
|
||||
lifecycle_state: observed
|
||||
health_status: unknown
|
||||
environment: local
|
||||
owner_repos: [state-hub]
|
||||
endpoints:
|
||||
- id: state-hub-health
|
||||
type: http
|
||||
url: "http://127.0.0.1:8000/state/health"
|
||||
expected_status: 200
|
||||
expected_signal: "health response"
|
||||
access_paths:
|
||||
- type: k8s
|
||||
target: local
|
||||
status: unknown
|
||||
""",
|
||||
)
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
||||
calls.append({"url": url, **kwargs})
|
||||
return DummyResponse(200, "ok: health response")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
|
||||
result = OpsInventoryContextResolver().resolve(
|
||||
"probe_services",
|
||||
None,
|
||||
{"inventory_path": str(inventory)},
|
||||
)
|
||||
|
||||
assert result["summary"] == {"ok": 1, "degraded": 0, "down": 0, "skipped": 1}
|
||||
assert result["services"][0]["service_id"] == "state-hub"
|
||||
assert result["endpoints"][0]["status"] == "ok"
|
||||
assert result["endpoints"][0]["matched_expected_status"] is True
|
||||
assert result["endpoints"][0]["matched_expected_signal"] is True
|
||||
assert result["access_paths"][0]["status"] == "skipped"
|
||||
assert result["access_paths"][0]["reason"] == "unsupported_access_path_type"
|
||||
assert calls == [
|
||||
{
|
||||
"url": "http://127.0.0.1:8000/state/health",
|
||||
"timeout": 10.0,
|
||||
"follow_redirects": False,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_probe_services_marks_status_mismatch_degraded(tmp_path, monkeypatch) -> None:
|
||||
inventory = _write_inventory(
|
||||
tmp_path,
|
||||
"""
|
||||
- id: gitea
|
||||
name: Gitea
|
||||
kind: application
|
||||
lifecycle_state: observed
|
||||
health_status: unknown
|
||||
environment: coulombcore
|
||||
owner_repos: [railiance-apps]
|
||||
endpoints:
|
||||
- id: gitea-registry
|
||||
type: https
|
||||
url: "https://gitea.coulomb.social/v2/"
|
||||
expected_status: 401
|
||||
expected_signal: "OCI registry auth challenge"
|
||||
access_paths: []
|
||||
""",
|
||||
)
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
||||
return DummyResponse(200, "OCI registry auth challenge")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
|
||||
result = OpsInventoryContextResolver().resolve(
|
||||
"probe_services",
|
||||
None,
|
||||
{"inventory_path": str(inventory)},
|
||||
)
|
||||
|
||||
endpoint = result["endpoints"][0]
|
||||
assert result["summary"] == {"ok": 0, "degraded": 1, "down": 0, "skipped": 0}
|
||||
assert endpoint["status"] == "degraded"
|
||||
assert endpoint["reason"] == "expected_status_mismatch"
|
||||
assert endpoint["matched_expected_status"] is False
|
||||
assert endpoint["matched_expected_signal"] is True
|
||||
|
||||
|
||||
def test_probe_services_marks_signal_mismatch_degraded(tmp_path, monkeypatch) -> None:
|
||||
inventory = _write_inventory(
|
||||
tmp_path,
|
||||
"""
|
||||
- id: inter-hub
|
||||
name: Inter-Hub
|
||||
kind: governance-service
|
||||
lifecycle_state: observed
|
||||
health_status: unknown
|
||||
environment: threephoenix-prod
|
||||
owner_repos: [inter-hub]
|
||||
endpoints:
|
||||
- id: inter-hub-openapi
|
||||
type: https
|
||||
url: "https://hub.coulomb.social/api/v2/openapi.json"
|
||||
expected_status: 200
|
||||
expected_signal: "OpenAPI document"
|
||||
access_paths: []
|
||||
""",
|
||||
)
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
||||
return DummyResponse(200, "{}")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
|
||||
result = OpsInventoryContextResolver().resolve(
|
||||
"probe_services",
|
||||
None,
|
||||
{"inventory_path": str(inventory)},
|
||||
)
|
||||
|
||||
endpoint = result["endpoints"][0]
|
||||
assert result["summary"] == {"ok": 0, "degraded": 1, "down": 0, "skipped": 0}
|
||||
assert endpoint["status"] == "degraded"
|
||||
assert endpoint["reason"] == "expected_signal_missing"
|
||||
assert endpoint["matched_expected_status"] is True
|
||||
assert endpoint["matched_expected_signal"] is False
|
||||
|
||||
|
||||
def test_probe_services_marks_network_error_down_and_sanitizes_output(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
inventory = _write_inventory(
|
||||
tmp_path,
|
||||
"""
|
||||
- id: private-api
|
||||
name: Private API
|
||||
kind: application
|
||||
lifecycle_state: observed
|
||||
health_status: unknown
|
||||
environment: local
|
||||
owner_repos: [secret-repo]
|
||||
endpoints:
|
||||
- id: private-api-health
|
||||
type: https
|
||||
url: "https://user:pass@example.test/health?token=super-secret"
|
||||
expected_status: 200
|
||||
expected_signal: "secret response body"
|
||||
access_paths: []
|
||||
""",
|
||||
)
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
||||
raise httpx.ConnectError("offline")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
|
||||
result = OpsInventoryContextResolver().resolve(
|
||||
"probe_services",
|
||||
None,
|
||||
{"inventory_path": str(inventory)},
|
||||
)
|
||||
serialized = json.dumps(result, sort_keys=True)
|
||||
|
||||
endpoint = result["endpoints"][0]
|
||||
assert result["summary"] == {"ok": 0, "degraded": 0, "down": 1, "skipped": 0}
|
||||
assert endpoint["status"] == "down"
|
||||
assert endpoint["url"] == "https://example.test/health"
|
||||
assert "super-secret" not in serialized
|
||||
assert "user:pass" not in serialized
|
||||
assert "secret response body" not in serialized
|
||||
|
||||
|
||||
def test_probe_services_skips_unsupported_and_network_disabled(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
inventory = _write_inventory(
|
||||
tmp_path,
|
||||
"""
|
||||
- id: bridge
|
||||
name: Ops Bridge
|
||||
kind: bridge
|
||||
lifecycle_state: observed
|
||||
health_status: unknown
|
||||
environment: local
|
||||
owner_repos: [ops-bridge]
|
||||
endpoints:
|
||||
- id: bridge-ssh
|
||||
type: ssh
|
||||
url: "ssh://bridge.example"
|
||||
- id: bridge-http
|
||||
type: http
|
||||
url: "http://bridge.example/health"
|
||||
access_paths: []
|
||||
""",
|
||||
)
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
||||
raise AssertionError("network should be disabled")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
|
||||
result = OpsInventoryContextResolver().resolve(
|
||||
"probe_services",
|
||||
None,
|
||||
{"inventory_path": str(inventory), "allow_network": False},
|
||||
)
|
||||
|
||||
assert result["summary"] == {"ok": 0, "degraded": 0, "down": 0, "skipped": 2}
|
||||
assert [entry["reason"] for entry in result["endpoints"]] == [
|
||||
"kind_not_included",
|
||||
"network_disabled",
|
||||
]
|
||||
|
||||
|
||||
def test_probe_services_missing_inventory_optional_and_required(tmp_path) -> None:
|
||||
missing = tmp_path / "missing.yml"
|
||||
resolver = OpsInventoryContextResolver()
|
||||
|
||||
optional = resolver.resolve(
|
||||
"probe_services",
|
||||
None,
|
||||
{"inventory_path": str(missing), "required": False},
|
||||
)
|
||||
|
||||
assert optional["status"] == "skipped"
|
||||
assert optional["reason"] == "inventory_not_found"
|
||||
assert optional["summary"] == {"ok": 0, "degraded": 0, "down": 0, "skipped": 1}
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
resolver.resolve(
|
||||
"probe_services",
|
||||
None,
|
||||
{"inventory_path": str(missing), "required": True},
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_query_returns_empty() -> None:
|
||||
assert OpsInventoryContextResolver().resolve("unknown", None, {}) == {}
|
||||
@@ -125,9 +125,15 @@ async def test_delete_schedule_removes_schedule(env: WorkflowEnvironment) -> Non
|
||||
await upsert_schedule(env.client, defn)
|
||||
await delete_schedule(env.client, defn.id)
|
||||
|
||||
schedules = await list_schedules(env.client)
|
||||
ids = [s["schedule_id"] for s in schedules]
|
||||
assert schedule_id(defn.id) not in ids, "Schedule should be gone after delete"
|
||||
sid = schedule_id(defn.id)
|
||||
ids: list[str] = []
|
||||
for _ in range(10):
|
||||
schedules = await list_schedules(env.client)
|
||||
ids = [s["schedule_id"] for s in schedules]
|
||||
if sid not in ids:
|
||||
break
|
||||
await asyncio.sleep(0.3)
|
||||
assert sid not in ids, "Schedule should be gone after delete"
|
||||
|
||||
|
||||
# ── T25e: delete_schedule is idempotent (no-op for non-existent schedule) ────
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from activity_core.definition_parser import scan_and_parse
|
||||
from activity_core.models import ActivityDefinition
|
||||
from activity_core.sync_activity_definitions import _definition_uuid
|
||||
|
||||
@@ -41,3 +42,56 @@ def test_activity_definition_accepts_adr_style_context_source_without_name() ->
|
||||
)
|
||||
|
||||
assert defn.context_sources[0].name == ""
|
||||
|
||||
|
||||
def test_scan_and_parse_reads_external_activity_definition_dirs(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
repo_root = tmp_path / "activity-core"
|
||||
external_root = tmp_path / "the-custodian"
|
||||
definitions_dir = external_root / "activity-definitions"
|
||||
repo_root.mkdir()
|
||||
definitions_dir.mkdir(parents=True)
|
||||
(definitions_dir / "ops-service-inventory-probes.md").write_text(
|
||||
"""---
|
||||
id: "40d15a87-7ff6-4d8e-992c-37df15f95110"
|
||||
name: "Ops Service Inventory Probes"
|
||||
enabled: false
|
||||
owner: custodian
|
||||
governance: custodian
|
||||
status: proposed
|
||||
trigger:
|
||||
type: cron
|
||||
cron_expression: "15 * * * *"
|
||||
timezone: Europe/Berlin
|
||||
misfire_policy: skip
|
||||
context_sources:
|
||||
- type: ops-inventory
|
||||
query: probe_services
|
||||
bind_to: context.ops_probe
|
||||
params:
|
||||
inventory_path: /tmp/service-inventory.yml
|
||||
evidence_sinks:
|
||||
- type: state-hub-progress
|
||||
event_type: ops_inventory_probe
|
||||
---
|
||||
|
||||
# Ops Service Inventory Probes
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.chdir(repo_root)
|
||||
monkeypatch.setenv("ACTIVITY_DEFINITION_DIRS", str(external_root))
|
||||
|
||||
definitions = scan_and_parse()
|
||||
|
||||
assert len(definitions) == 1
|
||||
definition = definitions[0]
|
||||
assert definition.name == "Ops Service Inventory Probes"
|
||||
assert definition.enabled is False
|
||||
assert definition.context_sources[0]["type"] == "ops-inventory"
|
||||
assert definition.context_sources[0]["params"]["evidence_sinks"][0]["type"] == (
|
||||
"state-hub-progress"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user