Initial curated ServiceMap source from 7a0a61232d05ba9097432abef6f7f4102a64d0b8

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:34:38 +02:00
commit 105251b9c8
96 files changed
+23587

No files matched your search

+182
View File
@@ -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
+69
View File
@@ -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
)
+133
View File
@@ -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")]
+186
View File
@@ -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)
+345
View File
@@ -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
+135
View File
@@ -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"
+244
View File
@@ -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)
+99
View File
@@ -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
+156
View File
@@ -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
+69
View File
@@ -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())
+107
View File
@@ -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
+159
View File
@@ -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"
+127
View File
@@ -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
+132
View File
@@ -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"