Initial curated ServiceMap source from 7a0a61232d05ba9097432abef6f7f4102a64d0b8
This commit is contained in:
commit
105251b9c8
96 files changed
+23587
No files matched your search
@@ -0,0 +1,182 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import main as main_module
|
||||
from app.discovery import DiscoveryManager
|
||||
from app.discovery_store import DiscoveryStore
|
||||
from app.models import DiscoveryConfidence, RelationEvidence, RelationType
|
||||
from app.store import Store
|
||||
|
||||
|
||||
def test_discovery_routes_reject_request_controlled_targets(tmp_path: Path, monkeypatch) -> None:
|
||||
main_module.store = Store(str(tmp_path / "servicemap.db"))
|
||||
main_module.discovery_store = DiscoveryStore(main_module.store)
|
||||
main_module.discovery_manager = DiscoveryManager(main_module.store, main_module.discovery_store)
|
||||
monkeypatch.setenv("SERVICEMAP_DOCKER_HOST", "unix:///var/run/docker.sock")
|
||||
monkeypatch.setenv("SERVICEMAP_APPOPS_URL", "http://appops.internal:1216")
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
assert client.post(
|
||||
"/api/v1/docker/discover", json={"docker_host": "tcp://attacker.internal:2375"}
|
||||
).status_code == 403
|
||||
assert client.post(
|
||||
"/api/v1/discovery/run", json={"docker_host": "tcp://attacker.internal:2375"}
|
||||
).status_code == 403
|
||||
assert client.post(
|
||||
"/api/v1/discovery/run", json={"appops_url": "http://attacker.internal"}
|
||||
).status_code == 403
|
||||
|
||||
|
||||
def test_service_api_crud_cycle_guard_and_export(tmp_path: Path) -> None:
|
||||
main_module.store = Store(str(tmp_path / "servicemap.db"))
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
database_response = client.post(
|
||||
"/api/v1/services",
|
||||
json={"name": "Database", "kind": "tcp", "host": "db.local", "port": 5432, "depends_on": []},
|
||||
)
|
||||
assert database_response.status_code == 201
|
||||
database = database_response.json()
|
||||
|
||||
api_response = client.post(
|
||||
"/api/v1/services",
|
||||
json={
|
||||
"name": "API",
|
||||
"kind": "http",
|
||||
"url": "https://api.local",
|
||||
"depends_on": [database["id"]],
|
||||
},
|
||||
)
|
||||
assert api_response.status_code == 201
|
||||
api_service = api_response.json()
|
||||
|
||||
cycle_response = client.put(
|
||||
f"/api/v1/services/{database['id']}",
|
||||
json={
|
||||
"name": "Database",
|
||||
"kind": "tcp",
|
||||
"host": "db.local",
|
||||
"port": 5432,
|
||||
"depends_on": [api_service["id"]],
|
||||
},
|
||||
)
|
||||
assert cycle_response.status_code == 422
|
||||
assert "cycle" in cycle_response.json()["detail"].lower()
|
||||
|
||||
listing = client.get("/api/v1/services")
|
||||
assert listing.status_code == 200
|
||||
assert len(listing.json()) == 2
|
||||
|
||||
exported = client.get("/api/v1/config?format=yaml")
|
||||
assert exported.status_code == 200
|
||||
assert "Database" in exported.text
|
||||
assert "API" in exported.text
|
||||
|
||||
deleted = client.delete(f"/api/v1/services/{database['id']}")
|
||||
assert deleted.status_code == 204
|
||||
api_snapshot = client.get(f"/api/v1/services/{api_service['id']}").json()
|
||||
assert api_snapshot["service"]["depends_on"] == []
|
||||
|
||||
|
||||
def test_overview_counts_only_visible_services(tmp_path: Path) -> None:
|
||||
main_module.store = Store(str(tmp_path / "servicemap.db"))
|
||||
client = TestClient(main_module.app)
|
||||
for name, visibility in (("Shown", "visible"), ("Retired", "hidden")):
|
||||
response = client.post(
|
||||
"/api/v1/services",
|
||||
json={"name": name, "kind": "tcp", "host": f"{name.lower()}.local", "port": 9000, "visibility": visibility},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert len(client.get("/api/v1/services").json()) == 1
|
||||
assert client.get("/api/v1/overview").json()["total_services"] == 1
|
||||
|
||||
|
||||
def test_config_import_endpoint_loads_validated_graph(tmp_path: Path) -> None:
|
||||
main_module.store = Store(str(tmp_path / "servicemap.db"))
|
||||
client = TestClient(main_module.app)
|
||||
content = """
|
||||
version: 1
|
||||
services:
|
||||
- name: Redis
|
||||
kind: tcp
|
||||
host: redis.local
|
||||
port: 6379
|
||||
depends_on: []
|
||||
- name: Worker
|
||||
kind: tcp
|
||||
host: worker.local
|
||||
port: 9000
|
||||
depends_on: [Redis]
|
||||
"""
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/config/import",
|
||||
json={"content": content, "format": "yaml", "mode": "replace"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"created": 2, "updated": 0, "total": 2}
|
||||
assert len(client.get("/api/v1/services").json()) == 2
|
||||
|
||||
|
||||
def test_service_api_rejects_invalid_endpoint_and_dependency_references(tmp_path: Path) -> None:
|
||||
main_module.store = Store(str(tmp_path / "servicemap.db"))
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
invalid_url = client.post(
|
||||
"/api/v1/services",
|
||||
json={"name": "Invalid", "kind": "http", "url": "ftp://example.test"},
|
||||
)
|
||||
assert invalid_url.status_code == 422
|
||||
|
||||
missing_dependency = client.post(
|
||||
"/api/v1/services",
|
||||
json={
|
||||
"name": "API",
|
||||
"kind": "http",
|
||||
"url": "https://api.local",
|
||||
"depends_on": ["missing-service-id"],
|
||||
},
|
||||
)
|
||||
assert missing_dependency.status_code == 422
|
||||
assert "unknown dependency" in missing_dependency.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_semantic_topology_and_logical_relations_are_backend_derived(tmp_path: Path) -> None:
|
||||
main_module.store = Store(str(tmp_path / "servicemap.db"))
|
||||
main_module.discovery_store = DiscoveryStore(main_module.store)
|
||||
main_module.discovery_manager = DiscoveryManager(main_module.store, main_module.discovery_store)
|
||||
client = TestClient(main_module.app)
|
||||
database = client.post("/api/v1/services", json={
|
||||
"name": "PostgreSQL", "kind": "tcp", "host": "db.local", "port": 5432,
|
||||
"application": "Zabbix", "domain": "Monitoring", "component_role": "database",
|
||||
}).json()
|
||||
api = client.post("/api/v1/services", json={
|
||||
"name": "Zabbix API", "kind": "http", "url": "https://zabbix.local",
|
||||
"application": "Zabbix", "domain": "Monitoring", "component_role": "api",
|
||||
"depends_on": [database["id"]],
|
||||
}).json()
|
||||
now = main_module.store.get_service(database["id"]).created_at
|
||||
main_module.discovery_store.save_evidence(RelationEvidence(
|
||||
id="compose-edge", source_service_id=api["id"], target_service_id=database["id"],
|
||||
relation_type=RelationType.DEPENDS_ON, evidence_type="compose",
|
||||
confidence=DiscoveryConfidence.HIGH, explanation="Compose dependency", first_seen_at=now, last_seen_at=now,
|
||||
))
|
||||
main_module.discovery_store.save_evidence(RelationEvidence(
|
||||
id="env-edge", source_service_id=api["id"], target_service_id=database["id"],
|
||||
relation_type=RelationType.DEPENDS_ON, evidence_type="environment",
|
||||
confidence=DiscoveryConfidence.MEDIUM, explanation="Environment dependency", first_seen_at=now, last_seen_at=now,
|
||||
))
|
||||
|
||||
semantic = client.get("/api/v1/topology/semantic")
|
||||
relations = client.get("/api/v1/topology/relations")
|
||||
services = client.get("/api/v1/services")
|
||||
|
||||
assert semantic.status_code == 200
|
||||
assert semantic.json()["domains"][0]["name"] == "Monitoring"
|
||||
assert semantic.json()["applications"][0]["component_count"] == 2
|
||||
assert relations.json()[0]["confidence"] == "confirmed"
|
||||
assert len(relations.json()[0]["evidence"]) == 2
|
||||
assert services.json()[0]["availability_24h"]["hours"] == 24
|
||||
@@ -0,0 +1,69 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth
|
||||
from app import main as main_module
|
||||
from app.store import Store
|
||||
|
||||
|
||||
def test_disabled_auth_preserves_v15_api_access(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("SERVICEMAP_AUTH_MODE", "disabled")
|
||||
main_module.store = Store(str(tmp_path / "servicemap.db"))
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/services",
|
||||
json={"name": "API", "kind": "http", "url": "https://api.local"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
def test_oidc_mode_fails_closed_but_health_stays_available(monkeypatch) -> None:
|
||||
monkeypatch.setenv("SERVICEMAP_AUTH_MODE", "oidc")
|
||||
monkeypatch.delenv("SERVICEMAP_OIDC_ISSUER", raising=False)
|
||||
monkeypatch.delenv("SERVICEMAP_OIDC_AUDIENCE", raising=False)
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
assert client.get("/health").status_code == 200
|
||||
assert client.get("/api/v1/services").status_code == 503
|
||||
|
||||
|
||||
def test_oidc_roles_enforce_read_operator_and_admin_boundaries(tmp_path: Path, monkeypatch) -> None:
|
||||
main_module.store = Store(str(tmp_path / "servicemap.db"))
|
||||
config = auth.AuthConfig(mode="oidc", issuer="https://id.example", audience="servicemap")
|
||||
monkeypatch.setattr(auth.AuthConfig, "from_environment", classmethod(lambda cls: config))
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
monkeypatch.setattr(auth, "verify_bearer", lambda token, _: {"viewer"})
|
||||
assert (
|
||||
client.get("/api/v1/services", headers={"Authorization": "Bearer viewer"}).status_code
|
||||
== 200
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/services",
|
||||
headers={"Authorization": "Bearer viewer"},
|
||||
json={"name": "Denied", "kind": "http", "url": "https://denied.local"},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
monkeypatch.setattr(auth, "verify_bearer", lambda token, _: {"operator", "viewer"})
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/services",
|
||||
headers={"Authorization": "Bearer operator"},
|
||||
json={"name": "Allowed", "kind": "http", "url": "https://allowed.local"},
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/config/import",
|
||||
headers={"Authorization": "Bearer operator"},
|
||||
json={"content": "version: 1\nservices: []", "format": "yaml", "mode": "merge"},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
@@ -0,0 +1,133 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config_io import export_document, import_document, parse_document, serialize_document
|
||||
from app.models import ServiceCreate, ServiceKind
|
||||
from app.store import Store
|
||||
|
||||
CONFIG = """
|
||||
version: 1
|
||||
services:
|
||||
- name: Database
|
||||
kind: tcp
|
||||
host: db.local
|
||||
port: 5432
|
||||
depends_on: []
|
||||
- name: API
|
||||
kind: http
|
||||
url: https://api.local
|
||||
depends_on: [Database]
|
||||
"""
|
||||
|
||||
|
||||
def test_yaml_import_export_round_trip(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
result = import_document(store, parse_document(CONFIG, "yaml"), "replace")
|
||||
|
||||
assert result.created == 2
|
||||
services = store.list_services()
|
||||
assert len(services) == 2
|
||||
api = store.get_service_by_name("API")
|
||||
database = store.get_service_by_name("Database")
|
||||
assert api is not None and database is not None
|
||||
assert api.depends_on == [database.id]
|
||||
|
||||
exported = export_document(store)
|
||||
yaml_text = serialize_document(exported, "yaml")
|
||||
reparsed = parse_document(yaml_text, "yaml")
|
||||
assert [service.name for service in reparsed.services] == [service.name for service in exported.services]
|
||||
|
||||
|
||||
def test_merge_can_reference_existing_service_not_in_document(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
database = store.create_service(
|
||||
ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db.local", port=5432)
|
||||
)
|
||||
document = parse_document(
|
||||
"""
|
||||
version: 1
|
||||
services:
|
||||
- name: API
|
||||
kind: http
|
||||
url: https://api.local
|
||||
depends_on: [Database]
|
||||
""",
|
||||
"yaml",
|
||||
)
|
||||
|
||||
result = import_document(store, document, "merge")
|
||||
|
||||
assert result.created == 1
|
||||
assert result.updated == 0
|
||||
api = store.get_service_by_name("API")
|
||||
assert api is not None
|
||||
assert api.depends_on == [database.id]
|
||||
|
||||
|
||||
def test_import_rejects_missing_dependency_and_cycles(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
missing = parse_document(
|
||||
"""
|
||||
version: 1
|
||||
services:
|
||||
- name: API
|
||||
kind: http
|
||||
url: https://api.local
|
||||
depends_on: [Database]
|
||||
""",
|
||||
"yaml",
|
||||
)
|
||||
with pytest.raises(ValueError, match="unknown dependencies"):
|
||||
import_document(store, missing, "replace")
|
||||
|
||||
cyclic = """
|
||||
version: 1
|
||||
services:
|
||||
- name: A
|
||||
kind: http
|
||||
url: https://a.local
|
||||
depends_on: [B]
|
||||
- name: B
|
||||
kind: http
|
||||
url: https://b.local
|
||||
depends_on: [A]
|
||||
"""
|
||||
with pytest.raises(ValueError, match="cycle"):
|
||||
parse_document(cyclic, "yaml")
|
||||
|
||||
with pytest.raises(ValueError, match="less than or equal to 1"):
|
||||
parse_document("version: 2\nservices: []", "yaml")
|
||||
|
||||
|
||||
def test_replace_import_rolls_back_on_mid_transaction_failure(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
original = store.create_service(
|
||||
ServiceCreate(name="Original", kind=ServiceKind.HTTP, url="https://original.local")
|
||||
)
|
||||
document = parse_document(
|
||||
"""
|
||||
version: 1
|
||||
services:
|
||||
- name: First
|
||||
kind: http
|
||||
url: https://first.local
|
||||
depends_on: []
|
||||
- name: Second
|
||||
kind: http
|
||||
url: https://second.local
|
||||
depends_on: []
|
||||
""",
|
||||
"yaml",
|
||||
)
|
||||
monkeypatch.setattr("app.store.uuid4", lambda: "duplicate-id")
|
||||
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
import_document(store, document, "replace")
|
||||
|
||||
remaining = store.list_services()
|
||||
assert [(service.id, service.name) for service in remaining] == [(original.id, "Original")]
|
||||
@@ -0,0 +1,186 @@
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import main as main_module
|
||||
from app.demo import DEMO_ROOT_CAUSE_NAME, build_demo_records
|
||||
from app.models import HealthStatus, ProbeResult, ServiceCreate, ServiceKind
|
||||
from app.store import Store
|
||||
|
||||
|
||||
def _client(tmp_path: Path) -> tuple[Store, TestClient]:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
main_module.store = store
|
||||
return store, TestClient(main_module.app)
|
||||
|
||||
|
||||
def _assert_acyclic(graph: dict[str, set[str]]) -> None:
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
|
||||
def visit(service_id: str) -> None:
|
||||
assert service_id not in visiting
|
||||
if service_id in visited:
|
||||
return
|
||||
visiting.add(service_id)
|
||||
for dependency_id in graph[service_id]:
|
||||
visit(dependency_id)
|
||||
visiting.remove(service_id)
|
||||
visited.add(service_id)
|
||||
|
||||
for service_id in graph:
|
||||
visit(service_id)
|
||||
|
||||
|
||||
def test_demo_seeds_topology_health_history_availability_and_incident(tmp_path: Path) -> None:
|
||||
store, client = _client(tmp_path)
|
||||
|
||||
response = client.post("/api/v1/demo")
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["active"] is True
|
||||
assert result["synthetic"] is True
|
||||
assert result["scenario_name"] == "Northstar Platform"
|
||||
assert result["total"] == 9
|
||||
|
||||
snapshots = client.get("/api/v1/services").json()
|
||||
assert len(snapshots) == 9
|
||||
by_name = {item["service"]["name"]: item for item in snapshots}
|
||||
assert set(by_name) == {
|
||||
"Public Endpoint",
|
||||
"Reverse Proxy",
|
||||
"Web Frontend",
|
||||
"Auth Service",
|
||||
"API Gateway",
|
||||
"PostgreSQL",
|
||||
"Redis",
|
||||
"Background Worker",
|
||||
"Object Storage",
|
||||
}
|
||||
ids = {item["service"]["id"] for item in snapshots}
|
||||
assert all(set(item["service"]["depends_on"]).issubset(ids) for item in snapshots)
|
||||
assert by_name[DEMO_ROOT_CAUSE_NAME]["latest_probe"]["status"] == HealthStatus.DOWN
|
||||
assert by_name[DEMO_ROOT_CAUSE_NAME]["effective_status"] == HealthStatus.DOWN
|
||||
assert all(item["effective_status"] != HealthStatus.UNKNOWN for item in snapshots)
|
||||
|
||||
semantic = client.get("/api/v1/topology/semantic").json()
|
||||
assert {item["name"] for item in semantic["domains"]} == {"Edge", "Application", "Data"}
|
||||
assert {item["name"] for item in semantic["applications"]} == {
|
||||
"Public Gateway",
|
||||
"Northstar App",
|
||||
"Data Platform",
|
||||
"Worker Platform",
|
||||
}
|
||||
|
||||
incidents = client.get("/api/v1/incidents").json()
|
||||
assert incidents["active"] is True
|
||||
assert incidents["candidates"][0]["service_name"] == DEMO_ROOT_CAUSE_NAME
|
||||
assert set(incidents["candidates"][0]["affected_service_names"]) == {
|
||||
"API Gateway",
|
||||
"Web Frontend",
|
||||
"Reverse Proxy",
|
||||
"Public Endpoint",
|
||||
"Background Worker",
|
||||
}
|
||||
|
||||
postgres_id = by_name[DEMO_ROOT_CAUSE_NAME]["service"]["id"]
|
||||
history = client.get(f"/api/v1/services/{postgres_id}/history?limit=50").json()
|
||||
assert len(history) == 24
|
||||
availability = client.get(f"/api/v1/services/{postgres_id}/availability").json()
|
||||
assert availability["total_checks"] == 24
|
||||
assert availability["uptime_percent"] < 100
|
||||
assert availability["down_checks"] == 2
|
||||
assert store.demo_active() is True
|
||||
|
||||
|
||||
def test_demo_reset_is_deterministic_and_exit_restores_topology_and_probes(tmp_path: Path) -> None:
|
||||
store, client = _client(tmp_path)
|
||||
original = store.create_service(
|
||||
ServiceCreate(name="Existing API", kind=ServiceKind.HTTP, url="https://existing.test")
|
||||
)
|
||||
original_probe = ProbeResult(
|
||||
id="existing-probe",
|
||||
service_id=original.id,
|
||||
status=HealthStatus.HEALTHY,
|
||||
latency_ms=12.5,
|
||||
detail="existing data",
|
||||
checked_at=datetime.now(UTC),
|
||||
)
|
||||
store.save_probe(original_probe)
|
||||
original_service_json = client.get(f"/api/v1/services/{original.id}").json()
|
||||
|
||||
first = client.post("/api/v1/demo").json()
|
||||
first_services = client.get("/api/v1/services").json()
|
||||
first_statuses = {
|
||||
item["service"]["name"]: (item["latest_probe"]["status"], item["effective_status"])
|
||||
for item in first_services
|
||||
}
|
||||
assert first["restored"] is False
|
||||
assert client.get(f"/api/v1/services/{original.id}").status_code == 404
|
||||
|
||||
reset = client.post("/api/v1/demo/reset").json()
|
||||
reset_services = client.get("/api/v1/services").json()
|
||||
reset_statuses = {
|
||||
item["service"]["name"]: (item["latest_probe"]["status"], item["effective_status"])
|
||||
for item in reset_services
|
||||
}
|
||||
assert reset["active"] is True
|
||||
assert reset["root_cause_service_name"] == DEMO_ROOT_CAUSE_NAME
|
||||
assert reset_statuses == first_statuses
|
||||
assert len(reset_services) == 9
|
||||
|
||||
exited = client.post("/api/v1/demo/exit").json()
|
||||
assert exited["active"] is False
|
||||
assert exited["restored"] is True
|
||||
assert client.get("/api/v1/demo").json() == {
|
||||
"active": False,
|
||||
"synthetic": False,
|
||||
"scenario_name": None,
|
||||
"service_count": 0,
|
||||
"root_cause_service_id": None,
|
||||
"root_cause_service_name": None,
|
||||
"affected_service_ids": [],
|
||||
"affected_service_names": [],
|
||||
"affected_count": 0,
|
||||
}
|
||||
assert client.get(f"/api/v1/services/{original.id}").json() == original_service_json
|
||||
assert store.probe_history(original.id)[0].id == original_probe.id
|
||||
|
||||
with store._connect() as db: # regression assertion for FK-clean restoration
|
||||
orphan_count = db.execute(
|
||||
"SELECT COUNT(*) FROM probe_results p LEFT JOIN services s ON s.id = p.service_id WHERE s.id IS NULL"
|
||||
).fetchone()[0]
|
||||
assert orphan_count == 0
|
||||
orphan_evidence_count = db.execute(
|
||||
"SELECT COUNT(*) FROM health_evidence e LEFT JOIN services s ON s.id = e.service_id "
|
||||
"WHERE e.service_id IS NOT NULL AND s.id IS NULL"
|
||||
).fetchone()[0]
|
||||
orphan_link_count = db.execute(
|
||||
"SELECT COUNT(*) FROM health_evidence_service_links link LEFT JOIN health_evidence e "
|
||||
"ON e.id = link.evidence_id WHERE e.id IS NULL"
|
||||
).fetchone()[0]
|
||||
assert orphan_evidence_count == 0
|
||||
assert orphan_link_count == 0
|
||||
|
||||
|
||||
def test_demo_probe_endpoints_remain_external_service_free(tmp_path: Path) -> None:
|
||||
_, client = _client(tmp_path)
|
||||
client.post("/api/v1/demo")
|
||||
snapshots = client.get("/api/v1/services").json()
|
||||
postgres_id = next(item["service"]["id"] for item in snapshots if item["service"]["name"] == DEMO_ROOT_CAUSE_NAME)
|
||||
|
||||
assert client.post(f"/api/v1/services/{postgres_id}/probe").status_code == 409
|
||||
assert client.post("/api/v1/probe-all").json() == {"requested": 0, "completed": 0, "results": []}
|
||||
|
||||
|
||||
def test_demo_records_have_valid_graph_and_stable_ids() -> None:
|
||||
fixed_now = datetime(2026, 9, 12, 10, 0, tzinfo=UTC)
|
||||
services_a, probes_a = build_demo_records(fixed_now)
|
||||
services_b, probes_b = build_demo_records(fixed_now)
|
||||
|
||||
assert [service.model_dump(mode="json") for service in services_a] == [service.model_dump(mode="json") for service in services_b]
|
||||
assert [probe.model_dump(mode="json") for probe in probes_a] == [probe.model_dump(mode="json") for probe in probes_b]
|
||||
assert len(probes_a) == 9 * 24
|
||||
graph = {service.id: set(service.depends_on) for service in services_a}
|
||||
_assert_acyclic(graph)
|
||||
@@ -0,0 +1,345 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
|
||||
from app import docker_discovery
|
||||
from app.demo import build_demo_records
|
||||
from app.discovery import DiscoveryManager
|
||||
from app.discovery_store import DiscoveryStore
|
||||
from app.models import (
|
||||
Criticality,
|
||||
DiscoveryConfidence,
|
||||
DiscoveryLifecycle,
|
||||
DiscoveryReconcileRequest,
|
||||
DiscoveryRunRequest,
|
||||
DiscoveryRunStatus,
|
||||
DockerCandidate,
|
||||
DockerPort,
|
||||
DockerSuggestion,
|
||||
ProbeEndpointCandidate,
|
||||
ProbeEndpointSource,
|
||||
ProbeReachability,
|
||||
RelationEvidence,
|
||||
RelationType,
|
||||
ServiceCreate,
|
||||
ServiceKind,
|
||||
ServiceUpdate,
|
||||
SuggestionStatus,
|
||||
TopologySuggestion,
|
||||
)
|
||||
from app.store import Store
|
||||
|
||||
|
||||
class _Image:
|
||||
def __init__(self, tag: str) -> None:
|
||||
self.tags = [tag]
|
||||
|
||||
|
||||
class _Container:
|
||||
def __init__(self, name: str, container_id: str, image: str, service: str, *, env: list[str] | None = None) -> None:
|
||||
self.name = name
|
||||
self.id = container_id
|
||||
self.status = "running"
|
||||
self.image = _Image(image)
|
||||
self.attrs = {
|
||||
"State": {"Status": "running", "Health": {"Status": "healthy"}},
|
||||
"Config": {
|
||||
"Image": image,
|
||||
"Env": env or [],
|
||||
"Healthcheck": {"Test": ["CMD", "true"]},
|
||||
"Labels": {
|
||||
"com.docker.compose.project": "northstar",
|
||||
"com.docker.compose.service": service,
|
||||
"com.docker.compose.depends_on": "postgres:service_healthy:false" if service == "api" else "",
|
||||
},
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Networks": {"northstar_default": {}},
|
||||
"Ports": {"8000/tcp": [{"HostIp": "127.0.0.1", "HostPort": "18080"}]} if service == "api" else {"5432/tcp": [{"HostIp": "127.0.0.1", "HostPort": "15432"}]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _DockerClient:
|
||||
containers: ClassVar[_DockerClient]
|
||||
current: ClassVar[list[_Container]] = []
|
||||
|
||||
def __init__(self, **_: object) -> None:
|
||||
self.containers = self
|
||||
|
||||
def ping(self) -> bool:
|
||||
return True
|
||||
|
||||
def list(self, all: bool) -> list[_Container]:
|
||||
assert all is False
|
||||
return self.current
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _manager(tmp_path: Path) -> DiscoveryManager:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
return DiscoveryManager(store, DiscoveryStore(store))
|
||||
|
||||
|
||||
def test_malformed_multi_host_environment_does_not_abort_discovery() -> None:
|
||||
evidence = docker_discovery.safe_environment_evidence(
|
||||
["UPSTREAM_HOSTS=chimera:6967,localhost:1221,127.0.0.1:1221"]
|
||||
)
|
||||
|
||||
assert evidence == ["UPSTREAM_HOSTS hostname=chimera"]
|
||||
assert docker_discovery.safe_environment_evidence(["BIND_ADDRESS=http://[invalid:8000"]) == []
|
||||
|
||||
|
||||
def test_internal_http_candidate_is_imported_without_an_unsafe_probe_target(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
candidate = DockerCandidate(
|
||||
container_id="a" * 64,
|
||||
name="internal-web",
|
||||
image="example/web:1",
|
||||
state="running",
|
||||
networks=[],
|
||||
ports=[DockerPort(container_port="80/tcp")],
|
||||
stable_source_ref="docker:name:internal-web",
|
||||
suggestions=[
|
||||
DockerSuggestion(
|
||||
name="internal-web",
|
||||
kind=ServiceKind.HTTP,
|
||||
host="internal-web",
|
||||
port=80,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
payload = manager._payload_for_candidate(candidate)
|
||||
|
||||
assert payload.kind == ServiceKind.HTTP
|
||||
assert payload.monitoring_enabled is False
|
||||
assert payload.url is None
|
||||
assert payload.host is None
|
||||
assert payload.port is None
|
||||
|
||||
published = candidate.model_copy(
|
||||
update={
|
||||
"name": "published-web",
|
||||
"suggestions": [
|
||||
DockerSuggestion(
|
||||
name="published-web",
|
||||
kind=ServiceKind.HTTP,
|
||||
url="http://127.0.0.1:9494",
|
||||
port=9494,
|
||||
)
|
||||
],
|
||||
"probe_candidates": [
|
||||
ProbeEndpointCandidate(
|
||||
source=ProbeEndpointSource.PUBLISHED_PORT,
|
||||
kind=ServiceKind.HTTP,
|
||||
url="http://127.0.0.1:9494",
|
||||
reachability=ProbeReachability.VERIFIED,
|
||||
primary=True,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
published_payload = manager._payload_for_candidate(published)
|
||||
|
||||
assert published_payload.url == "http://127.0.0.1:9494"
|
||||
assert published_payload.host is None
|
||||
assert published_payload.port is None
|
||||
|
||||
|
||||
def test_new_discovery_run_closes_stale_active_run(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
first = manager.discovery_store.start_run()
|
||||
second = manager.discovery_store.start_run()
|
||||
|
||||
assert manager.discovery_store.get_run(first.id).status == DiscoveryRunStatus.FAILED
|
||||
assert manager.discovery_store.get_run(second.id).status == DiscoveryRunStatus.DISCOVERING
|
||||
|
||||
|
||||
def test_repeated_suggestion_moves_to_latest_discovery_run(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
first = manager.discovery_store.start_run()
|
||||
now = datetime.now(UTC)
|
||||
suggestion = TopologySuggestion(
|
||||
id="suggestion-first",
|
||||
run_id=first.id,
|
||||
suggestion_type="service",
|
||||
proposed_name="example",
|
||||
confidence=DiscoveryConfidence.HIGH,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
saved_first = manager.discovery_store.save_suggestion(suggestion, "same-fingerprint")
|
||||
second = manager.discovery_store.start_run()
|
||||
saved_second = manager.discovery_store.save_suggestion(
|
||||
suggestion.model_copy(
|
||||
update={
|
||||
"id": "suggestion-second",
|
||||
"run_id": second.id,
|
||||
"status": SuggestionStatus.OPEN,
|
||||
"updated_at": datetime.now(UTC),
|
||||
}
|
||||
),
|
||||
"same-fingerprint",
|
||||
)
|
||||
|
||||
assert saved_first.id == saved_second.id == "suggestion-first"
|
||||
assert saved_second.run_id == second.id
|
||||
|
||||
|
||||
def test_discovery_sanitizes_environment_and_reconciles_high_confidence_edges(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_DockerClient.current = [
|
||||
_Container(
|
||||
"api",
|
||||
"a" * 64,
|
||||
"northstar/api:1",
|
||||
"api",
|
||||
env=["DATABASE_URL=postgres://user:supersecret@postgres:5432/app", "API_TOKEN=never-store"],
|
||||
),
|
||||
_Container("postgres", "b" * 64, "postgres:17", "postgres"),
|
||||
]
|
||||
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs))
|
||||
manager = _manager(tmp_path)
|
||||
|
||||
result = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
|
||||
api = next(item for item in result.candidates if item.name == "api")
|
||||
assert api.stable_source_ref == "docker:compose:northstar:api"
|
||||
assert any("hostname=postgres:5432" in item for item in api.environment_evidence)
|
||||
assert all("supersecret" not in item and "user" not in item for item in api.environment_evidence)
|
||||
assert all("API_TOKEN" not in item for item in api.environment_evidence)
|
||||
assert any(item.confidence == DiscoveryConfidence.HIGH for item in result.suggestions if item.suggestion_type == "relation")
|
||||
|
||||
reconciled = manager.reconcile(DiscoveryReconcileRequest(run_id=result.run.id, apply_high_confidence=True))
|
||||
assert reconciled.created == 2
|
||||
assert reconciled.accepted_relations >= 1
|
||||
services = manager.store.list_services()
|
||||
api_service = next(item for item in services if item.source_ref == "docker:compose:northstar:api")
|
||||
postgres_service = next(item for item in services if item.source_ref == "docker:compose:northstar:postgres")
|
||||
assert postgres_service.id in api_service.depends_on
|
||||
|
||||
edge = next(item for item in manager.discovery_store.list_evidence() if item.source_service_id == api_service.id and item.target_service_id == postgres_service.id)
|
||||
manager.edge_action(edge.id, "reject")
|
||||
repeated = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
|
||||
manager.reconcile(DiscoveryReconcileRequest(run_id=repeated.run.id, apply_high_confidence=True))
|
||||
api_after_rejection = next(item for item in manager.store.list_services() if item.id == api_service.id)
|
||||
assert postgres_service.id not in api_after_rejection.depends_on
|
||||
assert manager.discovery_store.get_evidence(edge.id) is not None
|
||||
assert not manager.discovery_store.get_evidence(edge.id).active
|
||||
|
||||
serialized = (tmp_path / "servicemap.db").read_bytes()
|
||||
assert b"supersecret" not in serialized
|
||||
assert b"API_TOKEN" not in serialized
|
||||
|
||||
|
||||
def test_stable_identity_survives_container_recreation_without_duplicate_service(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_DockerClient.current = [_Container("api", "a" * 64, "northstar/api:1", "api")]
|
||||
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs))
|
||||
manager = _manager(tmp_path)
|
||||
first = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
|
||||
manager.reconcile(DiscoveryReconcileRequest(run_id=first.run.id, apply_high_confidence=True))
|
||||
|
||||
_DockerClient.current = [_Container("api", "c" * 64, "northstar/api:2", "api")]
|
||||
second = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
|
||||
assert second.run.diff.changed == 1
|
||||
manager.reconcile(DiscoveryReconcileRequest(run_id=second.run.id, apply_high_confidence=True))
|
||||
services = [item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api"]
|
||||
assert len(services) == 1
|
||||
assert services[0].container_image == "northstar/api:2"
|
||||
|
||||
|
||||
def test_missing_container_is_retained_and_becomes_stale(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_DockerClient.current = [_Container("api", "a" * 64, "northstar/api:1", "api")]
|
||||
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs))
|
||||
manager = _manager(tmp_path)
|
||||
first = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
|
||||
manager.reconcile(DiscoveryReconcileRequest(run_id=first.run.id, apply_high_confidence=True))
|
||||
|
||||
_DockerClient.current = []
|
||||
missing = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
|
||||
assert missing.run.diff.missing == 1
|
||||
reconciled = manager.reconcile(DiscoveryReconcileRequest(run_id=missing.run.id))
|
||||
assert reconciled.missing == 1
|
||||
service = next(item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api")
|
||||
assert service.lifecycle == DiscoveryLifecycle.MISSING
|
||||
assert service.id in {item.id for item in manager.store.list_services()}
|
||||
|
||||
|
||||
def test_missing_identity_progresses_to_stale_then_retired_without_delete(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_DockerClient.current = [_Container("api", "a" * 64, "northstar/api:1", "api")]
|
||||
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs))
|
||||
manager = _manager(tmp_path)
|
||||
first = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
|
||||
manager.reconcile(DiscoveryReconcileRequest(run_id=first.run.id, apply_high_confidence=True))
|
||||
_DockerClient.current = []
|
||||
lifecycle: list[DiscoveryLifecycle] = []
|
||||
for _ in range(3):
|
||||
run = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
|
||||
manager.reconcile(DiscoveryReconcileRequest(run_id=run.run.id))
|
||||
lifecycle.append(next(item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api").lifecycle)
|
||||
assert lifecycle == [DiscoveryLifecycle.MISSING, DiscoveryLifecycle.STALE, DiscoveryLifecycle.RETIRED]
|
||||
assert len(manager.store.list_services()) == 1
|
||||
|
||||
|
||||
def test_low_confidence_network_hint_is_evidence_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_DockerClient.current = [
|
||||
_Container("frontend", "a" * 64, "northstar/frontend:1", "frontend"),
|
||||
_Container("backend", "b" * 64, "northstar/backend:1", "backend"),
|
||||
]
|
||||
for item in _DockerClient.current:
|
||||
item.attrs["Config"]["Labels"]["com.docker.compose.depends_on"] = ""
|
||||
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs))
|
||||
manager = _manager(tmp_path)
|
||||
run = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
|
||||
manager.reconcile(DiscoveryReconcileRequest(run_id=run.run.id, apply_high_confidence=True))
|
||||
services = manager.store.list_services()
|
||||
assert all(not service.depends_on for service in services)
|
||||
assert any(item.confidence == DiscoveryConfidence.LOW for item in run.suggestions if item.suggestion_type == "relation")
|
||||
|
||||
|
||||
def test_manual_group_override_survives_rediscovery(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_DockerClient.current = [_Container("api", "a" * 64, "northstar/api:1", "api")]
|
||||
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs))
|
||||
manager = _manager(tmp_path)
|
||||
first = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
|
||||
manager.reconcile(DiscoveryReconcileRequest(run_id=first.run.id, apply_high_confidence=True))
|
||||
before = next(item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api")
|
||||
payload = ServiceUpdate.model_validate(before.model_dump())
|
||||
payload.group = "Manual ownership"
|
||||
after = manager.store.update_service(before.id, payload)
|
||||
assert after is not None
|
||||
manager.record_service_override(before, after)
|
||||
_DockerClient.current = [_Container("api", "c" * 64, "northstar/api:2", "api")]
|
||||
second = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
|
||||
manager.reconcile(DiscoveryReconcileRequest(run_id=second.run.id, apply_high_confidence=True))
|
||||
updated = next(item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api")
|
||||
assert updated.group == "Manual ownership"
|
||||
assert updated.container_image == "northstar/api:2"
|
||||
|
||||
|
||||
def test_demo_exit_restores_discovery_evidence(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
discovery = DiscoveryStore(store)
|
||||
database = store.create_service(ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db", port=5432, category="database", criticality=Criticality.HIGH))
|
||||
application = store.create_service(ServiceCreate(name="Application", kind=ServiceKind.HTTP, url="https://app.local", depends_on=[database.id]))
|
||||
source = discovery.save_source(source_type="docker", source_id="docker:compose:stack:app", source_name="app", lifecycle=DiscoveryLifecycle.ACTIVE, metadata={"image": "app:1"})
|
||||
discovery.attach_source(application.id, source.id)
|
||||
now = datetime.now(UTC)
|
||||
discovery.save_evidence(RelationEvidence(id="evidence-1", source_service_id=application.id, target_service_id=database.id, relation_type=RelationType.DEPENDS_ON, evidence_type="manual", confidence=DiscoveryConfidence.CONFIRMED, explanation="test evidence", first_seen_at=now, last_seen_at=now))
|
||||
demo_services, demo_probes = build_demo_records()
|
||||
store.activate_demo(demo_services, demo_probes)
|
||||
assert store.demo_active()
|
||||
assert {item.name for item in store.list_services()} == {item.name for item in demo_services}
|
||||
assert store.exit_demo()
|
||||
restored = store.list_services()
|
||||
assert {item.name for item in restored} == {"Database", "Application"}
|
||||
assert discovery.get_evidence("evidence-1") is not None
|
||||
@@ -0,0 +1,135 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
|
||||
from app import docker_discovery
|
||||
from app.models import Service, ServiceKind
|
||||
|
||||
|
||||
class _FakeImage:
|
||||
tags: ClassVar[list[str]] = ["example/service:1.0"]
|
||||
|
||||
|
||||
class _FakeContainer:
|
||||
id = "abcdef1234567890"
|
||||
name = "example-service"
|
||||
status = "running"
|
||||
image = _FakeImage()
|
||||
attrs: ClassVar[dict[str, object]] = {
|
||||
"State": {"Status": "running", "Health": {"Status": "healthy"}},
|
||||
"Config": {"Image": "example/service:1.0"},
|
||||
"NetworkSettings": {
|
||||
"Networks": {"default": {}, "monitoring": {}},
|
||||
"Ports": {
|
||||
"8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": 1253}],
|
||||
"5432/tcp": None,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_: object) -> None:
|
||||
self.containers = self
|
||||
self.closed = False
|
||||
|
||||
def ping(self) -> bool:
|
||||
return True
|
||||
|
||||
def list(self, all: bool) -> list[_FakeContainer]:
|
||||
assert all is True
|
||||
return [_FakeContainer()]
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_discovery_normalizes_ports_networks_and_health(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _FakeClient()
|
||||
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: client)
|
||||
|
||||
result = docker_discovery.discover_containers("tcp://docker.local:2375")
|
||||
|
||||
assert result.docker_host == "tcp://docker.local:2375"
|
||||
assert result.candidates[0].name == "example-service"
|
||||
assert result.candidates[0].health == "healthy"
|
||||
assert result.candidates[0].networks == ["default", "monitoring"]
|
||||
assert {(port.container_port, port.host_port) for port in result.candidates[0].ports} == {
|
||||
("8000/tcp", 1253),
|
||||
("5432/tcp", None),
|
||||
}
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_discovery_filters_sensitive_label_keys_but_keeps_discovery_metadata(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
token = "do-not-persist-this-token"
|
||||
labels = {
|
||||
"servicemap.api-token": token,
|
||||
"servicemap.password": "do-not-persist-this-password",
|
||||
"servicemap.name": "Example API",
|
||||
"servicemap.probe": "http",
|
||||
"servicemap.port": "8000",
|
||||
"com.docker.compose.project": "app-stack",
|
||||
"com.docker.compose.service": "web",
|
||||
"com.docker.compose.depends_on": "db",
|
||||
"com.docker.compose.postgres-password": "do-not-persist-this-compose-password",
|
||||
"org.opencontainers.image.title": "Safe image title",
|
||||
"other.secret": "not forwarded",
|
||||
}
|
||||
attrs = {
|
||||
**_FakeContainer.attrs,
|
||||
"Config": {**_FakeContainer.attrs["Config"], "Labels": labels},
|
||||
}
|
||||
monkeypatch.setattr(_FakeContainer, "attrs", attrs)
|
||||
client = _FakeClient()
|
||||
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: client)
|
||||
|
||||
result = docker_discovery.discover_containers("tcp://docker.local:2375")
|
||||
|
||||
candidate = result.candidates[0]
|
||||
serialized = str(candidate.model_dump(mode="json"))
|
||||
assert token not in serialized
|
||||
assert "do-not-persist-this-password" not in serialized
|
||||
assert "do-not-persist-this-compose-password" not in serialized
|
||||
assert "other.secret" not in candidate.labels
|
||||
assert candidate.labels == {
|
||||
"servicemap.name": "Example API",
|
||||
"servicemap.probe": "http",
|
||||
"servicemap.port": "8000",
|
||||
"com.docker.compose.project": "app-stack",
|
||||
"com.docker.compose.service": "web",
|
||||
"com.docker.compose.depends_on": "db",
|
||||
"org.opencontainers.image.title": "Safe image title",
|
||||
}
|
||||
assert candidate.stable_source_ref == "docker:compose:app-stack:web"
|
||||
assert candidate.suggestions[0].kind == ServiceKind.HTTP
|
||||
|
||||
|
||||
def test_discovery_rejects_untrusted_scheme() -> None:
|
||||
with pytest.raises(ValueError, match="must use"):
|
||||
docker_discovery.discover_containers("file:///var/run/docker.sock")
|
||||
|
||||
|
||||
def test_discovery_marks_imported_and_carries_review_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _FakeClient()
|
||||
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: client)
|
||||
existing = Service(
|
||||
id="svc-1",
|
||||
name="example-service",
|
||||
kind=ServiceKind.HTTP,
|
||||
url="http://example-service:8000",
|
||||
source_type="docker",
|
||||
source_ref="docker:abcdef123456@example/service:1.0",
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
result = docker_discovery.discover_containers("tcp://docker.local:2375", [existing])
|
||||
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.import_state == "already imported"
|
||||
assert candidate.suggestions[0].source_type == "docker"
|
||||
assert candidate.suggestions[0].source_ref == "docker:abcdef123456@example/service:1.0"
|
||||
@@ -0,0 +1,244 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app import docker_provider
|
||||
|
||||
|
||||
def test_broker_mode_never_falls_back_to_a_socket_or_request_provided_host(monkeypatch) -> None:
|
||||
monkeypatch.setenv("SERVICEMAP_DOCKER_PROVIDER", "broker")
|
||||
monkeypatch.delenv("SERVICEMAP_DOCKER_BROKER_URL", raising=False)
|
||||
monkeypatch.delenv("SERVICEMAP_DOCKER_BROKER_TOKEN_FILE", raising=False)
|
||||
socket_used = False
|
||||
|
||||
def socket_client(*args, **kwargs):
|
||||
nonlocal socket_used
|
||||
socket_used = True
|
||||
raise AssertionError("broker mode must not create a socket client")
|
||||
|
||||
monkeypatch.setattr(docker_provider.docker, "DockerClient", socket_client)
|
||||
|
||||
with pytest.raises(docker_provider.DockerProviderError, match="Broker mode requires"):
|
||||
docker_provider.docker_client_for_inventory("https://attacker.invalid")
|
||||
|
||||
assert socket_used is False
|
||||
|
||||
|
||||
def test_broker_destination_is_deployment_configuration_only(tmp_path, monkeypatch) -> None:
|
||||
token_file = tmp_path / "broker-token"
|
||||
token_file.write_text("test-token", encoding="utf-8")
|
||||
monkeypatch.setenv("SERVICEMAP_DOCKER_PROVIDER", "broker")
|
||||
monkeypatch.setenv("SERVICEMAP_DOCKER_BROKER_URL", "https://broker.internal")
|
||||
monkeypatch.setenv("SERVICEMAP_DOCKER_BROKER_TOKEN_FILE", str(token_file))
|
||||
|
||||
client = docker_provider.docker_client_for_inventory("https://attacker.invalid")
|
||||
|
||||
assert isinstance(client, docker_provider.BrokerDockerClient)
|
||||
assert client.base_url == "https://broker.internal"
|
||||
client.close()
|
||||
|
||||
|
||||
def _mocked_broker_client(
|
||||
monkeypatch, handler, *, base_url="https://broker.internal", token="opaque-token"
|
||||
):
|
||||
original_client = httpx.Client
|
||||
transport = httpx.MockTransport(handler)
|
||||
options = {}
|
||||
|
||||
def client_factory(**kwargs):
|
||||
options.update(kwargs)
|
||||
return original_client(transport=transport, **kwargs)
|
||||
|
||||
monkeypatch.setattr(docker_provider.httpx, "Client", client_factory)
|
||||
client = docker_provider.BrokerDockerClient(base_url, token)
|
||||
return client, options
|
||||
|
||||
|
||||
def test_inventory_uses_authenticated_fixed_get_and_health_check_has_no_token(monkeypatch) -> None:
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/healthz":
|
||||
return httpx.Response(200)
|
||||
return httpx.Response(200, json={"items": []})
|
||||
|
||||
client, options = _mocked_broker_client(monkeypatch, handler)
|
||||
try:
|
||||
assert client.ping() is True
|
||||
assert client.containers.list() == []
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/healthz"),
|
||||
("GET", "/v1/containers"),
|
||||
]
|
||||
assert "authorization" not in requests[0].headers
|
||||
assert requests[1].headers["authorization"] == "Bearer opaque-token"
|
||||
assert options["follow_redirects"] is False
|
||||
assert options["trust_env"] is False
|
||||
assert 0 < options["timeout"].connect <= 5
|
||||
assert 0 < options["timeout"].read <= 5
|
||||
|
||||
|
||||
def test_broker_rejects_other_authenticated_paths_without_requesting_them(monkeypatch) -> None:
|
||||
requests = []
|
||||
client, _ = _mocked_broker_client(
|
||||
monkeypatch,
|
||||
lambda request: requests.append(request) or httpx.Response(200, json={"items": []}),
|
||||
)
|
||||
try:
|
||||
with pytest.raises(docker_provider.DockerProviderError, match="unsupported"):
|
||||
client._get("/v1/containers/other")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert requests == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_url",
|
||||
[
|
||||
"ftp://broker.internal",
|
||||
"https://user:password@broker.internal",
|
||||
"https://broker.internal?token=secret",
|
||||
"https://broker.internal/#fragment",
|
||||
"https:///missing-host",
|
||||
],
|
||||
)
|
||||
def test_broker_rejects_unsafe_destinations(base_url) -> None:
|
||||
with pytest.raises(docker_provider.DockerProviderError):
|
||||
docker_provider.BrokerDockerClient(base_url, "opaque-token")
|
||||
|
||||
|
||||
def test_broker_rejects_header_injection_in_token() -> None:
|
||||
with pytest.raises(docker_provider.DockerProviderError):
|
||||
docker_provider.BrokerDockerClient("https://broker.internal", "token\r\nX-Leak: yes")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{},
|
||||
{"items": "not-a-list"},
|
||||
{"items": [{"containerRef": "one"}, "malformed-row"]},
|
||||
{"items": [{"containerRef": "one", "name": "one", "state": "running"}]},
|
||||
],
|
||||
)
|
||||
def test_broker_rejects_partial_or_malformed_inventory(payload, monkeypatch) -> None:
|
||||
client, _ = _mocked_broker_client(
|
||||
monkeypatch,
|
||||
lambda request: httpx.Response(200, json=payload),
|
||||
)
|
||||
try:
|
||||
with pytest.raises(docker_provider.DockerProviderError, match="invalid"):
|
||||
client.containers.list()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_broker_rejects_non_json_inventory_response(monkeypatch) -> None:
|
||||
client, _ = _mocked_broker_client(
|
||||
monkeypatch,
|
||||
lambda request: httpx.Response(
|
||||
200, content=b'{"items":[]}', headers={"content-type": "text/plain"}
|
||||
),
|
||||
)
|
||||
try:
|
||||
with pytest.raises(docker_provider.DockerProviderError, match="invalid"):
|
||||
client.containers.list()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_broker_rejects_oversized_inventory_response(monkeypatch) -> None:
|
||||
monkeypatch.setattr(docker_provider, "_BROKER_MAX_RESPONSE_BYTES", 4)
|
||||
client, _ = _mocked_broker_client(
|
||||
monkeypatch,
|
||||
lambda request: httpx.Response(200, content=b'{"items":[]}'),
|
||||
)
|
||||
try:
|
||||
with pytest.raises(docker_provider.DockerProviderError, match="invalid"):
|
||||
client.containers.list()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_broker_container_preserves_discovery_fields_without_exposing_environment(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
item = {
|
||||
"containerRef": "abcdef1234567890",
|
||||
"name": "/example-service",
|
||||
"image": "example/service:1.0",
|
||||
"state": "running",
|
||||
"health": "healthy",
|
||||
"healthcheckPresent": True,
|
||||
"labels": {"servicemap.enabled": "true"},
|
||||
"networks": ["default"],
|
||||
"ports": {
|
||||
"8000/tcp": [{"hostIp": "0.0.0.0", "hostPort": "1253"}],
|
||||
"5432/tcp": None,
|
||||
},
|
||||
}
|
||||
client, _ = _mocked_broker_client(
|
||||
monkeypatch,
|
||||
lambda request: httpx.Response(200, json={"items": [item]}),
|
||||
)
|
||||
try:
|
||||
[container] = client.containers.list()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert container.id == item["containerRef"]
|
||||
assert container.name == "example-service"
|
||||
assert container.image.tags == [item["image"]]
|
||||
assert container.attrs["State"] == {"Status": "running", "Health": {"Status": "healthy"}}
|
||||
assert container.attrs["Config"]["Labels"] == item["labels"]
|
||||
assert container.attrs["Config"]["Healthcheck"] == {"BrokerReported": True}
|
||||
assert container.attrs["Config"]["Env"] == []
|
||||
assert container.attrs["NetworkSettings"]["Networks"] == {"default": {}}
|
||||
assert container.attrs["NetworkSettings"]["Ports"] == {
|
||||
"8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": "1253"}],
|
||||
"5432/tcp": [],
|
||||
}
|
||||
|
||||
|
||||
def test_broker_health_status_proves_healthcheck_when_old_schema_omits_flag(monkeypatch) -> None:
|
||||
item = {
|
||||
"containerRef": "abcdef1234567890",
|
||||
"name": "example-service",
|
||||
"image": "example/service:1.0",
|
||||
"state": "running",
|
||||
"health": "healthy",
|
||||
"labels": {},
|
||||
"networks": [],
|
||||
"ports": {},
|
||||
}
|
||||
client, _ = _mocked_broker_client(
|
||||
monkeypatch, lambda request: httpx.Response(200, json={"items": [item]})
|
||||
)
|
||||
try:
|
||||
[container] = client.containers.list()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert container.attrs["Config"]["Healthcheck"] == {"BrokerReported": True}
|
||||
|
||||
|
||||
def test_broker_error_messages_do_not_expose_destination_or_credential(monkeypatch) -> None:
|
||||
def handler(request):
|
||||
raise httpx.ConnectError(
|
||||
"failed with opaque-token at https://broker.internal", request=request
|
||||
)
|
||||
|
||||
client, _ = _mocked_broker_client(monkeypatch, handler)
|
||||
try:
|
||||
with pytest.raises(docker_provider.DockerProviderError) as caught:
|
||||
client.containers.list()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert "Docker broker request failed" in str(caught.value)
|
||||
assert "opaque-token" not in str(caught.value)
|
||||
assert "broker.internal" not in str(caught.value)
|
||||
@@ -0,0 +1,99 @@
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.health import dependency_status, effective_status, validate_dependencies
|
||||
from app.incidents import incident_overview
|
||||
from app.models import HealthStatus, ProbeResult, ServiceCreate, ServiceKind, ServiceUpdate
|
||||
from app.store import Store
|
||||
|
||||
|
||||
def _probe(service_id: str, status: HealthStatus) -> ProbeResult:
|
||||
return ProbeResult(
|
||||
id=f"probe-{service_id}-{status.value}",
|
||||
service_id=service_id,
|
||||
status=status,
|
||||
latency_ms=1.0,
|
||||
detail=status.value,
|
||||
checked_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_down_dependency_degrades_healthy_service(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
database = store.create_service(
|
||||
ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432)
|
||||
)
|
||||
api = store.create_service(
|
||||
ServiceCreate(
|
||||
name="API",
|
||||
kind=ServiceKind.HTTP,
|
||||
url="https://example.test",
|
||||
depends_on=[database.id],
|
||||
)
|
||||
)
|
||||
store.save_probe(_probe(database.id, HealthStatus.DOWN))
|
||||
store.save_probe(_probe(api.id, HealthStatus.HEALTHY))
|
||||
|
||||
assert dependency_status(api, store) == HealthStatus.DOWN
|
||||
assert effective_status(api, store) == HealthStatus.DEGRADED
|
||||
|
||||
|
||||
def test_service_without_dependencies_has_healthy_dependency_state(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
service = store.create_service(
|
||||
ServiceCreate(name="Standalone", kind=ServiceKind.HTTP, url="https://example.test")
|
||||
)
|
||||
|
||||
assert dependency_status(service, store) == HealthStatus.HEALTHY
|
||||
assert effective_status(service, store) == HealthStatus.UNKNOWN
|
||||
|
||||
|
||||
def test_cycle_validation_rejects_recursive_dependencies(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
database = store.create_service(
|
||||
ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432)
|
||||
)
|
||||
api = store.create_service(
|
||||
ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://example.test", depends_on=[database.id])
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="cycle"):
|
||||
validate_dependencies([api.id], store, service_id=database.id)
|
||||
|
||||
validate_dependencies([database.id], store, service_id=api.id)
|
||||
updated = store.update_service(
|
||||
api.id,
|
||||
ServiceUpdate(name="API", kind=ServiceKind.HTTP, url="https://example.test", depends_on=[database.id]),
|
||||
)
|
||||
assert updated is not None
|
||||
|
||||
|
||||
def test_incident_overview_finds_root_cause_and_multihop_impact(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
database = store.create_service(
|
||||
ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432)
|
||||
)
|
||||
api = store.create_service(
|
||||
ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://example.test", depends_on=[database.id])
|
||||
)
|
||||
web = store.create_service(
|
||||
ServiceCreate(name="Web", kind=ServiceKind.HTTP, url="https://web.test", depends_on=[api.id])
|
||||
)
|
||||
store.save_probe(_probe(database.id, HealthStatus.DOWN))
|
||||
store.save_probe(_probe(api.id, HealthStatus.HEALTHY))
|
||||
store.save_probe(_probe(web.id, HealthStatus.HEALTHY))
|
||||
|
||||
assert effective_status(api, store) == HealthStatus.DEGRADED
|
||||
assert dependency_status(web, store) == HealthStatus.DEGRADED
|
||||
assert effective_status(web, store) == HealthStatus.DEGRADED
|
||||
|
||||
overview = incident_overview(store)
|
||||
|
||||
assert overview.active is True
|
||||
assert overview.down_service_ids == [database.id]
|
||||
assert set(overview.degraded_service_ids) == {api.id, web.id}
|
||||
assert overview.candidates[0].service_id == database.id
|
||||
assert api.id in overview.candidates[0].affected_service_ids
|
||||
assert web.id in overview.candidates[0].affected_service_ids
|
||||
@@ -0,0 +1,156 @@
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from typing import Self
|
||||
|
||||
import pytest
|
||||
|
||||
from app import probes
|
||||
from app.models import HealthStatus, ProbeResult, Service, ServiceKind
|
||||
|
||||
|
||||
def _service() -> Service:
|
||||
now = datetime.now(UTC)
|
||||
return Service(
|
||||
id="service-1",
|
||||
name="API",
|
||||
kind=ServiceKind.HTTP,
|
||||
url="https://api.local",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
def _tcp_service() -> Service:
|
||||
now = datetime.now(UTC)
|
||||
return Service(
|
||||
id="tcp-service-1",
|
||||
name="TCP service",
|
||||
kind=ServiceKind.TCP,
|
||||
host="tcp.local",
|
||||
port=5432,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "expected"),
|
||||
[
|
||||
(200, HealthStatus.HEALTHY),
|
||||
(302, HealthStatus.HEALTHY),
|
||||
(404, HealthStatus.DEGRADED),
|
||||
(503, HealthStatus.DOWN),
|
||||
],
|
||||
)
|
||||
def test_http_status_classification(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
status_code: int,
|
||||
expected: HealthStatus,
|
||||
) -> None:
|
||||
class FakeResponse:
|
||||
def __init__(self, code: int) -> None:
|
||||
self.status_code = code
|
||||
|
||||
class FakeClient:
|
||||
async def __aenter__(self) -> Self:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, url: str) -> FakeResponse:
|
||||
assert url == "https://api.local"
|
||||
return FakeResponse(status_code)
|
||||
|
||||
monkeypatch.setattr(probes.httpx, "AsyncClient", lambda **kwargs: FakeClient())
|
||||
|
||||
status, detail = asyncio.run(probes._probe_once(_service(), 1.0))
|
||||
|
||||
assert status == expected
|
||||
assert detail == f"HTTP {status_code}"
|
||||
|
||||
|
||||
def test_retry_latency_reports_final_attempt_not_backoff(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls = 0
|
||||
|
||||
async def fake_probe_once(service: Service, timeout_seconds: float) -> tuple[HealthStatus, str]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise OSError("temporary failure")
|
||||
return HealthStatus.HEALTHY, "HTTP 200"
|
||||
|
||||
timings = iter([10.0, 10.05, 20.0, 20.02])
|
||||
monkeypatch.setattr(probes, "_probe_once", fake_probe_once)
|
||||
monkeypatch.setattr(probes, "perf_counter", lambda: next(timings))
|
||||
|
||||
result = asyncio.run(probes.probe_service(_service(), timeout_seconds=1.0, retries=1))
|
||||
|
||||
assert calls == 2
|
||||
assert result.status == HealthStatus.HEALTHY
|
||||
assert result.latency_ms == pytest.approx(20.0)
|
||||
assert result.detail == "HTTP 200 after 2 attempts"
|
||||
|
||||
|
||||
def test_tcp_probe_success_and_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class FakeWriter:
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
return None
|
||||
|
||||
async def fake_open_connection(host: str | None, port: int | None) -> tuple[object, FakeWriter]:
|
||||
assert (host, port) == ("tcp.local", 5432)
|
||||
return object(), FakeWriter()
|
||||
|
||||
monkeypatch.setattr(probes.asyncio, "open_connection", fake_open_connection)
|
||||
status, detail = asyncio.run(probes._probe_once(_tcp_service(), 1.0))
|
||||
assert status == HealthStatus.HEALTHY
|
||||
assert detail == "TCP tcp.local:5432 reachable"
|
||||
|
||||
async def failing_open_connection(host: str | None, port: int | None) -> tuple[object, FakeWriter]:
|
||||
raise ConnectionRefusedError("refused")
|
||||
|
||||
monkeypatch.setattr(probes.asyncio, "open_connection", failing_open_connection)
|
||||
result = asyncio.run(probes.probe_service(_tcp_service(), timeout_seconds=1.0, retries=0))
|
||||
assert result.status == HealthStatus.DOWN
|
||||
assert "ConnectionRefusedError" in result.detail
|
||||
|
||||
|
||||
def test_probe_many_respects_concurrency_boundary(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
services = [
|
||||
_service().model_copy(update={"id": f"service-{index}", "name": f"API-{index}"})
|
||||
for index in range(5)
|
||||
]
|
||||
active = 0
|
||||
maximum = 0
|
||||
|
||||
async def fake_probe_service(
|
||||
service: Service,
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
retries: int,
|
||||
) -> ProbeResult:
|
||||
nonlocal active, maximum
|
||||
active += 1
|
||||
maximum = max(maximum, active)
|
||||
await asyncio.sleep(0)
|
||||
active -= 1
|
||||
return ProbeResult(
|
||||
id=f"probe-{service.id}",
|
||||
service_id=service.id,
|
||||
status=HealthStatus.HEALTHY,
|
||||
latency_ms=1.0,
|
||||
detail="ok",
|
||||
checked_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(probes, "probe_service", fake_probe_service)
|
||||
|
||||
results = asyncio.run(probes.probe_many(services, concurrency=2))
|
||||
|
||||
assert len(results) == len(services)
|
||||
assert maximum <= 2
|
||||
@@ -0,0 +1,69 @@
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
|
||||
from app.scheduler import scheduled_probe_loop
|
||||
|
||||
|
||||
class EmptyStore:
|
||||
def __init__(self) -> None:
|
||||
self.prune_calls = 0
|
||||
|
||||
def list_services(self) -> list[object]:
|
||||
return []
|
||||
|
||||
def prune_probes(self, retention_days: int) -> int:
|
||||
assert retention_days == 30
|
||||
self.prune_calls += 1
|
||||
return 0
|
||||
|
||||
|
||||
class SyntheticStore(EmptyStore):
|
||||
def demo_active(self) -> bool:
|
||||
return True
|
||||
|
||||
def list_services(self) -> list[object]:
|
||||
raise AssertionError("synthetic demo must not start live probes")
|
||||
|
||||
|
||||
def test_scheduler_runs_first_cycle_before_initial_sleep() -> None:
|
||||
async def scenario() -> None:
|
||||
store = EmptyStore()
|
||||
task = asyncio.create_task(
|
||||
scheduled_probe_loop(
|
||||
store, # type: ignore[arg-type]
|
||||
interval_seconds=3600,
|
||||
timeout_seconds=1.0,
|
||||
retries=0,
|
||||
concurrency=1,
|
||||
retention_days=30,
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
assert store.prune_calls == 1
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_scheduler_pauses_live_probes_for_synthetic_demo() -> None:
|
||||
async def scenario() -> None:
|
||||
store = SyntheticStore()
|
||||
task = asyncio.create_task(
|
||||
scheduled_probe_loop(
|
||||
store, # type: ignore[arg-type]
|
||||
interval_seconds=3600,
|
||||
timeout_seconds=1.0,
|
||||
retries=0,
|
||||
concurrency=1,
|
||||
retention_days=30,
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
assert store.prune_calls == 1
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -0,0 +1,107 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import HealthStatus, ProbeResult, ServiceCreate, ServiceKind, ServiceUpdate
|
||||
from app.store import Store
|
||||
|
||||
|
||||
def test_service_round_trip_and_update(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
created = store.create_service(
|
||||
ServiceCreate(name="Example API", kind=ServiceKind.HTTP, url="https://example.test")
|
||||
)
|
||||
|
||||
loaded = store.get_service(created.id)
|
||||
assert loaded is not None
|
||||
assert loaded.name == "Example API"
|
||||
assert loaded.kind == ServiceKind.HTTP
|
||||
assert loaded.url == "https://example.test"
|
||||
|
||||
updated = store.update_service(
|
||||
created.id,
|
||||
ServiceUpdate(name="Example TCP", kind=ServiceKind.TCP, host="127.0.0.1", port=443),
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.kind == ServiceKind.TCP
|
||||
assert updated.host == "127.0.0.1"
|
||||
assert updated.port == 443
|
||||
assert updated.updated_at >= created.updated_at
|
||||
|
||||
|
||||
def test_delete_service_cleans_downstream_dependency(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
database = store.create_service(
|
||||
ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432)
|
||||
)
|
||||
api = store.create_service(
|
||||
ServiceCreate(
|
||||
name="API",
|
||||
kind=ServiceKind.HTTP,
|
||||
url="https://example.test",
|
||||
depends_on=[database.id],
|
||||
)
|
||||
)
|
||||
|
||||
assert store.delete_service(database.id) is True
|
||||
loaded_api = store.get_service(api.id)
|
||||
assert loaded_api is not None
|
||||
assert loaded_api.depends_on == []
|
||||
assert store.delete_service(database.id) is False
|
||||
|
||||
|
||||
def test_stale_probe_after_service_delete_is_ignored(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
service = store.create_service(
|
||||
ServiceCreate(name="Temporary", kind=ServiceKind.HTTP, url="https://example.test")
|
||||
)
|
||||
assert store.delete_service(service.id) is True
|
||||
|
||||
probe = ProbeResult(
|
||||
id="stale-probe",
|
||||
service_id=service.id,
|
||||
status=HealthStatus.HEALTHY,
|
||||
latency_ms=1.0,
|
||||
detail="stale",
|
||||
checked_at=datetime.now(UTC),
|
||||
)
|
||||
assert store.save_probe(probe) == probe
|
||||
assert store.probe_history(service.id) == []
|
||||
|
||||
|
||||
def test_availability_summary_and_retention(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
service = store.create_service(
|
||||
ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://example.test")
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
for index, probe_status in enumerate((HealthStatus.HEALTHY, HealthStatus.DEGRADED, HealthStatus.DOWN)):
|
||||
store.save_probe(
|
||||
ProbeResult(
|
||||
id=f"probe-{index}",
|
||||
service_id=service.id,
|
||||
status=probe_status,
|
||||
latency_ms=float(10 + index),
|
||||
detail=probe_status.value,
|
||||
checked_at=now - timedelta(minutes=index),
|
||||
)
|
||||
)
|
||||
store.save_probe(
|
||||
ProbeResult(
|
||||
id="old-probe",
|
||||
service_id=service.id,
|
||||
status=HealthStatus.DOWN,
|
||||
latency_ms=99.0,
|
||||
detail="old",
|
||||
checked_at=now - timedelta(days=40),
|
||||
)
|
||||
)
|
||||
|
||||
summary = store.availability_summary(service.id, hours=24)
|
||||
assert summary.total_checks == 3
|
||||
assert summary.healthy_checks == 1
|
||||
assert summary.degraded_checks == 1
|
||||
assert summary.down_checks == 1
|
||||
assert summary.uptime_percent == 66.67
|
||||
assert summary.average_latency_ms == 11.0
|
||||
|
||||
assert store.prune_probes(30) == 1
|
||||
@@ -0,0 +1,159 @@
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from app.config_io import export_document, import_document, parse_document
|
||||
from app.incidents import sync_incidents
|
||||
from app.models import HealthStatus, ProbeResult, ServiceCreate, ServiceKind
|
||||
from app.store import Store
|
||||
|
||||
|
||||
def _probe(service_id: str, status: HealthStatus, number: int) -> ProbeResult:
|
||||
return ProbeResult(
|
||||
id=f"probe-{service_id}-{number}",
|
||||
service_id=service_id,
|
||||
status=status,
|
||||
latency_ms=4.0,
|
||||
detail=status.value,
|
||||
checked_at=datetime.now(UTC) + timedelta(microseconds=number),
|
||||
)
|
||||
|
||||
|
||||
def test_v110_schema_migrates_v101_database_without_reset(tmp_path: Path) -> None:
|
||||
path = tmp_path / "servicemap.db"
|
||||
with sqlite3.connect(path) as db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE services (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, kind TEXT NOT NULL,
|
||||
url TEXT, host TEXT, port INTEGER, depends_on TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL, updated_at TEXT
|
||||
);
|
||||
CREATE TABLE probe_results (
|
||||
id TEXT PRIMARY KEY, service_id TEXT NOT NULL, status TEXT NOT NULL,
|
||||
latency_ms REAL, detail TEXT NOT NULL, checked_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE demo_sessions (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), snapshot_services TEXT NOT NULL,
|
||||
snapshot_probes TEXT NOT NULL, demo_service_ids TEXT NOT NULL, started_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
created = datetime.now(UTC).isoformat()
|
||||
db.execute(
|
||||
"INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
("legacy", "Legacy API", "http", "https://legacy.local", None, None, "[]", created, created),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO probe_results VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("legacy-probe", "legacy", "healthy", 12.0, "legacy", created),
|
||||
)
|
||||
|
||||
store = Store(str(path))
|
||||
service = store.get_service("legacy")
|
||||
assert service is not None
|
||||
assert service.name == "Legacy API"
|
||||
assert store.service_health("legacy") == HealthStatus.HEALTHY
|
||||
assert store.latest_probe("legacy") is not None
|
||||
assert store.path == path
|
||||
|
||||
|
||||
def test_failure_and_recovery_thresholds_are_explicit(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
service = store.create_service(
|
||||
ServiceCreate(
|
||||
name="Threshold API",
|
||||
kind=ServiceKind.HTTP,
|
||||
url="https://threshold.local",
|
||||
failure_threshold=2,
|
||||
recovery_threshold=2,
|
||||
)
|
||||
)
|
||||
|
||||
store.save_probe(_probe(service.id, HealthStatus.DOWN, 1))
|
||||
assert store.service_health(service.id) == HealthStatus.UNKNOWN
|
||||
store.save_probe(_probe(service.id, HealthStatus.DOWN, 2))
|
||||
assert store.service_health(service.id) == HealthStatus.DOWN
|
||||
store.save_probe(_probe(service.id, HealthStatus.HEALTHY, 3))
|
||||
assert store.service_health(service.id) == HealthStatus.RECOVERING
|
||||
store.save_probe(_probe(service.id, HealthStatus.HEALTHY, 4))
|
||||
assert store.service_health(service.id) == HealthStatus.HEALTHY
|
||||
|
||||
|
||||
def test_incident_journal_acknowledges_resolves_and_does_not_duplicate(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
database = store.create_service(ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db.local", port=5432))
|
||||
api = store.create_service(ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://api.local", depends_on=[database.id]))
|
||||
|
||||
store.save_probe(_probe(database.id, HealthStatus.DOWN, 1))
|
||||
store.save_probe(_probe(database.id, HealthStatus.DOWN, 2))
|
||||
store.save_probe(_probe(api.id, HealthStatus.HEALTHY, 1))
|
||||
active = store.list_incidents(active_only=True)
|
||||
assert len(active) == 1
|
||||
assert active[0].root_service_id == database.id
|
||||
assert api.id in active[0].affected_service_ids
|
||||
assert len([event for event in store.list_events(100) if event.event_type == "incident.opened"]) == 1
|
||||
|
||||
acknowledged = store.acknowledge_incident(active[0].id)
|
||||
assert acknowledged is not None
|
||||
assert acknowledged.status.value == "acknowledged"
|
||||
store.save_probe(_probe(database.id, HealthStatus.HEALTHY, 3))
|
||||
resolved = store.get_incident(active[0].id)
|
||||
assert resolved is not None
|
||||
assert resolved.status.value == "resolved"
|
||||
assert resolved.resolved_at is not None
|
||||
assert any(event.event_type == "incident.resolved" for event in store.list_events(100))
|
||||
|
||||
|
||||
def test_incident_reconciliation_resolves_existing_duplicate_active_roots(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
service = store.create_service(ServiceCreate(name="Duplicate root", kind=ServiceKind.TCP, host="db.local", port=5432))
|
||||
store.save_probe(_probe(service.id, HealthStatus.DOWN, 1))
|
||||
store.save_probe(_probe(service.id, HealthStatus.DOWN, 2))
|
||||
original = store.list_incidents(active_only=True)[0]
|
||||
duplicate = original.model_copy(update={"id": str(uuid4()), "detected_at": original.detected_at + timedelta(seconds=1)})
|
||||
store.upsert_incident(duplicate)
|
||||
|
||||
sync_incidents(store)
|
||||
|
||||
active = store.list_incidents(active_only=True)
|
||||
assert len(active) == 1
|
||||
assert active[0].root_service_id == service.id
|
||||
assert store.get_incident(duplicate.id).status.value == "resolved"
|
||||
assert any(event.event_type == "incident.duplicate_resolved" for event in store.incident_timeline(duplicate.id))
|
||||
|
||||
|
||||
def test_maintenance_suppresses_incident_and_config_v2_round_trips(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
document = parse_document(
|
||||
"""
|
||||
version: 2
|
||||
services:
|
||||
- name: DNS edge
|
||||
kind: dns
|
||||
host: edge.internal
|
||||
group: Edge
|
||||
environment: staging
|
||||
tags: [dns, edge]
|
||||
criticality: high
|
||||
interval_seconds: 15
|
||||
depends_on: []
|
||||
""",
|
||||
"yaml",
|
||||
)
|
||||
result = import_document(store, document, "replace")
|
||||
assert result.created == 1
|
||||
service = store.get_service_by_name("DNS edge")
|
||||
assert service is not None
|
||||
assert service.group == "Edge"
|
||||
assert service.interval_seconds == 15
|
||||
assert parse_document(json.dumps(export_document(store).model_dump(mode="json")), "json").version == 2
|
||||
|
||||
updated = store.set_maintenance(service.id, True, "planned work")
|
||||
assert updated is not None
|
||||
assert store.service_health(service.id) == HealthStatus.MAINTENANCE
|
||||
store.save_probe(_probe(service.id, HealthStatus.DOWN, 1))
|
||||
sync_incidents(store)
|
||||
assert store.list_incidents(active_only=True) == []
|
||||
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.discovery import DiscoveryManager
|
||||
from app.discovery_store import DiscoveryStore
|
||||
from app.docker_discovery import _kind_and_endpoint, _preflight_candidate
|
||||
from app.models import (
|
||||
DiscoveryCategory,
|
||||
DiscoveryConfidence,
|
||||
DiscoveryRunStatus,
|
||||
DockerCandidate,
|
||||
DockerPort,
|
||||
DockerSuggestion,
|
||||
HealthStatus,
|
||||
MonitoringState,
|
||||
ProbeEndpointCandidate,
|
||||
ProbeEndpointSource,
|
||||
ProbeReachability,
|
||||
ProbeResult,
|
||||
ServiceCreate,
|
||||
ServiceKind,
|
||||
)
|
||||
from app.store import Store
|
||||
|
||||
|
||||
def _manager(tmp_path: Path) -> DiscoveryManager:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
return DiscoveryManager(store, DiscoveryStore(store))
|
||||
|
||||
|
||||
def _candidate(
|
||||
name: str,
|
||||
reference: str,
|
||||
*,
|
||||
project: str | None = None,
|
||||
compose_service: str | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
networks: list[str] | None = None,
|
||||
) -> DockerCandidate:
|
||||
return DockerCandidate(
|
||||
container_id=reference[-12:],
|
||||
name=name,
|
||||
image="example/service:1",
|
||||
state="running",
|
||||
networks=networks or [],
|
||||
ports=[],
|
||||
labels=labels or {},
|
||||
stable_source_ref=reference,
|
||||
compose_project=project,
|
||||
compose_service=compose_service,
|
||||
category=DiscoveryCategory.APPLICATION,
|
||||
)
|
||||
|
||||
|
||||
def test_internal_docker_dns_is_evidence_not_an_active_probe() -> None:
|
||||
kind, url, host, port, evidence = _kind_and_endpoint(
|
||||
"postgres",
|
||||
[DockerPort(container_port="5432/tcp")],
|
||||
{},
|
||||
)
|
||||
|
||||
assert kind == ServiceKind.TCP
|
||||
assert url is None
|
||||
assert host == "postgres"
|
||||
assert port == 5432
|
||||
assert "internal container endpoint candidate" in evidence
|
||||
|
||||
|
||||
def test_published_port_requires_an_explicit_probe_host() -> None:
|
||||
ports = [DockerPort(container_port="8080/tcp", host_ip="0.0.0.0", host_port=1226)]
|
||||
labels = {"servicemap.probe": "http"}
|
||||
_, unsafe_url, unsafe_host, _, _ = _kind_and_endpoint("app", ports, labels)
|
||||
kind, url, host, port, evidence = _kind_and_endpoint("app", ports, labels, "monitoring-host")
|
||||
|
||||
assert unsafe_url is None
|
||||
assert unsafe_host == "app"
|
||||
assert kind == ServiceKind.HTTP
|
||||
assert url == "http://monitoring-host:1226"
|
||||
assert host is None and port is None
|
||||
assert "host-published endpoint" in evidence
|
||||
|
||||
|
||||
def test_running_container_without_reachable_endpoint_is_runtime_observed(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
candidate = _candidate("worker", "docker:name:worker")
|
||||
candidate.suggestions = [DockerSuggestion(name="worker", kind=ServiceKind.TCP, host="worker", port=9000)]
|
||||
candidate.probe_candidates = [
|
||||
ProbeEndpointCandidate(
|
||||
source=ProbeEndpointSource.CONTAINER_INTERNAL,
|
||||
kind=ServiceKind.TCP,
|
||||
host="worker",
|
||||
port=9000,
|
||||
reachability=ProbeReachability.INTERNAL_ONLY,
|
||||
)
|
||||
]
|
||||
|
||||
payload = manager._payload_for_candidate(candidate)
|
||||
|
||||
assert payload.monitoring_enabled is False
|
||||
assert payload.monitoring_state.value == "observed"
|
||||
assert payload.probe_reachability == ProbeReachability.INTERNAL_ONLY
|
||||
|
||||
|
||||
def test_docker_healthcheck_observation_does_not_enable_network_probe(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
candidate = _candidate("database", "docker:name:database")
|
||||
candidate.healthcheck_present = True
|
||||
candidate.probe_candidates = [
|
||||
ProbeEndpointCandidate(
|
||||
source=ProbeEndpointSource.DOCKER_HEALTHCHECK,
|
||||
kind=ServiceKind.TCP,
|
||||
reachability=ProbeReachability.VERIFIED,
|
||||
confidence=DiscoveryConfidence.HIGH,
|
||||
)
|
||||
]
|
||||
|
||||
payload = manager._payload_for_candidate(candidate)
|
||||
|
||||
assert payload.monitoring_enabled is False
|
||||
assert payload.monitoring_state.value == "docker_health"
|
||||
assert payload.probe_source == ProbeEndpointSource.DOCKER_HEALTHCHECK
|
||||
|
||||
|
||||
def test_appops_application_health_is_provenance_not_component_health(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
candidate = _candidate("application", "docker:name:application")
|
||||
candidate.healthcheck_present = True
|
||||
candidate.appops_health = "healthy"
|
||||
candidate.appops_app_id = "northstar"
|
||||
candidate.appops_discovery_confidence = 0.9
|
||||
|
||||
payload = manager._payload_for_candidate(candidate)
|
||||
|
||||
assert payload.monitoring_enabled is False
|
||||
assert payload.monitoring_state == MonitoringState.DOCKER_HEALTH
|
||||
assert payload.probe_source == ProbeEndpointSource.DOCKER_HEALTHCHECK
|
||||
assert payload.runtime_health is None
|
||||
|
||||
service = manager.store.create_service(payload)
|
||||
manager._record_candidate_evidence(candidate, service)
|
||||
resolution = manager.store.health_resolution(service.id)
|
||||
|
||||
assert resolution.selected_evidence_id is None
|
||||
assert resolution.evidence[0].scope.value == "application"
|
||||
assert "not used as component" in resolution.explanation
|
||||
|
||||
|
||||
def test_compose_reference_prefers_same_project_over_duplicate_stack_name(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
run = manager.discovery_store.start_run()
|
||||
source = _candidate(
|
||||
"api-a",
|
||||
"docker:compose:stack-a:api",
|
||||
project="stack-a",
|
||||
compose_service="api",
|
||||
labels={"com.docker.compose.depends_on": "postgres:service_healthy:false"},
|
||||
)
|
||||
postgres_a = _candidate("postgres-a", "docker:compose:stack-a:postgres", project="stack-a", compose_service="postgres")
|
||||
postgres_b = _candidate("postgres-b", "docker:compose:stack-b:postgres", project="stack-b", compose_service="postgres")
|
||||
|
||||
suggestions = manager._relation_suggestions(run, [source, postgres_a, postgres_b], {})
|
||||
|
||||
assert len(suggestions) == 1
|
||||
assert suggestions[0].target_ref == postgres_a.stable_source_ref
|
||||
assert suggestions[0].confidence == DiscoveryConfidence.HIGH
|
||||
|
||||
|
||||
def test_ambiguous_global_name_stays_review_only(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
run = manager.discovery_store.start_run()
|
||||
source = _candidate(
|
||||
"api",
|
||||
"docker:name:api",
|
||||
labels={"servicemap.depends_on": "redis"},
|
||||
)
|
||||
redis_a = _candidate("redis-a", "docker:compose:a:redis", project="a", compose_service="redis")
|
||||
redis_b = _candidate("redis-b", "docker:compose:b:redis", project="b", compose_service="redis")
|
||||
|
||||
suggestions = manager._relation_suggestions(run, [source, redis_a, redis_b], {})
|
||||
|
||||
assert len(suggestions) == 1
|
||||
assert suggestions[0].target_ref is None
|
||||
assert suggestions[0].confidence == DiscoveryConfidence.LOW
|
||||
assert "Manual review required" in " ".join(suggestions[0].evidence)
|
||||
|
||||
|
||||
def test_reachability_repair_is_dry_run_first_and_preserves_probe_history(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
candidate = _candidate("legacy-db", "docker:name:legacy-db")
|
||||
candidate.suggestions = [DockerSuggestion(name="legacy-db", kind=ServiceKind.TCP, host="legacy-db", port=5432)]
|
||||
candidate.probe_candidates = [
|
||||
ProbeEndpointCandidate(
|
||||
source=ProbeEndpointSource.CONTAINER_INTERNAL,
|
||||
kind=ServiceKind.TCP,
|
||||
host="legacy-db",
|
||||
port=5432,
|
||||
reachability=ProbeReachability.INTERNAL_ONLY,
|
||||
)
|
||||
]
|
||||
legacy = manager.store.create_service(
|
||||
ServiceCreate(
|
||||
name="legacy-db",
|
||||
kind=ServiceKind.TCP,
|
||||
host="legacy-db",
|
||||
port=5432,
|
||||
source_type="docker",
|
||||
source_ref=candidate.stable_source_ref,
|
||||
)
|
||||
)
|
||||
manager.store.save_probe(
|
||||
ProbeResult(
|
||||
id="legacy-probe",
|
||||
service_id=legacy.id,
|
||||
status=HealthStatus.DOWN,
|
||||
latency_ms=None,
|
||||
detail="legacy unreachable internal DNS target",
|
||||
checked_at=legacy.created_at,
|
||||
)
|
||||
)
|
||||
run = manager.discovery_store.start_run()
|
||||
source = manager.discovery_store.save_source(
|
||||
source_type="docker",
|
||||
source_id=candidate.stable_source_ref or "",
|
||||
source_name=candidate.name,
|
||||
lifecycle=candidate.lifecycle,
|
||||
metadata={"candidate": candidate.model_dump(mode="json")},
|
||||
)
|
||||
manager.discovery_store.save_observation(
|
||||
run_id=run.id,
|
||||
source=source,
|
||||
source_name=candidate.name,
|
||||
lifecycle=candidate.lifecycle,
|
||||
change_type="changed",
|
||||
metadata={"candidate": candidate.model_dump(mode="json")},
|
||||
)
|
||||
manager.discovery_store.finish_run(run.id, status=DiscoveryRunStatus.COMPLETED, source_counts={}, diff=run.diff)
|
||||
|
||||
preview = manager.repair_probe_reachability(run_id=run.id, dry_run=True)
|
||||
assert preview.changed == 1
|
||||
assert manager.store.get_service(legacy.id).monitoring_enabled is True # type: ignore[union-attr]
|
||||
|
||||
applied = manager.repair_probe_reachability(run_id=run.id, dry_run=False)
|
||||
updated = manager.store.get_service(legacy.id)
|
||||
assert applied.disabled_unsafe == 1
|
||||
assert updated is not None and updated.monitoring_enabled is False
|
||||
assert updated.monitoring_state.value == "observed"
|
||||
assert len(manager.store.probe_history(legacy.id)) == 1
|
||||
|
||||
|
||||
def test_container_http_port_is_not_misclassified_by_a_custom_host_port() -> None:
|
||||
kind, url, host, port, evidence = _kind_and_endpoint(
|
||||
"adminer",
|
||||
[DockerPort(container_port="8080/tcp", host_ip="0.0.0.0", host_port=8070)],
|
||||
{},
|
||||
"127.0.0.1",
|
||||
)
|
||||
|
||||
assert kind == ServiceKind.HTTP
|
||||
assert url == "http://127.0.0.1:8070"
|
||||
assert host is None and port is None
|
||||
assert "HTTP-like container port" in evidence
|
||||
|
||||
|
||||
def test_published_preflight_requires_a_real_tcp_connection(monkeypatch) -> None:
|
||||
candidate = ProbeEndpointCandidate(
|
||||
source=ProbeEndpointSource.PUBLISHED_PORT,
|
||||
kind=ServiceKind.TCP,
|
||||
host="127.0.0.1",
|
||||
port=3306,
|
||||
)
|
||||
|
||||
class _Connection:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("app.docker_discovery.socket.create_connection", lambda *_args, **_kwargs: _Connection())
|
||||
verified = _preflight_candidate(candidate)
|
||||
|
||||
assert verified.primary is True
|
||||
assert verified.reachability == ProbeReachability.VERIFIED
|
||||
assert verified.preflight_detail == "TCP connect succeeded"
|
||||
|
||||
|
||||
def test_http_preflight_records_response_without_scanning_paths(monkeypatch) -> None:
|
||||
candidate = ProbeEndpointCandidate(
|
||||
source=ProbeEndpointSource.PUBLISHED_PORT,
|
||||
kind=ServiceKind.HTTP,
|
||||
url="http://127.0.0.1:8070",
|
||||
)
|
||||
|
||||
class _Connection:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
class _Response:
|
||||
status_code = 204
|
||||
|
||||
class _Client:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
def request(self, method: str, url: str) -> _Response:
|
||||
assert method == "HEAD"
|
||||
assert url == candidate.url
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr("app.docker_discovery.socket.create_connection", lambda *_args, **_kwargs: _Connection())
|
||||
monkeypatch.setattr("app.docker_discovery.httpx.Client", lambda **_kwargs: _Client())
|
||||
verified = _preflight_candidate(candidate)
|
||||
|
||||
assert verified.primary is True
|
||||
assert verified.preflight_detail == "HTTP 204 responded"
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.discovery_store import DiscoveryStore
|
||||
from app.models import (
|
||||
DiscoveryConfidence,
|
||||
HealthStatus,
|
||||
MonitoringState,
|
||||
RelationEvidence,
|
||||
RelationType,
|
||||
Service,
|
||||
ServiceCreate,
|
||||
ServiceKind,
|
||||
ServiceSnapshot,
|
||||
TopologySuggestion,
|
||||
)
|
||||
from app.semantic import build_semantic_topology
|
||||
from app.store import Store
|
||||
|
||||
|
||||
def _service(identifier: str, name: str, *, application: str | None = None, category: str = "application") -> Service:
|
||||
now = datetime.now(UTC)
|
||||
return Service(
|
||||
id=identifier,
|
||||
name=name,
|
||||
kind=ServiceKind.HTTP,
|
||||
url=f"https://{identifier}.local",
|
||||
application=application,
|
||||
category=category,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(service: Service, direct: HealthStatus, effective: HealthStatus | None = None) -> ServiceSnapshot:
|
||||
return ServiceSnapshot(
|
||||
service=service,
|
||||
direct_status=direct,
|
||||
dependency_status=effective or direct,
|
||||
effective_status=effective or direct,
|
||||
)
|
||||
|
||||
|
||||
def test_semantic_topology_keeps_singletons_as_distinct_applications() -> None:
|
||||
snapshots = [
|
||||
_snapshot(_service("one", "One", category="application"), HealthStatus.HEALTHY),
|
||||
_snapshot(_service("two", "Two", category="application"), HealthStatus.HEALTHY),
|
||||
]
|
||||
|
||||
topology = build_semantic_topology(snapshots, [])
|
||||
|
||||
assert {app.name for app in topology.applications} == {"One", "Two"}
|
||||
assert all(app.component_count == 1 for app in topology.applications)
|
||||
|
||||
|
||||
def test_semantic_app_health_is_explainable_and_ignores_unmonitored_auxiliaries() -> None:
|
||||
postgres = _service("postgres", "Zabbix PostgreSQL", application="Zabbix", category="database")
|
||||
server = _service("server", "Zabbix Server", application="Zabbix", category="monitoring")
|
||||
agent = _service("agent", "Zabbix Agent", application="Zabbix", category="monitoring")
|
||||
agent.monitoring_enabled = False
|
||||
agent.monitoring_state = MonitoringState.UNMONITORED
|
||||
topology = build_semantic_topology([
|
||||
_snapshot(postgres, HealthStatus.DOWN),
|
||||
_snapshot(server, HealthStatus.HEALTHY),
|
||||
_snapshot(agent, HealthStatus.UNMONITORED),
|
||||
], [], root_service_ids={"postgres"}, affected_service_ids={"server"})
|
||||
|
||||
app = next(item for item in topology.applications if item.name == "Zabbix")
|
||||
assert app.status == HealthStatus.DEGRADED
|
||||
assert app.root_cause is True
|
||||
assert "PostgreSQL" in app.status_explanation
|
||||
assert app.monitored_components == 2
|
||||
assert app.unmonitored_components == 1
|
||||
components = {item.service_id: item for item in topology.components}
|
||||
assert components["postgres"].root_cause is True
|
||||
assert components["server"].root_cause is False
|
||||
assert components["server"].affected is True
|
||||
|
||||
|
||||
def test_logical_relation_merges_independent_evidence_and_improves_confidence(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
source = store.create_service(ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://api.local"))
|
||||
target = store.create_service(ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db", port=5432))
|
||||
discovery = DiscoveryStore(store)
|
||||
now = datetime.now(UTC)
|
||||
for evidence_type, confidence, explanation in (
|
||||
("compose", DiscoveryConfidence.MEDIUM, "Compose depends_on declares database"),
|
||||
("environment", DiscoveryConfidence.HIGH, "DATABASE_HOST resolves to database"),
|
||||
):
|
||||
discovery.save_evidence(RelationEvidence(
|
||||
id=f"evidence-{evidence_type}",
|
||||
source_service_id=source.id,
|
||||
target_service_id=target.id,
|
||||
relation_type=RelationType.DEPENDS_ON,
|
||||
evidence_type=evidence_type,
|
||||
confidence=confidence,
|
||||
explanation=explanation,
|
||||
source_ref=f"docker:compose:stack:{evidence_type}",
|
||||
first_seen_at=now,
|
||||
last_seen_at=now,
|
||||
))
|
||||
|
||||
relations = discovery.list_logical_relations()
|
||||
|
||||
assert len(relations) == 1
|
||||
assert relations[0].confidence == DiscoveryConfidence.CONFIRMED
|
||||
assert {item.evidence_type for item in relations[0].evidence} == {"compose", "environment"}
|
||||
|
||||
|
||||
def test_repeated_suggestion_merges_changed_evidence_without_a_duplicate(tmp_path: Path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
discovery = DiscoveryStore(store)
|
||||
run = discovery.start_run()
|
||||
now = datetime.now(UTC)
|
||||
base = {
|
||||
"id": "suggest-rel", "run_id": run.id, "suggestion_type": "relation", "source_ref": "docker:api",
|
||||
"target_ref": "docker:db", "relation_type": RelationType.DEPENDS_ON,
|
||||
"confidence": DiscoveryConfidence.HIGH, "created_at": now, "updated_at": now,
|
||||
}
|
||||
discovery.save_suggestion(TopologySuggestion(**base, evidence=["Compose relationship"]), "canonical-relation")
|
||||
second = {**base, "id": "suggest-rel-second"}
|
||||
saved = discovery.save_suggestion(TopologySuggestion(**second, evidence=["Environment relationship"]), "canonical-relation")
|
||||
|
||||
assert saved.evidence == ["Compose relationship", "Environment relationship"]
|
||||
assert len(discovery.list_suggestions()) == 1
|
||||
@@ -0,0 +1,132 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.discovery_store import DiscoveryStore
|
||||
from app.evidence import resolve_health
|
||||
from app.models import (
|
||||
EvidenceProvider,
|
||||
EvidenceScope,
|
||||
HealthEvidence,
|
||||
HealthStatus,
|
||||
ServiceCreate,
|
||||
ServiceKind,
|
||||
)
|
||||
from app.store import Store
|
||||
|
||||
|
||||
def _evidence(
|
||||
evidence_id: str,
|
||||
*,
|
||||
provider: EvidenceProvider,
|
||||
status: HealthStatus,
|
||||
scope: EvidenceScope = EvidenceScope.COMPONENT,
|
||||
service_id: str | None = "svc",
|
||||
observed_at: datetime | None = None,
|
||||
) -> HealthEvidence:
|
||||
observed = observed_at or datetime.now(UTC)
|
||||
return HealthEvidence(
|
||||
id=evidence_id,
|
||||
subject_type="service" if scope == EvidenceScope.COMPONENT else "application",
|
||||
subject_id=service_id or "appops:demo",
|
||||
service_id=service_id,
|
||||
provider=provider,
|
||||
provider_ref=provider.value,
|
||||
status=status,
|
||||
scope=scope,
|
||||
confidence=0.8,
|
||||
observed_at=observed,
|
||||
valid_until=observed + timedelta(minutes=5),
|
||||
received_at=observed,
|
||||
summary="test evidence",
|
||||
)
|
||||
|
||||
|
||||
def test_application_scope_evidence_never_becomes_component_direct_health(tmp_path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
service = store.create_service(
|
||||
ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://api.local")
|
||||
)
|
||||
store.record_health_evidence(
|
||||
_evidence(
|
||||
"appops-application",
|
||||
provider=EvidenceProvider.APPOPS,
|
||||
status=HealthStatus.DOWN,
|
||||
scope=EvidenceScope.APPLICATION,
|
||||
service_id=None,
|
||||
),
|
||||
service_ids=[service.id],
|
||||
)
|
||||
|
||||
resolution = store.health_resolution(service.id)
|
||||
|
||||
assert resolution.selected_evidence_id is None
|
||||
assert resolution.status == HealthStatus.UNKNOWN
|
||||
assert "application-scoped" in resolution.explanation
|
||||
|
||||
|
||||
def test_evidence_conflict_and_priority_are_visible(tmp_path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
service = store.create_service(
|
||||
ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://api.local")
|
||||
)
|
||||
store.record_health_evidence(
|
||||
_evidence(
|
||||
"docker-down",
|
||||
provider=EvidenceProvider.DOCKER,
|
||||
status=HealthStatus.DOWN,
|
||||
service_id=service.id,
|
||||
)
|
||||
)
|
||||
store.record_health_evidence(
|
||||
_evidence(
|
||||
"probe-healthy",
|
||||
provider=EvidenceProvider.PROBE,
|
||||
status=HealthStatus.HEALTHY,
|
||||
service_id=service.id,
|
||||
)
|
||||
)
|
||||
|
||||
resolution = store.health_resolution(service.id)
|
||||
|
||||
assert resolution.status == HealthStatus.HEALTHY
|
||||
assert resolution.selected_provider == EvidenceProvider.PROBE
|
||||
assert resolution.conflict is True
|
||||
|
||||
|
||||
def test_expired_evidence_is_explained_but_not_selected() -> None:
|
||||
old = datetime.now(UTC) - timedelta(hours=2)
|
||||
resolution = resolve_health(
|
||||
[
|
||||
_evidence(
|
||||
"expired",
|
||||
provider=EvidenceProvider.DOCKER,
|
||||
status=HealthStatus.DOWN,
|
||||
observed_at=old,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
assert resolution.selected_evidence_id is None
|
||||
assert resolution.evidence[0].freshness.value == "expired"
|
||||
|
||||
|
||||
def test_topology_snapshots_diff_canonical_service_and_dependency_changes(tmp_path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
discovery = DiscoveryStore(store)
|
||||
database = store.create_service(
|
||||
ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db.local", port=5432)
|
||||
)
|
||||
first = discovery.capture_snapshot(run_id=None)
|
||||
store.create_service(
|
||||
ServiceCreate(
|
||||
name="API",
|
||||
kind=ServiceKind.HTTP,
|
||||
url="https://api.local",
|
||||
depends_on=[database.id],
|
||||
)
|
||||
)
|
||||
second = discovery.capture_snapshot(run_id=None)
|
||||
|
||||
diff = discovery.topology_diff(after_snapshot_id=second.id, before_snapshot_id=first.id)
|
||||
|
||||
assert diff.summary["added"] == 2 # API service and its canonical dependency
|
||||
assert {item.entity_type for item in diff.added} == {"service", "relationship"}
|
||||
@@ -0,0 +1,347 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app import observation as observation_module
|
||||
from app import store as store_module
|
||||
from app.docker_discovery import AppOpsInfo, AppOpsInventory, DockerHealthObservation
|
||||
from app.evidence import freshness_for
|
||||
from app.models import (
|
||||
EvidenceProvider,
|
||||
EvidenceScope,
|
||||
HealthEvidence,
|
||||
HealthResolutionReason,
|
||||
HealthStatus,
|
||||
ServiceCreate,
|
||||
ServiceKind,
|
||||
ServiceSnapshot,
|
||||
)
|
||||
from app.observation import ObservationManager, observation_ttl
|
||||
from app.semantic import build_semantic_topology
|
||||
from app.store import Store
|
||||
|
||||
|
||||
def _service(identifier: str, name: str, *, application: str | None = "Northstar", source_ref: str | None = None):
|
||||
return ServiceCreate(name=name, kind=ServiceKind.HTTP, url=f"https://{identifier}.test", application=application, source_ref=source_ref)
|
||||
|
||||
|
||||
def _application_evidence(
|
||||
evidence_id: str,
|
||||
*,
|
||||
status: HealthStatus = HealthStatus.HEALTHY,
|
||||
confidence: float = 0.9,
|
||||
observed_at: datetime | None = None,
|
||||
valid_for: timedelta = timedelta(minutes=3),
|
||||
app_id: str = "northstar",
|
||||
) -> HealthEvidence:
|
||||
now = observed_at or datetime.now(UTC)
|
||||
return HealthEvidence(
|
||||
id=evidence_id,
|
||||
subject_type="application",
|
||||
subject_id=f"appops:{app_id}",
|
||||
provider=EvidenceProvider.APPOPS,
|
||||
provider_ref=app_id,
|
||||
status=status,
|
||||
scope=EvidenceScope.APPLICATION,
|
||||
confidence=confidence,
|
||||
observed_at=now,
|
||||
valid_until=now + valid_for,
|
||||
received_at=now,
|
||||
summary="AppOps aggregate health",
|
||||
)
|
||||
|
||||
|
||||
def _component_evidence(evidence_id: str, service_id: str, status: HealthStatus) -> HealthEvidence:
|
||||
now = datetime.now(UTC)
|
||||
return HealthEvidence(
|
||||
id=evidence_id,
|
||||
subject_type="service",
|
||||
subject_id=service_id,
|
||||
service_id=service_id,
|
||||
provider=EvidenceProvider.DOCKER,
|
||||
provider_ref=service_id,
|
||||
status=status,
|
||||
scope=EvidenceScope.COMPONENT,
|
||||
confidence=0.95,
|
||||
observed_at=now,
|
||||
valid_until=now + timedelta(minutes=3),
|
||||
received_at=now,
|
||||
summary="Docker healthcheck",
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(service, *, direct: HealthStatus, effective: HealthStatus | None = None, evidence: list[HealthEvidence] | None = None) -> ServiceSnapshot:
|
||||
return ServiceSnapshot(
|
||||
service=service,
|
||||
direct_status=direct,
|
||||
dependency_status=effective or direct,
|
||||
effective_status=effective or direct,
|
||||
health_evidence=evidence or [],
|
||||
)
|
||||
|
||||
|
||||
def test_appops_health_resolves_application_without_promoting_component_scope(tmp_path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
api = store.create_service(_service("api", "Northstar API"))
|
||||
worker = store.create_service(_service("worker", "Northstar Worker"))
|
||||
aggregate = _application_evidence("appops-northstar")
|
||||
store.record_health_evidence(aggregate, service_ids=[api.id, worker.id])
|
||||
|
||||
snapshots = [
|
||||
_snapshot(api, direct=HealthStatus.UNKNOWN, evidence=store.health_evidence_for_service(api.id)),
|
||||
_snapshot(worker, direct=HealthStatus.UNKNOWN, evidence=store.health_evidence_for_service(worker.id)),
|
||||
]
|
||||
topology = build_semantic_topology(snapshots, [])
|
||||
application = topology.applications[0]
|
||||
|
||||
assert application.status == HealthStatus.HEALTHY
|
||||
assert application.health_resolution.reason_code == HealthResolutionReason.SELECTED_APPLICATION_EVIDENCE
|
||||
assert application.health_resolution.confidence == 0.9
|
||||
assert all(component.direct_status == HealthStatus.UNKNOWN for component in topology.components)
|
||||
assert all(component.unknown_reason is not None for component in topology.components)
|
||||
|
||||
|
||||
def test_component_failure_overrides_healthy_aggregate_and_exposes_conflict(tmp_path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
database = store.create_service(_service("db", "Northstar PostgreSQL"))
|
||||
api = store.create_service(_service("api", "Northstar API"))
|
||||
aggregate = _application_evidence("appops-northstar")
|
||||
store.record_health_evidence(aggregate, service_ids=[database.id, api.id])
|
||||
store.record_health_evidence(_component_evidence("db-down", database.id, HealthStatus.DOWN))
|
||||
|
||||
topology = build_semantic_topology(
|
||||
[
|
||||
_snapshot(database, direct=HealthStatus.DOWN, evidence=store.health_evidence_for_service(database.id)),
|
||||
_snapshot(api, direct=HealthStatus.UNKNOWN, effective=HealthStatus.DEGRADED, evidence=store.health_evidence_for_service(api.id)),
|
||||
],
|
||||
[],
|
||||
)
|
||||
application = topology.applications[0]
|
||||
|
||||
assert application.status in {HealthStatus.DOWN, HealthStatus.DEGRADED}
|
||||
assert application.health_resolution.reason_code == HealthResolutionReason.COMPONENT_FAILURE
|
||||
assert application.health_resolution.conflict is True
|
||||
|
||||
|
||||
def test_application_and_domain_coverage_treat_partial_unknown_as_coverage_not_failure() -> None:
|
||||
now = datetime.now(UTC)
|
||||
from app.models import Service
|
||||
|
||||
healthy = Service(id="healthy", **_service("healthy", "Known", application="App A").model_dump(), created_at=now, updated_at=now)
|
||||
unknown = Service(id="unknown", **_service("unknown", "Unresolved", application="App A").model_dump(), created_at=now, updated_at=now)
|
||||
topology = build_semantic_topology(
|
||||
[_snapshot(healthy, direct=HealthStatus.HEALTHY), _snapshot(unknown, direct=HealthStatus.UNKNOWN)],
|
||||
[],
|
||||
)
|
||||
application = topology.applications[0]
|
||||
domain = topology.domains[0]
|
||||
|
||||
assert application.status == HealthStatus.HEALTHY
|
||||
assert application.resolved_components == 1
|
||||
assert application.unresolved_components == 1
|
||||
assert application.component_coverage_percent == 50
|
||||
assert domain.status == HealthStatus.HEALTHY
|
||||
assert domain.application_coverage_percent == 100
|
||||
|
||||
|
||||
def test_missing_stale_and_expired_application_evidence_have_structured_reasons() -> None:
|
||||
now = datetime.now(UTC)
|
||||
from app.models import Service
|
||||
|
||||
service = Service(id="app", **_service("app", "No signal").model_dump(), created_at=now, updated_at=now)
|
||||
no_evidence = build_semantic_topology([_snapshot(service, direct=HealthStatus.UNKNOWN)], []).applications[0]
|
||||
assert no_evidence.health_resolution.reason_code == HealthResolutionReason.NO_APPLICATION_EVIDENCE
|
||||
|
||||
stale_evidence = _application_evidence(
|
||||
"stale", observed_at=now - timedelta(minutes=2, seconds=30), valid_for=timedelta(minutes=2)
|
||||
)
|
||||
stale = build_semantic_topology([_snapshot(service, direct=HealthStatus.UNKNOWN, evidence=[stale_evidence])], []).applications[0]
|
||||
assert stale.health_resolution.reason_code == HealthResolutionReason.STALE_EVIDENCE
|
||||
|
||||
expired_evidence = _application_evidence(
|
||||
"expired", observed_at=now - timedelta(minutes=8), valid_for=timedelta(minutes=2)
|
||||
)
|
||||
expired = build_semantic_topology([_snapshot(service, direct=HealthStatus.UNKNOWN, evidence=[expired_evidence])], []).applications[0]
|
||||
assert expired.health_resolution.reason_code == HealthResolutionReason.EXPIRED_EVIDENCE
|
||||
|
||||
|
||||
def test_same_named_postgres_sources_do_not_share_application_or_appops_claim(tmp_path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
first = store.create_service(_service("one", "PostgreSQL one", application="PostgreSQL", source_ref="docker:name:postgres-one"))
|
||||
second = store.create_service(_service("two", "PostgreSQL two", application="PostgreSQL", source_ref="docker:name:postgres-two"))
|
||||
store.record_health_evidence(_application_evidence("first", status=HealthStatus.HEALTHY, app_id="postgres-one"), service_ids=[first.id])
|
||||
store.record_health_evidence(_application_evidence("second", status=HealthStatus.DOWN, app_id="postgres-two"), service_ids=[second.id])
|
||||
|
||||
topology = build_semantic_topology(
|
||||
[
|
||||
_snapshot(first, direct=HealthStatus.UNKNOWN, evidence=store.health_evidence_for_service(first.id)),
|
||||
_snapshot(second, direct=HealthStatus.UNKNOWN, evidence=store.health_evidence_for_service(second.id)),
|
||||
],
|
||||
[],
|
||||
)
|
||||
assert len(topology.applications) == 2
|
||||
assert sorted(item.status for item in topology.applications) == [HealthStatus.DOWN, HealthStatus.HEALTHY]
|
||||
|
||||
|
||||
def test_observation_refresh_renews_evidence_without_duplicates_and_uses_bounded_ttl(tmp_path, monkeypatch) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
service = store.create_service(_service("api", "Northstar API", source_ref="docker:name:northstar-api"))
|
||||
first_now = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
current = {"value": first_now}
|
||||
monkeypatch.setattr(observation_module, "utc_now", lambda: current["value"])
|
||||
monkeypatch.setattr(
|
||||
observation_module,
|
||||
"appops_inventory",
|
||||
lambda _: AppOpsInventory(
|
||||
{"northstar-api": AppOpsInfo("northstar", None, None, "healthy", "Northstar", 0.9)},
|
||||
True,
|
||||
"1 mapping loaded",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
observation_module,
|
||||
"observe_container_health",
|
||||
lambda _: [DockerHealthObservation("abc", "northstar-api", "docker:name:northstar-api", "running", "healthy", True)],
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(store_module.event_bus, "publish", lambda event: events.append(event))
|
||||
manager = ObservationManager(store, interval_seconds=60)
|
||||
first = manager.refresh()
|
||||
events.clear()
|
||||
current["value"] = first_now + timedelta(minutes=20)
|
||||
second = manager.refresh()
|
||||
|
||||
evidence = store.health_evidence_for_service(service.id)
|
||||
active = [item for item in evidence if item.active]
|
||||
assert first.docker_observations == second.docker_observations == 1
|
||||
assert observation_ttl(60) == timedelta(seconds=180)
|
||||
assert len(active) == 2
|
||||
assert all(item.valid_until - item.observed_at == timedelta(seconds=180) for item in active)
|
||||
assert all(freshness_for(item, now=current["value"]).value == "fresh" for item in active)
|
||||
assert store.provider_statuses()[0].valid_until - store.provider_statuses()[0].observed_at == timedelta(seconds=180)
|
||||
assert events == []
|
||||
|
||||
|
||||
def test_appops_warning_is_degraded_application_evidence_only(tmp_path, monkeypatch) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
service = store.create_service(
|
||||
_service("postgres", "PostgreSQL", source_ref="docker:name:postgresql15")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
observation_module,
|
||||
"appops_inventory",
|
||||
lambda _: AppOpsInventory(
|
||||
{"postgresql15": AppOpsInfo("postgres", "database", "critical", "warning", "PostgreSQL", 1.0)},
|
||||
True,
|
||||
"1 mapping loaded",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
observation_module,
|
||||
"observe_container_health",
|
||||
lambda _: [
|
||||
DockerHealthObservation(
|
||||
"abc", "postgresql15", "docker:name:postgresql15", "running", None, False
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = ObservationManager(store, interval_seconds=60).refresh()
|
||||
evidence = store.health_evidence_for_service(service.id)
|
||||
topology = build_semantic_topology(
|
||||
[_snapshot(service, direct=HealthStatus.UNKNOWN, evidence=evidence)], []
|
||||
)
|
||||
|
||||
assert result.appops_observations == 1
|
||||
assert len(evidence) == 1
|
||||
assert evidence[0].scope == EvidenceScope.APPLICATION
|
||||
assert evidence[0].status == HealthStatus.DEGRADED
|
||||
assert topology.applications[0].status == HealthStatus.DEGRADED
|
||||
assert topology.components[0].direct_status == HealthStatus.UNKNOWN
|
||||
|
||||
|
||||
def test_loaded_evidence_reports_expired_freshness(tmp_path) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
service = store.create_service(_service("api", "Northstar API"))
|
||||
old = datetime.now(UTC) - timedelta(minutes=10)
|
||||
store.record_health_evidence(
|
||||
_application_evidence("old-claim", observed_at=old, valid_for=timedelta(minutes=2)),
|
||||
service_ids=[service.id],
|
||||
)
|
||||
|
||||
assert store.health_evidence_for_service(service.id)[0].freshness.value == "expired"
|
||||
assert store.health_evidence_for_services([service.id])[service.id][0].freshness.value == "expired"
|
||||
|
||||
|
||||
def test_empty_broker_inventory_is_unavailable_with_existing_docker_services(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
service = store.create_service(
|
||||
_service("api", "Northstar API", source_ref="docker:name:northstar-api")
|
||||
)
|
||||
store.record_health_evidence(
|
||||
_component_evidence("existing-docker-claim", service.id, HealthStatus.HEALTHY),
|
||||
service_ids=[service.id],
|
||||
)
|
||||
monkeypatch.setenv("SERVICEMAP_DOCKER_PROVIDER", "broker")
|
||||
monkeypatch.setattr(
|
||||
observation_module,
|
||||
"appops_inventory",
|
||||
lambda _: AppOpsInventory({}, False, "AppOps unavailable"),
|
||||
)
|
||||
monkeypatch.setattr(observation_module, "observe_container_health", lambda _: [])
|
||||
|
||||
result = ObservationManager(store, interval_seconds=60).refresh()
|
||||
|
||||
assert result.docker_observations == 0
|
||||
assert next(
|
||||
item for item in store.provider_statuses() if item.provider == EvidenceProvider.DOCKER
|
||||
).status.value == "unavailable"
|
||||
assert [item.id for item in store.health_evidence_for_service(service.id)] == [
|
||||
"existing-docker-claim"
|
||||
]
|
||||
|
||||
|
||||
def test_single_provider_outage_ages_only_its_claim_and_recovers_without_duplicates(tmp_path, monkeypatch) -> None:
|
||||
store = Store(str(tmp_path / "servicemap.db"))
|
||||
service = store.create_service(_service("api", "Northstar API", source_ref="docker:name:northstar-api"))
|
||||
now = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
current = {"value": now}
|
||||
mode = {"available": True}
|
||||
monkeypatch.setattr(observation_module, "utc_now", lambda: current["value"])
|
||||
monkeypatch.setattr(
|
||||
observation_module,
|
||||
"appops_inventory",
|
||||
lambda _: (
|
||||
AppOpsInventory(
|
||||
{"northstar-api": AppOpsInfo("northstar", None, None, "healthy", "Northstar", 0.9)},
|
||||
True,
|
||||
"1 mapping loaded",
|
||||
)
|
||||
if mode["available"]
|
||||
else AppOpsInventory({}, False, "AppOps inventory unavailable: ConnectError")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
observation_module,
|
||||
"observe_container_health",
|
||||
lambda _: [DockerHealthObservation("abc", "northstar-api", "docker:name:northstar-api", "running", "healthy", True)],
|
||||
)
|
||||
manager = ObservationManager(store, interval_seconds=60)
|
||||
manager.refresh()
|
||||
mode["available"] = False
|
||||
current["value"] += timedelta(seconds=60)
|
||||
manager.refresh()
|
||||
|
||||
evidence_during_outage = store.health_evidence_for_service(service.id)
|
||||
assert any(item.provider == EvidenceProvider.APPOPS and freshness_for(item, now=current["value"]).value == "fresh" for item in evidence_during_outage)
|
||||
assert any(item.provider == EvidenceProvider.DOCKER and freshness_for(item, now=current["value"]).value == "fresh" for item in evidence_during_outage)
|
||||
assert next(item for item in store.provider_statuses() if item.provider == EvidenceProvider.APPOPS).status.value == "unavailable"
|
||||
|
||||
mode["available"] = True
|
||||
current["value"] += timedelta(seconds=60)
|
||||
manager.refresh()
|
||||
active = [item for item in store.health_evidence_for_service(service.id) if item.active]
|
||||
assert len(active) == 2
|
||||
assert next(item for item in store.provider_statuses() if item.provider == EvidenceProvider.APPOPS).status.value == "healthy"
|
||||
Reference in new issue
Block a user