Files
ServiceMap-Public/backend/tests/test_discovery_v120.py
T

346 lines
15 KiB
Python

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