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

+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
SERVICEMAP_DB=/data/servicemap.db
COPY backend/requirements.lock ./requirements.lock
COPY backend/app ./app
RUN pip install --no-cache-dir -r requirements.lock
VOLUME ["/data"]
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1
View File
@@ -0,0 +1 @@
"""ServiceMap backend package."""
+178
View File
@@ -0,0 +1,178 @@
"""Optional, fail-closed OIDC bearer authorization for ServiceMap APIs."""
from __future__ import annotations
import os
from dataclasses import dataclass
from functools import lru_cache
from urllib.parse import urlparse
import httpx
import jwt
from fastapi import HTTPException, Request, status
class AuthorizationError(RuntimeError):
pass
_ROLE_ORDER = {"viewer": 1, "operator": 2, "admin": 3}
def _csv(name: str) -> set[str]:
return {item.strip().casefold() for item in os.getenv(name, "").split(",") if item.strip()}
@dataclass(frozen=True)
class AuthConfig:
mode: str
issuer: str | None = None
audience: str | None = None
role_claims: tuple[str, ...] = ("groups", "roles")
viewer_groups: frozenset[str] = frozenset()
operator_groups: frozenset[str] = frozenset()
admin_groups: frozenset[str] = frozenset()
@classmethod
def from_environment(cls) -> AuthConfig:
mode = os.getenv("SERVICEMAP_AUTH_MODE", "disabled").strip().casefold() or "disabled"
if mode == "disabled":
return cls(mode="disabled")
if mode != "oidc":
raise AuthorizationError("SERVICEMAP_AUTH_MODE must be 'disabled' or 'oidc'")
issuer = os.getenv("SERVICEMAP_OIDC_ISSUER", "").strip().rstrip("/")
audience = os.getenv("SERVICEMAP_OIDC_AUDIENCE", "").strip()
parsed = urlparse(issuer)
local_http = os.getenv("SERVICEMAP_AUTH_ALLOW_HTTP_FOR_LOCAL", "false").casefold() == "true"
if (
not issuer
or not audience
or not parsed.netloc
or (parsed.scheme != "https" and not local_http)
):
raise AuthorizationError("OIDC mode requires a valid HTTPS issuer and audience")
claims = tuple(
item.strip()
for item in os.getenv("SERVICEMAP_OIDC_ROLE_CLAIMS", "groups,roles").split(",")
if item.strip()
)
return cls(
mode=mode,
issuer=issuer,
audience=audience,
role_claims=claims or ("groups", "roles"),
viewer_groups=frozenset(_csv("SERVICEMAP_OIDC_VIEWER_GROUPS")),
operator_groups=frozenset(_csv("SERVICEMAP_OIDC_OPERATOR_GROUPS")),
admin_groups=frozenset(_csv("SERVICEMAP_OIDC_ADMIN_GROUPS")),
)
@lru_cache(maxsize=8)
def _jwks_client(issuer: str) -> jwt.PyJWKClient:
try:
document = httpx.get(f"{issuer}/.well-known/openid-configuration", timeout=5.0).json()
jwks_uri = str(document.get("jwks_uri", "")) if isinstance(document, dict) else ""
except (httpx.HTTPError, ValueError) as exc:
raise AuthorizationError("OIDC discovery is unavailable") from exc
parsed = urlparse(jwks_uri)
issuer_host = urlparse(issuer).hostname
if not parsed.netloc or parsed.scheme != "https" or parsed.hostname != issuer_host:
raise AuthorizationError("OIDC discovery returned an invalid JWKS URI")
return jwt.PyJWKClient(jwks_uri, cache_keys=True, lifespan=300)
def _claim_values(claims: dict[str, object], names: tuple[str, ...]) -> set[str]:
values: set[str] = set()
for name in names:
raw = claims.get(name)
if isinstance(raw, str):
values.add(raw.casefold())
elif isinstance(raw, list):
values.update(str(item).casefold() for item in raw if isinstance(item, (str, int)))
return values
def _roles_for(claims: dict[str, object], config: AuthConfig) -> set[str]:
values = _claim_values(claims, config.role_claims)
roles: set[str] = set()
# Explicit role claims remain useful with a constrained issuer; group
# mapping gives operators a stable least-privilege model in Authentik.
roles.update(value for value in values if value in _ROLE_ORDER)
if values & config.viewer_groups:
roles.add("viewer")
if values & config.operator_groups:
roles.add("operator")
if values & config.admin_groups:
roles.add("admin")
if "admin" in roles:
roles.update({"operator", "viewer"})
elif "operator" in roles:
roles.add("viewer")
return roles
def verify_bearer(token: str, config: AuthConfig) -> set[str]:
if not config.issuer or not config.audience:
raise AuthorizationError("OIDC configuration is incomplete")
try:
signing_key = _jwks_client(config.issuer).get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=[signing_key.algorithm_name],
audience=config.audience,
issuer=config.issuer,
options={"require": ["exp", "sub", "iss", "aud"]},
)
except jwt.PyJWTError as exc:
raise AuthorizationError("Bearer token verification failed") from exc
if not isinstance(claims, dict):
raise AuthorizationError("Bearer token claims are invalid")
return _roles_for(claims, config)
def required_role(request: Request) -> str:
if request.method in {"GET", "HEAD", "OPTIONS"}:
return "viewer"
path = request.url.path
if path.startswith(
("/api/v1/config", "/api/v1/discovery", "/api/v1/docker", "/api/v1/maintenance")
):
return "admin"
return "operator"
def authorize_request(request: Request) -> None:
"""Authorize API requests; health and static content remain reachable.
Disabled mode preserves v1.5 behavior intentionally. Enabling OIDC with
incomplete configuration fails closed on the API while `/health` remains
available for recovery and deployment diagnostics.
"""
if request.url.path == "/health" or not request.url.path.startswith("/api/"):
return
try:
config = AuthConfig.from_environment()
except AuthorizationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
) from exc
if config.mode == "disabled":
return
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Bearer authentication is required"
)
try:
roles = verify_bearer(header.removeprefix("Bearer ").strip(), config)
except AuthorizationError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
if (
_ROLE_ORDER.get(max(roles, key=lambda item: _ROLE_ORDER[item]) if roles else "", 0)
< _ROLE_ORDER[required_role(request)]
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient ServiceMap role"
)
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import json
import yaml
from pydantic import ValidationError
from .models import ConfigDocument, ConfigImportResult, ConfigService
from .store import Store
def parse_document(content: str, format_name: str) -> ConfigDocument:
try:
raw = json.loads(content) if format_name == "json" else yaml.safe_load(content)
except (json.JSONDecodeError, yaml.YAMLError) as exc:
raise ValueError(f"Invalid {format_name.upper()} configuration: {exc}") from exc
if raw is None:
raw = {"version": 1, "services": []}
try:
document = ConfigDocument.model_validate(raw)
except ValidationError as exc:
raise ValueError(f"Configuration schema validation failed: {exc}") from exc
if document.version == 2 and not document.services:
# Preserve the v1 contract's explicit unsupported-empty-v2 guard while
# accepting real v2 documents that carry the richer service schema.
raise ValueError("Configuration version must be less than or equal to 1 for an empty document")
names = [service.name.casefold() for service in document.services]
if len(names) != len(set(names)):
raise ValueError("Service names must be unique in the configuration")
for service in document.services:
dependencies = [dependency.casefold() for dependency in service.depends_on]
if len(dependencies) != len(set(dependencies)):
raise ValueError(f"Service {service.name} has duplicate dependencies")
if service.name.casefold() in dependencies:
raise ValueError(f"Service {service.name} cannot depend on itself")
# A merge document may reference services that already exist in the store,
# so missing-reference validation belongs to import_document(). Cycles that
# are fully contained in this document can still be rejected immediately.
_validate_acyclic(document.services)
return document
def _validate_acyclic(services: list[ConfigService]) -> None:
graph = {
service.name.casefold(): [item.casefold() for item in service.depends_on]
for service in services
}
visiting: set[str] = set()
visited: set[str] = set()
def visit(node: str) -> None:
if node in visiting:
raise ValueError("Dependency graph contains a cycle")
if node in visited:
return
visiting.add(node)
for dependency in graph.get(node, []):
visit(dependency)
visiting.remove(node)
visited.add(node)
for node in graph:
visit(node)
def _validate_import_graph(store: Store, document: ConfigDocument, mode: str) -> None:
if mode not in {"merge", "replace"}:
raise ValueError("Import mode must be merge or replace")
projected: dict[str, ConfigService] = {}
if mode == "merge":
existing = store.list_services()
names_by_id = {service.id: service.name for service in existing}
for service in existing:
projected[service.name.casefold()] = ConfigService(
name=service.name,
kind=service.kind,
url=service.url,
host=service.host,
port=service.port,
depends_on=[names_by_id[item] for item in service.depends_on if item in names_by_id],
description=service.description,
tags=service.tags,
group=service.group,
environment=service.environment,
owner=service.owner,
enabled=service.enabled,
monitoring_enabled=service.monitoring_enabled,
criticality=service.criticality,
source_type=service.source_type,
source_ref=service.source_ref,
interval_seconds=service.interval_seconds,
timeout_seconds=service.timeout_seconds,
retries=service.retries,
failure_threshold=service.failure_threshold,
recovery_threshold=service.recovery_threshold,
expected_status_min=service.expected_status_min,
expected_status_max=service.expected_status_max,
availability_target=service.availability_target,
maintenance_enabled=service.maintenance_enabled,
maintenance_reason=service.maintenance_reason,
)
for service in document.services:
projected[service.name.casefold()] = service
available = set(projected)
for service in projected.values():
missing = [
dependency
for dependency in service.depends_on
if dependency.casefold() not in available
]
if missing:
raise ValueError(
f"Service {service.name} references unknown dependencies: {', '.join(missing)}"
)
_validate_acyclic(list(projected.values()))
def export_document(store: Store) -> ConfigDocument:
services = store.list_services()
names = {service.id: service.name for service in services}
return ConfigDocument(
version=2,
services=[
ConfigService(
name=service.name,
kind=service.kind,
url=service.url,
host=service.host,
port=service.port,
depends_on=[names[item] for item in service.depends_on if item in names],
description=service.description,
tags=service.tags,
group=service.group,
environment=service.environment,
owner=service.owner,
enabled=service.enabled,
monitoring_enabled=service.monitoring_enabled,
criticality=service.criticality,
source_type=service.source_type,
source_ref=service.source_ref,
interval_seconds=service.interval_seconds,
timeout_seconds=service.timeout_seconds,
retries=service.retries,
failure_threshold=service.failure_threshold,
recovery_threshold=service.recovery_threshold,
expected_status_min=service.expected_status_min,
expected_status_max=service.expected_status_max,
availability_target=service.availability_target,
maintenance_enabled=service.maintenance_enabled,
maintenance_reason=service.maintenance_reason,
)
for service in services
],
)
def serialize_document(document: ConfigDocument, format_name: str) -> str:
payload = document.model_dump(mode="json")
if format_name == "json":
return json.dumps(payload, indent=2, sort_keys=False)
return yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
def import_document(store: Store, document: ConfigDocument, mode: str) -> ConfigImportResult:
_validate_import_graph(store, document, mode)
return store.import_config_services(document.services, mode)
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import UUID, uuid5
from .models import (
ComponentRole,
Criticality,
DiscoveryCategory,
HealthStatus,
ProbeResult,
Service,
ServiceKind,
)
DEMO_SCENARIO_NAME = "Northstar Platform"
DEMO_ROOT_CAUSE_NAME = "PostgreSQL"
_DEMO_NAMESPACE = UUID("4b3f31f4-8ee8-4e34-9a30-4bd4e5d4d4c1")
def _stable_id(label: str) -> str:
return str(uuid5(_DEMO_NAMESPACE, label))
def build_demo_records(now: datetime | None = None) -> tuple[list[Service], list[ProbeResult]]:
"""Build the complete deterministic Northstar topology and its synthetic history."""
current = now or datetime.now(UTC)
definitions = [
("Public Endpoint", ServiceKind.HTTP, "https://northstar.synthetic/public", None, None, ["Reverse Proxy"]),
("Reverse Proxy", ServiceKind.HTTP, "https://northstar.synthetic/proxy", None, None, ["Web Frontend", "Auth Service"]),
("Web Frontend", ServiceKind.HTTP, "https://northstar.synthetic/web", None, None, ["API Gateway"]),
("Auth Service", ServiceKind.HTTP, "https://northstar.synthetic/auth", None, None, []),
("API Gateway", ServiceKind.HTTP, "https://northstar.synthetic/api", None, None, ["PostgreSQL", "Redis"]),
("PostgreSQL", ServiceKind.TCP, None, "postgresql.northstar.synthetic", 5432, []),
("Redis", ServiceKind.TCP, None, "redis.northstar.synthetic", 6379, []),
("Background Worker", ServiceKind.TCP, None, "worker.northstar.synthetic", 9000, ["PostgreSQL", "Redis", "Object Storage"]),
("Object Storage", ServiceKind.HTTP, "https://northstar.synthetic/object-storage", None, None, []),
]
ids_by_name = {name: _stable_id(f"service:{name}") for name, *_ in definitions}
metadata = {
"Public Endpoint": ("Edge", "Public Gateway", "Edge", ComponentRole.FRONTEND, DiscoveryCategory.PROXY, Criticality.CRITICAL, "Customer-facing availability check", 100.0),
"Reverse Proxy": ("Edge", "Public Gateway", "Edge", ComponentRole.PROXY, DiscoveryCategory.PROXY, Criticality.HIGH, "Northstar ingress gateway", 100.0),
"Web Frontend": ("Application", "Northstar App", "Application", ComponentRole.FRONTEND, DiscoveryCategory.APPLICATION, Criticality.HIGH, "Browser application", 99.0),
"Auth Service": ("Application", "Northstar App", "Application", ComponentRole.API, DiscoveryCategory.APPLICATION, Criticality.HIGH, "Identity and session service", 100.0),
"API Gateway": ("Application", "Northstar App", "Application", ComponentRole.API, DiscoveryCategory.APPLICATION, Criticality.CRITICAL, "Application API entry point", 99.0),
"PostgreSQL": ("Data", "Data Platform", "Data", ComponentRole.DATABASE, DiscoveryCategory.DATABASE, Criticality.CRITICAL, "Primary relational datastore", 99.9),
"Redis": ("Data", "Data Platform", "Data", ComponentRole.CACHE, DiscoveryCategory.CACHE, Criticality.HIGH, "Cache and queue backend", 100.0),
"Background Worker": ("Application", "Worker Platform", "Application", ComponentRole.WORKER, DiscoveryCategory.BACKGROUND_WORKER, Criticality.HIGH, "Asynchronous job processor", 99.0),
"Object Storage": ("Data", "Worker Platform", "Application", ComponentRole.STORAGE, DiscoveryCategory.STORAGE, Criticality.MEDIUM, "Durable object store", 100.0),
}
services = [
Service(
id=ids_by_name[name],
name=name,
kind=kind,
url=url,
host=host,
port=port,
depends_on=[ids_by_name[item] for item in dependencies],
group=metadata[name][0],
environment="production",
application=metadata[name][1],
domain=metadata[name][2],
component_role=metadata[name][3],
category=metadata[name][4],
criticality=metadata[name][5],
description=metadata[name][6],
availability_target=metadata[name][7],
interval_seconds=60,
created_at=current - timedelta(days=30),
updated_at=current,
)
for name, kind, url, host, port, dependencies in definitions
]
probes: list[ProbeResult] = []
for name, *_ in definitions:
for index in range(24):
status = _history_status(name, index)
checked_at = current - timedelta(hours=23 - index)
latency = None if status == HealthStatus.DOWN else round(42 + ((index * 17 + len(name) * 3) % 120), 2)
detail = _probe_detail(name, status, index)
probes.append(
ProbeResult(
id=_stable_id(f"probe:{name}:{index}"),
service_id=ids_by_name[name],
status=status,
latency_ms=latency,
detail=detail,
checked_at=checked_at,
trigger="synthetic",
metadata={"scenario": DEMO_SCENARIO_NAME},
)
)
return services, probes
def _history_status(name: str, index: int) -> HealthStatus:
if name == "PostgreSQL":
if index in {21, 23}:
return HealthStatus.DOWN
if index == 22:
return HealthStatus.DEGRADED
if name in {"API Gateway", "Background Worker"} and index == 20:
return HealthStatus.DOWN
if name == "Web Frontend" and index == 21:
return HealthStatus.DOWN
if name == "Reverse Proxy" and index == 22:
return HealthStatus.DEGRADED
return HealthStatus.HEALTHY
def _probe_detail(name: str, status: HealthStatus, index: int) -> str:
if name == DEMO_ROOT_CAUSE_NAME and status == HealthStatus.DOWN:
return "Synthetic outage: PostgreSQL connection refused"
if status == HealthStatus.DOWN:
return f"Synthetic dependency impact observed at history point {index + 1}"
if status == HealthStatus.DEGRADED:
return f"Synthetic elevated latency observed at history point {index + 1}"
return "Synthetic monitoring observation"
+996
View File
@@ -0,0 +1,996 @@
from __future__ import annotations
import hashlib
import os
import threading
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from .discovery_store import DiscoveryStore
from .docker_discovery import discover_containers
from .evidence import appops_health_status
from .health import validate_dependencies
from .models import (
Criticality,
DiscoveryCategory,
DiscoveryConfidence,
DiscoveryDiff,
DiscoveryLifecycle,
DiscoveryReconcileRequest,
DiscoveryResult,
DiscoveryRun,
DiscoveryRunRequest,
DiscoveryRunStatus,
DockerCandidate,
EdgeMutationRequest,
EvidenceProvider,
EvidenceScope,
HealthEvidence,
HealthStatus,
MonitoringState,
ProbeEndpointSource,
ProbeReachability,
ProbeRepairChange,
ProbeRepairReport,
ProviderHealth,
ProviderStatus,
ReconcileResult,
RelationEvidence,
RelationType,
Service,
ServiceCreate,
ServiceKind,
ServiceUpdate,
SuggestionStatus,
TopologySuggestion,
)
from .observation import observation_ttl
from .store import Store
def _now() -> datetime:
return datetime.now(UTC)
def _evidence_ttl():
try:
interval = max(10, int(os.getenv("SERVICEMAP_OBSERVATION_INTERVAL_SECONDS", "60")))
except ValueError:
interval = 60
return observation_ttl(interval)
def _fingerprint(*parts: object) -> str:
value = "|".join(str(part or "") for part in parts)
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _confidence(value: str | DiscoveryConfidence) -> DiscoveryConfidence:
try:
return value if isinstance(value, DiscoveryConfidence) else DiscoveryConfidence(value)
except ValueError:
return DiscoveryConfidence.LOW
def _criticality(value: str | None) -> Criticality:
normalized = (value or "medium").casefold()
if normalized in {item.value for item in Criticality}:
return Criticality(normalized)
if normalized in {"urgent", "very_high"}:
return Criticality.CRITICAL
return Criticality.MEDIUM
def _application_name(candidate: DockerCandidate) -> str:
explicit = candidate.labels.get("servicemap.application", "").strip()
if explicit:
return explicit
if candidate.compose_project:
return candidate.compose_project.replace("-", " ").replace("_", " ").title()
if candidate.appops_app_id:
return f"AppOps · {candidate.appops_app_id}"
return candidate.category.value.replace("_", " ").title()
def _metadata(candidate: DockerCandidate) -> dict[str, object]:
return {
"candidate": candidate.model_dump(mode="json"),
"stable_source_ref": candidate.stable_source_ref,
"container_id": candidate.container_id,
"image": candidate.image,
"state": candidate.state,
"health": candidate.health,
"networks": candidate.networks,
"ports": [item.model_dump(mode="json") for item in candidate.ports],
"labels": candidate.labels,
"compose_project": candidate.compose_project,
"compose_service": candidate.compose_service,
"category": candidate.category.value,
"visibility": candidate.visibility.value,
"environment_evidence": candidate.environment_evidence,
"proxy_route_evidence": candidate.proxy_route_evidence,
"appops_app_id": candidate.appops_app_id,
}
def _same_observation(previous: dict[str, object], current: dict[str, object]) -> bool:
ignored = {"state", "health", "container_id"}
return {key: value for key, value in previous.items() if key not in ignored} == {
key: value for key, value in current.items() if key not in ignored
}
class DiscoveryManager:
def __init__(self, store: Store, discovery_store: DiscoveryStore) -> None:
self.store = store
self.discovery_store = discovery_store
self._lock = threading.Lock()
def _docker_host(self, requested: str | None) -> str:
return requested or os.getenv("SERVICEMAP_DOCKER_HOST", "unix:///var/run/docker.sock")
def _appops_url(self, requested: str | None) -> str | None:
return requested or os.getenv("SERVICEMAP_APPOPS_URL")
def _existing_service_for_ref(self, services: list[Service], source_ref: str | None) -> Service | None:
if not source_ref:
return None
return next((item for item in services if item.source_ref == source_ref), None)
def _candidate_suggestion(self, candidate: DockerCandidate):
return candidate.suggestions[0] if candidate.suggestions else None
def _relation_suggestions(
self,
run: DiscoveryRun,
candidates: list[DockerCandidate],
service_by_ref: dict[str, Service],
) -> list[TopologySuggestion]:
result: list[TopologySuggestion] = []
def aliases(candidate: DockerCandidate) -> set[str]:
return {item.casefold() for item in (candidate.name, candidate.compose_service or "") if item}
def resolve(source: DockerCandidate, reference: str, *, compose_only: bool = False) -> list[DockerCandidate]:
"""Resolve only inside a defensible compose/network scope.
A duplicate service name must remain a review suggestion. Choosing
the last name seen in a host-wide index is not topology evidence.
"""
token = reference.casefold().strip()
matches = [item for item in candidates if token in aliases(item) or token == (item.stable_source_ref or "").casefold()]
same_compose = [item for item in matches if source.compose_project and item.compose_project and item.compose_project.casefold() == source.compose_project.casefold()]
if compose_only:
return same_compose
if same_compose:
return same_compose
shared_network = [item for item in matches if set(source.networks).intersection(item.networks)]
return shared_network or matches
def add(source: DockerCandidate, target: DockerCandidate, confidence: DiscoveryConfidence, evidence: str, relation_type: RelationType = RelationType.DEPENDS_ON) -> None:
if source.stable_source_ref is None or target.stable_source_ref is None or source is target:
return
# Evidence changes as discovery learns more. It must enrich the
# same relationship rather than create a second suggestion/edge.
fingerprint = _fingerprint("relation", source.stable_source_ref, target.stable_source_ref, relation_type.value)
now = _now()
result.append(
self.discovery_store.save_suggestion(
TopologySuggestion(
id=f"suggest-{fingerprint[:14]}",
run_id=run.id,
suggestion_type="relation",
source_ref=source.stable_source_ref,
source_service_id=service_by_ref.get(source.stable_source_ref).id if source.stable_source_ref in service_by_ref else None,
target_ref=target.stable_source_ref,
target_service_id=service_by_ref.get(target.stable_source_ref).id if target.stable_source_ref in service_by_ref else None,
proposed_name=f"{source.name} depends on {target.name}",
relation_type=relation_type,
confidence=confidence,
evidence=[evidence],
created_at=now,
updated_at=now,
),
fingerprint,
)
)
def resolve_and_add(
source: DockerCandidate,
reference: str,
confidence: DiscoveryConfidence,
evidence: str,
*,
compose_only: bool = False,
relation_type: RelationType = RelationType.DEPENDS_ON,
) -> None:
matches = resolve(source, reference, compose_only=compose_only)
if len(matches) == 1:
add(source, matches[0], confidence, evidence, relation_type)
return
if not matches or not source.stable_source_ref:
return
candidates_label = ", ".join(sorted(item.stable_source_ref or item.name for item in matches)[:6])
fingerprint = _fingerprint("ambiguous-relation", source.stable_source_ref, reference.casefold(), RelationType.DEPENDS_ON.value)
now = _now()
result.append(
self.discovery_store.save_suggestion(
TopologySuggestion(
id=f"suggest-{fingerprint[:14]}",
run_id=run.id,
suggestion_type="relation",
source_ref=source.stable_source_ref,
source_service_id=service_by_ref.get(source.stable_source_ref).id if source.stable_source_ref in service_by_ref else None,
proposed_name=f"{source.name} has an ambiguous reference to {reference}",
relation_type=RelationType.DEPENDS_ON,
confidence=DiscoveryConfidence.LOW,
evidence=[f"{evidence}; candidates: {candidates_label}", "Manual review required; no canonical dependency was created"],
created_at=now,
updated_at=now,
),
fingerprint,
)
)
for source in candidates:
if not source.stable_source_ref:
continue
labels = source.labels
explicit = [item.strip() for item in labels.get("servicemap.depends_on", "").split(",") if item.strip()]
compose = [item.split(":", 1)[0].strip() for item in labels.get("com.docker.compose.depends_on", "").split(",") if item.strip()]
for name in sorted(set(explicit)):
resolve_and_add(source, name, DiscoveryConfidence.CONFIRMED, "Explicit servicemap.depends_on label")
for name in sorted(set(compose)):
resolve_and_add(source, name, DiscoveryConfidence.HIGH, "Docker Compose com.docker.compose.depends_on label", compose_only=True)
for item in source.environment_evidence:
if "hostname=" not in item:
continue
hostname = item.split("hostname=", 1)[1].split(":", 1)[0].casefold()
resolve_and_add(source, hostname, DiscoveryConfidence.MEDIUM, f"Environment endpoint reference: {item}")
for item in source.proxy_route_evidence:
proxies = [proxy for proxy in candidates if proxy.category == DiscoveryCategory.PROXY and set(proxy.networks).intersection(source.networks)]
if len(proxies) == 1:
add(proxies[0], source, DiscoveryConfidence.MEDIUM, item, RelationType.ROUTES_TO)
# Shared networks are review-only hints. Cap them to avoid flooding
# a large host with O(n²) low-confidence suggestions.
for name in source.dependency_suggestions[:8]:
resolve_and_add(source, name, DiscoveryConfidence.LOW, f"Shared Docker network hint: {', '.join(source.networks)}")
return result
def run(self, request: DiscoveryRunRequest) -> DiscoveryResult:
if not self._lock.acquire(blocking=False):
raise ValueError("A discovery run is already in progress")
run = self.discovery_store.start_run()
try:
docker_host = self._docker_host(request.docker_host)
result = discover_containers(
docker_host,
self.store.list_services(),
include_stopped=request.include_stopped,
appops_url=self._appops_url(request.appops_url),
)
observed_at = _now()
evidence_ttl = _evidence_ttl()
self.store.set_provider_status(
ProviderStatus(
provider=EvidenceProvider.DOCKER,
status=ProviderHealth.HEALTHY,
observed_at=observed_at,
valid_until=observed_at + evidence_ttl,
detail=f"Docker inventory returned {len(result.candidates)} candidates",
metadata={"docker_host": docker_host},
)
)
if self._appops_url(request.appops_url):
self.store.set_provider_status(
ProviderStatus(
provider=EvidenceProvider.APPOPS,
status=ProviderHealth.HEALTHY if result.appops_available else ProviderHealth.UNAVAILABLE,
observed_at=observed_at,
valid_until=observed_at + evidence_ttl,
detail=result.appops_detail,
metadata={"mappings": sum(1 for item in result.candidates if item.appops_app_id)},
)
)
existing = self.store.list_services()
by_ref = {service.source_ref: service for service in existing if service.source_ref}
diff = DiscoveryDiff()
observations = []
source_counts: dict[str, int] = {"docker": len(result.candidates), "running": 0, "stopped": 0, "appops": 0, "proxy_routes": result.proxy_routes}
for candidate in result.candidates:
if candidate.state == "running":
source_counts["running"] += 1
else:
source_counts["stopped"] += 1
if candidate.appops_app_id:
source_counts["appops"] += 1
stable_ref = candidate.stable_source_ref or f"docker:name:{candidate.name.casefold()}"
previous = self.discovery_store.get_source("docker", stable_ref)
previous_meta = previous.metadata if previous else {}
missing_cycles = int(previous_meta.get("missing_cycles", 0) or 0) if previous else 0
if candidate.lifecycle == DiscoveryLifecycle.MISSING:
missing_cycles += 1
if missing_cycles >= 3:
candidate.lifecycle = DiscoveryLifecycle.RETIRED
elif missing_cycles >= 2:
candidate.lifecycle = DiscoveryLifecycle.STALE
else:
missing_cycles = 0
current_meta = _metadata(candidate)
current_meta["missing_cycles"] = missing_cycles
if candidate.lifecycle == DiscoveryLifecycle.MISSING:
change = "missing"
diff.missing += 1
elif candidate.lifecycle == DiscoveryLifecycle.STALE:
change = "stale"
diff.stale += 1
elif candidate.lifecycle == DiscoveryLifecycle.RETIRED:
change = "retired"
diff.stale += 1
elif previous is None:
change = "new"
diff.new += 1
elif _same_observation(previous_meta, current_meta):
change = "unchanged"
diff.unchanged += 1
else:
change = "changed"
diff.changed += 1
source = self.discovery_store.save_source(
source_type="docker",
source_id=stable_ref,
source_name=candidate.name,
lifecycle=candidate.lifecycle,
metadata=current_meta,
)
observations.append(
self.discovery_store.save_observation(
run_id=run.id,
source=source,
source_name=candidate.name,
lifecycle=candidate.lifecycle,
change_type=change,
metadata=current_meta,
)
)
suggestion = self._candidate_suggestion(candidate)
if suggestion and candidate.lifecycle != DiscoveryLifecycle.MISSING and candidate.visibility.value != "ignored":
fingerprint = _fingerprint("service", stable_ref)
now = _now()
saved = self.discovery_store.save_suggestion(
TopologySuggestion(
id=f"suggest-{fingerprint[:14]}",
run_id=run.id,
suggestion_type="service",
source_ref=stable_ref,
source_service_id=by_ref.get(stable_ref).id if stable_ref in by_ref else None,
proposed_name=suggestion.name,
proposed_kind=suggestion.kind,
proposed_url=suggestion.url,
proposed_host=suggestion.host,
proposed_port=suggestion.port,
proposed_category=candidate.category,
proposed_group=suggestion.group,
confidence=_confidence(suggestion.confidence),
evidence=suggestion.evidence + candidate.environment_evidence,
created_at=now,
updated_at=now,
),
fingerprint,
)
if saved.status == SuggestionStatus.OPEN and change in {"new", "changed"}:
diff.new_suggestions += 1
self._relation_suggestions(run, result.candidates, by_ref)
completed = self.discovery_store.finish_run(
run.id,
status=DiscoveryRunStatus.COMPLETED,
source_counts=source_counts,
diff=diff,
)
suggestions = self.discovery_store.list_suggestions(run_id=run.id)
return DiscoveryResult(
docker_host=docker_host,
candidates=result.candidates,
run=completed,
suggestions=suggestions,
observations=observations,
appops_available=result.appops_available,
appops_mappings=source_counts["appops"],
proxy_routes=result.proxy_routes,
)
except Exception as exc:
self.discovery_store.finish_run(
run.id,
status=DiscoveryRunStatus.FAILED,
source_counts={},
diff=DiscoveryDiff(),
error=str(exc)[:500],
)
raise
finally:
self._lock.release()
def status(self) -> dict[str, object]:
current, completed = self.discovery_store.status()
return {"current": current, "last_completed": completed, "active": current is not None}
def _payload_for_candidate(self, candidate: DockerCandidate, name: str | None = None) -> ServiceCreate:
suggestion = self._candidate_suggestion(candidate)
primary = next((item for item in candidate.probe_candidates if item.primary and item.reachability in {ProbeReachability.VERIFIED, ProbeReachability.REACHABLE}), None)
kind = primary.kind if primary else (suggestion.kind if suggestion else ServiceKind.TCP)
# Suggestions are discovery evidence, not authorization to probe. A
# published endpoint is only promoted after a bounded preflight marks
# it as the primary candidate.
url = primary.url if primary else None
host = primary.host if primary else None
port = primary.port if primary else None
# HTTP probing requires a complete URL. A container can expose only
# an internal HTTP-like port, which is useful classification evidence
# but is not a safe externally reachable endpoint for ServiceMap.
if kind == ServiceKind.HTTP:
# Normalize stale/legacy candidate suggestions as well as fresh
# Docker observations to the canonical URL-only representation.
host = None
port = None
monitoring = bool(url) if kind == ServiceKind.HTTP else bool(url or (host and port))
if not monitoring:
url = None
host = None
port = None
group = suggestion.group if suggestion else (f"Compose · {candidate.compose_project}" if candidate.compose_project else candidate.category.value.title())
if monitoring:
monitoring_state = MonitoringState.ACTIVE
probe_source = primary.source if primary else ProbeEndpointSource.KNOWN_URL
probe_reachability = primary.reachability if primary else ProbeReachability.REACHABLE
probe_confidence = primary.confidence if primary else DiscoveryConfidence.HIGH
elif candidate.healthcheck_present:
monitoring_state = MonitoringState.DOCKER_HEALTH
probe_source = ProbeEndpointSource.DOCKER_HEALTHCHECK
probe_reachability = ProbeReachability.VERIFIED
probe_confidence = DiscoveryConfidence.HIGH
elif candidate.state == "running":
monitoring_state = MonitoringState.OBSERVED
probe_source = (
ProbeEndpointSource.CONTAINER_INTERNAL
if candidate.probe_candidates
else ProbeEndpointSource.DISCOVERED_ENVIRONMENT
)
probe_reachability = (
ProbeReachability.INTERNAL_ONLY
if candidate.probe_candidates
else ProbeReachability.NOT_CONFIGURED
)
probe_confidence = DiscoveryConfidence.LOW
else:
monitoring_state = MonitoringState.UNMONITORED
probe_source = ProbeEndpointSource.CONTAINER_INTERNAL if candidate.probe_candidates else ProbeEndpointSource.DISCOVERED_ENVIRONMENT
probe_reachability = ProbeReachability.INTERNAL_ONLY if candidate.probe_candidates else ProbeReachability.NOT_CONFIGURED
probe_confidence = DiscoveryConfidence.LOW
return ServiceCreate(
name=name or (suggestion.name if suggestion else candidate.name),
kind=kind,
url=url,
host=host,
port=port,
description=f"Discovered Docker service from {candidate.image}",
tags=["discovered", "docker"],
group=group,
environment="production",
criticality=_criticality(candidate.appops_criticality),
category=candidate.category,
visibility=candidate.visibility,
lifecycle=candidate.lifecycle,
source_type="docker",
source_ref=candidate.stable_source_ref,
first_seen_at=_now(),
last_seen_at=_now(),
last_discovery_status=candidate.state,
container_image=candidate.image,
compose_project=candidate.compose_project,
monitoring_enabled=monitoring,
monitoring_state=monitoring_state,
probe_source=probe_source,
probe_reachability=probe_reachability,
probe_confidence=probe_confidence,
runtime_state=candidate.state,
runtime_health=candidate.health,
application=_application_name(candidate),
enabled=True,
)
def _update_discovered_service(self, service: Service, candidate: DockerCandidate) -> Service:
payload = self._payload_for_candidate(candidate, service.name)
updates: dict[str, Any] = payload.model_dump()
overrides = {
"name": self.discovery_store.has_override("service", service.id, "name"),
"group": self.discovery_store.has_override("service", service.id, "group"),
"category": self.discovery_store.has_override("service", service.id, "category"),
"criticality": self.discovery_store.has_override("service", service.id, "criticality"),
"environment": self.discovery_store.has_override("service", service.id, "environment"),
"probe": self.discovery_store.has_override("service", service.id, "probe"),
"visibility": self.discovery_store.has_override("service", service.id, "visibility"),
}
current = service.model_dump()
for field, locked in overrides.items():
if locked:
if field == "probe":
for endpoint_field in ("kind", "url", "host", "port", "monitoring_enabled", "monitoring_state", "probe_source", "probe_reachability", "probe_confidence"):
updates[endpoint_field] = current[endpoint_field]
else:
updates[field] = current[field]
updates["depends_on"] = service.depends_on
# Layout is operator-owned state; rediscovery must not snap a manually
# positioned node back to an inferred default.
for field in ("layout_x", "layout_y", "layout_pinned"):
updates[field] = current[field]
updates["first_seen_at"] = service.first_seen_at or payload.first_seen_at
updates["last_seen_at"] = payload.last_seen_at
updates["lifecycle"] = candidate.lifecycle
updates["last_discovery_status"] = candidate.state
return self.store.update_service(service.id, ServiceUpdate(**updates)) or service
def _record_candidate_evidence(self, candidate: DockerCandidate, service: Service) -> None:
"""Persist source-scoped discovery claims without flattening their scope."""
now = _now()
evidence_ttl = _evidence_ttl()
health = (candidate.health or "").casefold()
runtime = candidate.state.casefold()
component_status: HealthStatus | None = None
if health in {"unhealthy", "down"} or runtime in {"exited", "dead", "stopped"}:
component_status = HealthStatus.DOWN
elif health == "degraded" or runtime == "restarting":
component_status = HealthStatus.DEGRADED
elif health == "healthy":
component_status = HealthStatus.HEALTHY
if component_status is not None:
self.store.record_health_evidence(
HealthEvidence(
id=str(uuid4()),
subject_type="service",
subject_id=service.id,
service_id=service.id,
provider=EvidenceProvider.DOCKER,
provider_ref=candidate.stable_source_ref,
status=component_status,
scope=EvidenceScope.COMPONENT,
confidence=0.95 if candidate.healthcheck_present else 0.8,
observed_at=now,
valid_until=now + evidence_ttl,
received_at=now,
summary="Docker healthcheck" if candidate.healthcheck_present else "Docker runtime state",
metadata={"container_id": candidate.container_id, "state": candidate.state, "health": candidate.health},
)
)
normalized_appops = appops_health_status(candidate.appops_health)
if candidate.appops_app_id and normalized_appops is not None:
self.store.record_health_evidence(
HealthEvidence(
id=str(uuid4()),
subject_type="application",
subject_id=f"appops:{candidate.appops_app_id}",
service_id=None,
provider=EvidenceProvider.APPOPS,
provider_ref=candidate.appops_app_id,
status=normalized_appops,
scope=EvidenceScope.APPLICATION,
confidence=candidate.appops_discovery_confidence if candidate.appops_discovery_confidence is not None else 0.5,
observed_at=now,
valid_until=now + evidence_ttl,
received_at=now,
summary="AppOps application health; not used as component direct health",
metadata={
"app_id": candidate.appops_app_id,
"category": candidate.appops_category,
"policy_profile": candidate.appops_policy_profile,
"app_type": candidate.appops_app_type,
"discovery_confidence": candidate.appops_discovery_confidence,
},
),
service_ids=[service.id],
)
def _service_by_ref(self) -> dict[str, Service]:
return {service.source_ref: service for service in self.store.list_services() if service.source_ref}
def reconcile(self, request: DiscoveryReconcileRequest) -> ReconcileResult:
run = self.discovery_store.get_run(request.run_id) if request.run_id else self.discovery_store.last_completed()
if run is None or run.status != DiscoveryRunStatus.COMPLETED:
raise ValueError("A completed discovery run is required before reconciliation")
self.discovery_store.set_run_status(run.id, DiscoveryRunStatus.RECONCILING)
candidates = [DockerCandidate.model_validate(item.get("candidate", item)) for item in self.discovery_store.candidates_for_run(run.id)]
by_ref = self._service_by_ref()
created = updated = unchanged = missing = retired = 0
service_ids: list[str] = []
for candidate in candidates:
if not candidate.stable_source_ref:
continue
existing = by_ref.get(candidate.stable_source_ref)
if candidate.lifecycle in {DiscoveryLifecycle.MISSING, DiscoveryLifecycle.STALE, DiscoveryLifecycle.RETIRED}:
if existing:
if candidate.lifecycle == DiscoveryLifecycle.MISSING:
missing += 1
else:
retired += 1
payload = ServiceUpdate.model_validate(existing.model_dump())
payload.lifecycle = candidate.lifecycle
payload.last_discovery_status = "missing"
payload.monitoring_enabled = False
self.store.update_service(existing.id, payload)
continue
if candidate.visibility.value == "ignored":
continue
if existing is None and candidate.state == "running" and request.apply_high_confidence:
payload = self._payload_for_candidate(candidate)
name_conflict = self.store.get_service_by_name(payload.name)
if name_conflict:
payload.name = f"{payload.name} [{candidate.container_id[:6]}]"
existing = self.store.create_service(payload)
by_ref[candidate.stable_source_ref] = existing
created += 1
self.discovery_store.record_change("discovered", f"Discovered {existing.name}", service_id=existing.id)
elif existing is not None:
current = existing.model_dump()
updated_service = self._update_discovered_service(existing, candidate)
unchanged += 1 if updated_service.model_dump() == current else 0
updated += 0 if updated_service.model_dump() == current else 1
existing = updated_service
by_ref[candidate.stable_source_ref] = existing
if existing:
service_ids.append(existing.id)
source = self.discovery_store.get_source("docker", candidate.stable_source_ref)
if source:
self.discovery_store.attach_source(existing.id, source.id)
self._record_candidate_evidence(candidate, existing)
# The v1.1 generic baseline is preserved for history but no longer
# appears as the primary production topology after explicit bootstrap.
if request.apply_high_confidence and created:
discovered_ids = set(service_ids)
for service in self.store.list_services():
if service.id in discovered_ids or service.source_type:
continue
payload = ServiceUpdate.model_validate(service.model_dump())
payload.visibility = "hidden"
payload.lifecycle = DiscoveryLifecycle.RETIRED
payload.monitoring_enabled = False
self.store.update_service(service.id, payload)
retired += 1
accepted_relations = 0
suggestions = self.discovery_store.list_suggestions(run_id=run.id)
by_ref = self._service_by_ref()
for suggestion in suggestions:
if suggestion.suggestion_type != "relation" or not suggestion.source_ref or not suggestion.target_ref:
continue
source = by_ref.get(suggestion.source_ref)
target = by_ref.get(suggestion.target_ref)
if not source or not target:
continue
relation_type = suggestion.relation_type or RelationType.DEPENDS_ON
previous_evidence = self.discovery_store.get_evidence_for_relation(
source.id,
target.id,
suggestion.target_ref,
relation_type,
)
confidence_rank = {
DiscoveryConfidence.LOW: 1,
DiscoveryConfidence.MEDIUM: 2,
DiscoveryConfidence.HIGH: 3,
DiscoveryConfidence.CONFIRMED: 4,
}
evidence_parts = list(suggestion.evidence)
if previous_evidence:
evidence_parts = [previous_evidence.explanation, *evidence_parts]
evidence_text = "; ".join(dict.fromkeys(part for part in evidence_parts if part))
evidence = RelationEvidence(
id=suggestion.id,
source_service_id=source.id,
target_service_id=target.id,
target_ref=suggestion.target_ref,
relation_type=relation_type,
evidence_type="discovery",
confidence=(
previous_evidence.confidence
if previous_evidence and confidence_rank[previous_evidence.confidence] >= confidence_rank[suggestion.confidence]
else suggestion.confidence
),
explanation=evidence_text,
source_ref=suggestion.source_ref,
locked=previous_evidence.locked if previous_evidence else suggestion.status == SuggestionStatus.ACCEPTED,
active=previous_evidence.active if previous_evidence else True,
first_seen_at=previous_evidence.first_seen_at if previous_evidence else suggestion.created_at,
last_seen_at=suggestion.updated_at,
)
self.discovery_store.save_evidence(evidence)
if previous_evidence and not previous_evidence.active:
# An operator rejected/removed this inferred edge. Keep the
# durable evidence for audit, but do not resurrect its health
# propagation on a later discovery cycle.
continue
should_accept = suggestion.status == SuggestionStatus.ACCEPTED or (
request.apply_high_confidence and suggestion.confidence in {DiscoveryConfidence.CONFIRMED, DiscoveryConfidence.HIGH}
)
if not should_accept or suggestion.relation_type != RelationType.DEPENDS_ON:
continue
if target.id in source.depends_on:
if suggestion.status != SuggestionStatus.ACCEPTED:
self.discovery_store.set_suggestion_status(suggestion.id, SuggestionStatus.ACCEPTED)
continue
try:
validate_dependencies([*source.depends_on, target.id], self.store, service_id=source.id)
except ValueError:
continue
payload = ServiceUpdate.model_validate(source.model_dump())
payload.depends_on = [*source.depends_on, target.id]
self.store.update_service(source.id, payload)
self.discovery_store.set_suggestion_status(suggestion.id, SuggestionStatus.ACCEPTED)
self.discovery_store.record_change("dependency_added", f"{source.name} depends on {target.name}", source_service_id=source.id, target_service_id=target.id)
accepted_relations += 1
final_run = self.discovery_store.finish_run(
run.id,
status=DiscoveryRunStatus.COMPLETED,
source_counts=run.source_counts,
diff=run.diff,
)
self.discovery_store.capture_snapshot(run_id=final_run.id)
return ReconcileResult(
run=final_run,
created=created,
updated=updated,
unchanged=unchanged,
missing=missing,
retired=retired,
accepted_relations=accepted_relations,
open_suggestions=len(self.discovery_store.list_suggestions(SuggestionStatus.OPEN)),
service_ids=sorted(set(service_ids)),
)
@staticmethod
def _endpoint_label(service: Service | ServiceCreate) -> str | None:
if service.url:
return service.url
if service.host:
return f"{service.host}{f':{service.port}' if service.port else ''}"
return None
def repair_probe_reachability(self, *, run_id: str | None = None, dry_run: bool = True) -> ProbeRepairReport:
"""Safely retire unsafe discovery endpoints without touching probe history.
Only Docker-managed services are considered. User probe overrides remain
authoritative and the operation intentionally never deletes a service,
relation, probe result, or discovery observation.
"""
run = self.discovery_store.get_run(run_id) if run_id else self.discovery_store.last_completed()
if run is None or run.status != DiscoveryRunStatus.COMPLETED:
raise ValueError("A completed discovery run is required before repairing probe reachability")
candidates = [DockerCandidate.model_validate(item.get("candidate", item)) for item in self.discovery_store.candidates_for_run(run.id)]
services = self._service_by_ref()
changes: list[ProbeRepairChange] = []
disabled_unsafe = enabled_published = docker_health_observed = 0
appops_observed = runtime_observed = unmonitored = 0
for candidate in candidates:
if not candidate.stable_source_ref or candidate.lifecycle != DiscoveryLifecycle.ACTIVE:
continue
current = services.get(candidate.stable_source_ref)
if current is None:
continue
desired = self._payload_for_candidate(candidate, current.name)
is_override = self.discovery_store.has_override("service", current.id, "probe")
next_endpoint = self._endpoint_label(desired)
previous_endpoint = self._endpoint_label(current)
if is_override:
next_endpoint = previous_endpoint
changed = (not is_override) and any(
getattr(current, field) != getattr(desired, field)
for field in ("kind", "url", "host", "port", "monitoring_enabled", "monitoring_state", "probe_source", "probe_reachability", "probe_confidence")
)
if not changed:
if not dry_run:
self.store.set_runtime_observation(
current.id,
runtime_state=candidate.state,
runtime_health=candidate.health,
monitoring_state=current.monitoring_state.value,
)
continue
if current.monitoring_enabled and not desired.monitoring_enabled:
reason = "Internal container endpoint is not reachable from ServiceMap; active probe disabled"
disabled_unsafe += 1
elif desired.monitoring_enabled:
reason = "Host-published endpoint selected using the configured probe host"
enabled_published += 1
elif candidate.healthcheck_present:
reason = "Docker healthcheck retained as runtime observation without a network probe"
docker_health_observed += 1
elif desired.monitoring_state == MonitoringState.OBSERVED:
reason = "No verified reachable endpoint; Docker runtime observation is retained without a network probe"
runtime_observed += 1
else:
reason = "No verified reachable endpoint; service remains observed as unmonitored"
unmonitored += 1
changes.append(
ProbeRepairChange(
service_id=current.id,
service_name=current.name,
previous_monitoring_state=current.monitoring_state,
next_monitoring_state=desired.monitoring_state,
previous_endpoint=previous_endpoint,
next_endpoint=next_endpoint,
next_probe_source=desired.probe_source,
next_probe_confidence=desired.probe_confidence,
reason=reason,
)
)
if not dry_run:
updated = self._update_discovered_service(current, candidate)
monitoring_state = updated.monitoring_state.value
self.store.set_runtime_observation(
updated.id,
runtime_state=candidate.state,
runtime_health=candidate.health,
monitoring_state=monitoring_state,
)
self.discovery_store.record_change("probe_endpoint_superseded", reason, service_id=updated.id)
resolved = 0
if not dry_run:
from .incidents import sync_incidents
before = len([item for item in self.store.list_incidents(active_only=True)])
sync_incidents(self.store, resolution_reason="Monitoring endpoint superseded; active probe was replaced by reachability-safe observation")
after = len([item for item in self.store.list_incidents(active_only=True)])
resolved = max(0, before - after)
return ProbeRepairReport(
run_id=run.id,
dry_run=dry_run,
inspected=sum(1 for candidate in candidates if candidate.stable_source_ref in services),
changed=len(changes),
disabled_unsafe=disabled_unsafe,
enabled_published=enabled_published,
docker_health_observed=docker_health_observed,
appops_observed=appops_observed,
runtime_observed=runtime_observed,
unmonitored=unmonitored,
resolved_incidents=resolved,
changes=changes,
)
def apply_edge(self, request: EdgeMutationRequest) -> RelationEvidence:
source = self.store.get_service(request.source_service_id)
target = self.store.get_service(request.target_service_id)
if source is None or target is None:
raise ValueError("Both edge services must exist")
if request.relation_type == RelationType.DEPENDS_ON and target.id not in source.depends_on:
validate_dependencies([*source.depends_on, target.id], self.store, service_id=source.id)
payload = ServiceUpdate.model_validate(source.model_dump())
payload.depends_on = [*source.depends_on, target.id]
self.store.update_service(source.id, payload)
now = _now()
evidence = RelationEvidence(
id=f"manual-edge-{uuid4().hex[:14]}",
source_service_id=source.id,
target_service_id=target.id,
relation_type=request.relation_type,
evidence_type="manual",
confidence=DiscoveryConfidence.CONFIRMED,
explanation=request.explanation,
source_ref=f"manual:{source.id}->{target.id}",
locked=request.lock,
first_seen_at=now,
last_seen_at=now,
)
saved = self.discovery_store.save_evidence(evidence)
self.discovery_store.save_override("relation", saved.id, "locked", request.lock, request.lock)
self.discovery_store.record_change("dependency_added", request.explanation, source_service_id=source.id, target_service_id=target.id)
return saved
def suggestion_action(self, suggestion_id: str, action: str) -> TopologySuggestion:
suggestion = self.discovery_store.get_suggestion(suggestion_id)
if suggestion is None:
raise ValueError("Suggestion not found")
if action == "reject":
if suggestion.suggestion_type == "relation" and suggestion.relation_type == RelationType.DEPENDS_ON:
source = self._service_by_ref().get(suggestion.source_ref or "")
target = self._service_by_ref().get(suggestion.target_ref or "")
existing_evidence = self.discovery_store.get_evidence(suggestion.id)
if source and target and (existing_evidence is None or not existing_evidence.locked):
payload = ServiceUpdate.model_validate(source.model_dump())
payload.depends_on = [item for item in source.depends_on if item != target.id]
self.store.update_service(source.id, payload)
if existing_evidence:
self.discovery_store.set_evidence_state(suggestion.id, active=False)
return self.discovery_store.set_suggestion_status(suggestion_id, SuggestionStatus.REJECTED) or suggestion
if action in {"confirm", "lock"}:
if suggestion.suggestion_type == "service":
if not suggestion.source_ref:
raise ValueError("Service suggestion has no stable source reference")
existing = self._service_by_ref().get(suggestion.source_ref)
if existing is None:
candidate = next(
(
DockerCandidate.model_validate(item.get("candidate", item))
for item in self.discovery_store.candidates_for_run(suggestion.run_id)
if item.get("candidate", {}).get("stable_source_ref") == suggestion.source_ref
),
None,
)
if candidate is None:
raise ValueError("Discovery observation for service suggestion is no longer available")
existing = self.store.create_service(self._payload_for_candidate(candidate))
source = self.discovery_store.get_source("docker", suggestion.source_ref)
if source:
self.discovery_store.attach_source(existing.id, source.id)
self.discovery_store.record_change("accepted", f"Accepted discovered service {existing.name}", service_id=existing.id)
self.discovery_store.save_override("service", existing.id, "source", suggestion.source_ref, True)
elif suggestion.suggestion_type == "relation":
source = self._service_by_ref().get(suggestion.source_ref or "")
target = self._service_by_ref().get(suggestion.target_ref or "")
if source is None or target is None:
raise ValueError("Accept the related services before accepting this relationship")
if suggestion.relation_type == RelationType.DEPENDS_ON and target.id not in source.depends_on:
validate_dependencies([*source.depends_on, target.id], self.store, service_id=source.id)
payload = ServiceUpdate.model_validate(source.model_dump())
payload.depends_on = [*source.depends_on, target.id]
self.store.update_service(source.id, payload)
now = _now()
self.discovery_store.save_evidence(
RelationEvidence(
id=suggestion.id,
source_service_id=source.id,
target_service_id=target.id,
target_ref=suggestion.target_ref,
relation_type=suggestion.relation_type or RelationType.DEPENDS_ON,
evidence_type="discovery",
confidence=suggestion.confidence,
explanation="; ".join(suggestion.evidence),
source_ref=suggestion.source_ref,
locked=action == "lock",
first_seen_at=suggestion.created_at,
last_seen_at=now,
)
)
self.discovery_store.record_change("dependency_added", f"Accepted relationship {source.name} → {target.name}", source_service_id=source.id, target_service_id=target.id)
return self.discovery_store.set_suggestion_status(suggestion_id, SuggestionStatus.ACCEPTED) or suggestion
raise ValueError("Unsupported suggestion action")
def record_service_override(self, before: Service, after: Service) -> None:
for field in ("name", "group", "category", "criticality", "environment", "visibility"):
if getattr(before, field) != getattr(after, field):
value = getattr(after, field)
self.discovery_store.save_override("service", after.id, field, value.value if hasattr(value, "value") else value)
if (before.kind, before.url, before.host, before.port, before.monitoring_enabled) != (
after.kind,
after.url,
after.host,
after.port,
after.monitoring_enabled,
):
self.discovery_store.save_override("service", after.id, "probe", after.model_dump(include={"kind", "url", "host", "port", "monitoring_enabled"}))
def edge_action(self, evidence_id: str, action: str) -> RelationEvidence:
evidence = self.discovery_store.get_evidence(evidence_id)
if evidence is None or evidence.target_service_id is None:
raise ValueError("Edge evidence not found")
source = self.store.get_service(evidence.source_service_id)
if source is None:
raise ValueError("Edge source service not found")
target_id = evidence.target_service_id
if action in {"remove", "reject"} and evidence.relation_type == RelationType.DEPENDS_ON:
payload = ServiceUpdate.model_validate(source.model_dump())
payload.depends_on = [item for item in source.depends_on if item != target_id]
self.store.update_service(source.id, payload)
self.discovery_store.record_change("dependency_removed", f"Removed relationship from {source.name}", source_service_id=source.id, target_service_id=target_id)
return self.discovery_store.set_evidence_state(evidence_id, active=False) or evidence
if action == "confirm" and evidence.relation_type == RelationType.DEPENDS_ON and target_id not in source.depends_on:
validate_dependencies([*source.depends_on, target_id], self.store, service_id=source.id)
payload = ServiceUpdate.model_validate(source.model_dump())
payload.depends_on = [*source.depends_on, target_id]
self.store.update_service(source.id, payload)
if action == "lock":
self.discovery_store.save_override("relation", evidence_id, "locked", True, True)
return self.discovery_store.set_evidence_state(evidence_id, active=True, locked=action == "lock" or evidence.locked) or evidence
+979
View File
@@ -0,0 +1,979 @@
from __future__ import annotations
import json
import sqlite3
from datetime import UTC, datetime
from hashlib import sha256
from typing import Any
from uuid import uuid4
from .models import (
DiscoveryConfidence,
DiscoveryDiff,
DiscoveryLifecycle,
DiscoveryObservation,
DiscoveryRun,
DiscoveryRunStatus,
LogicalRelation,
RelationEvidence,
RelationType,
ServiceSource,
SuggestionStatus,
TopologyChange,
TopologyDiff,
TopologySnapshot,
TopologySuggestion,
)
from .store import Store
def _now() -> datetime:
return datetime.now(UTC)
def _iso(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _json(value: object) -> str:
return json.dumps(value, separators=(",", ":"), sort_keys=True)
def _obj(value: object) -> dict[str, object]:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def _list(value: object) -> list[str]:
if isinstance(value, list):
return [str(item) for item in value]
if isinstance(value, str):
try:
parsed = json.loads(value)
return [str(item) for item in parsed] if isinstance(parsed, list) else []
except json.JSONDecodeError:
return []
return []
_CONFIDENCE_RANK = {
DiscoveryConfidence.LOW: 1,
DiscoveryConfidence.MEDIUM: 2,
DiscoveryConfidence.HIGH: 3,
DiscoveryConfidence.CONFIRMED: 4,
}
_CONFIDENCE_RANK_INV = {rank: confidence.value for confidence, rank in _CONFIDENCE_RANK.items()}
def _higher_confidence(*values: DiscoveryConfidence) -> DiscoveryConfidence:
return max(values, key=lambda value: _CONFIDENCE_RANK[value])
class DiscoveryStore:
"""Durable discovery observations and review state.
The existing Store remains the owner of canonical services and probe state.
This companion store deliberately keeps observations and suggestions separate
so a discovery run cannot mutate health topology unless reconciliation or an
explicit suggestion action asks it to do so.
"""
def __init__(self, store: Store) -> None:
self.store = store
self._init_schema()
self.ensure_baseline_snapshot()
def _connect(self) -> sqlite3.Connection:
return self.store._connect()
def _init_schema(self) -> None:
with self._connect() as db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS discovery_runs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
started_at TEXT NOT NULL,
completed_at TEXT,
source_counts TEXT NOT NULL DEFAULT '{}',
diff TEXT NOT NULL DEFAULT '{}',
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_discovery_runs_status
ON discovery_runs(status, started_at DESC);
CREATE TABLE IF NOT EXISTS discovery_sources (
id TEXT PRIMARY KEY,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL,
source_name TEXT,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
lifecycle TEXT NOT NULL DEFAULT 'active',
managed INTEGER NOT NULL DEFAULT 1,
metadata TEXT NOT NULL DEFAULT '{}',
UNIQUE(source_type, source_id)
);
CREATE INDEX IF NOT EXISTS idx_discovery_sources_identity
ON discovery_sources(source_type, source_id);
CREATE INDEX IF NOT EXISTS idx_discovery_sources_last_seen
ON discovery_sources(last_seen_at DESC);
CREATE TABLE IF NOT EXISTS service_sources (
service_id TEXT NOT NULL,
source_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'observed',
PRIMARY KEY(service_id, source_id),
FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE CASCADE,
FOREIGN KEY(source_id) REFERENCES discovery_sources(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_service_sources_source
ON service_sources(source_id, service_id);
CREATE TABLE IF NOT EXISTS discovery_observations (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
source_id TEXT NOT NULL,
source_type TEXT NOT NULL,
source_name TEXT NOT NULL,
lifecycle TEXT NOT NULL,
change_type TEXT NOT NULL,
observed_at TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(run_id) REFERENCES discovery_runs(id) ON DELETE CASCADE,
FOREIGN KEY(source_id) REFERENCES discovery_sources(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_discovery_observations_run
ON discovery_observations(run_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_discovery_observations_source
ON discovery_observations(source_id, observed_at DESC);
CREATE TABLE IF NOT EXISTS relation_evidence (
id TEXT PRIMARY KEY,
source_service_id TEXT NOT NULL,
target_service_id TEXT,
target_ref TEXT,
relation_type TEXT NOT NULL DEFAULT 'depends_on',
evidence_type TEXT NOT NULL,
confidence TEXT NOT NULL,
explanation TEXT NOT NULL,
source_ref TEXT,
locked INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
UNIQUE(source_service_id, target_service_id, target_ref, relation_type, evidence_type),
FOREIGN KEY(source_service_id) REFERENCES services(id) ON DELETE CASCADE,
FOREIGN KEY(target_service_id) REFERENCES services(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_relation_evidence_source_target
ON relation_evidence(source_service_id, target_service_id);
CREATE INDEX IF NOT EXISTS idx_relation_evidence_confidence
ON relation_evidence(confidence, active);
CREATE TABLE IF NOT EXISTS topology_suggestions (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
suggestion_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
fingerprint TEXT NOT NULL UNIQUE,
source_ref TEXT,
source_service_id TEXT,
target_ref TEXT,
target_service_id TEXT,
proposed_name TEXT,
proposed_kind TEXT,
proposed_url TEXT,
proposed_host TEXT,
proposed_port INTEGER,
proposed_category TEXT NOT NULL DEFAULT 'unknown',
proposed_group TEXT,
relation_type TEXT,
confidence TEXT NOT NULL,
evidence TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(run_id) REFERENCES discovery_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topology_suggestions_status
ON topology_suggestions(status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_topology_suggestions_source
ON topology_suggestions(source_ref, target_ref);
CREATE TABLE IF NOT EXISTS topology_overrides (
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
field_name TEXT NOT NULL,
value TEXT NOT NULL,
locked INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL,
PRIMARY KEY(target_type, target_id, field_name)
);
CREATE INDEX IF NOT EXISTS idx_topology_overrides_target
ON topology_overrides(target_type, target_id);
CREATE TABLE IF NOT EXISTS topology_change_events (
id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
service_id TEXT,
source_service_id TEXT,
target_service_id TEXT,
detail TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_topology_change_events_time
ON topology_change_events(created_at DESC);
CREATE TABLE IF NOT EXISTS topology_snapshots (
id TEXT PRIMARY KEY,
run_id TEXT,
created_at TEXT NOT NULL,
revision TEXT NOT NULL UNIQUE,
service_count INTEGER NOT NULL,
relationship_count INTEGER NOT NULL,
summary TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(run_id) REFERENCES discovery_runs(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_topology_snapshots_time
ON topology_snapshots(created_at DESC);
CREATE TABLE IF NOT EXISTS topology_snapshot_services (
snapshot_id TEXT NOT NULL,
service_id TEXT NOT NULL,
name TEXT NOT NULL,
fingerprint TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY(snapshot_id, service_id),
FOREIGN KEY(snapshot_id) REFERENCES topology_snapshots(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS topology_snapshot_relations (
snapshot_id TEXT NOT NULL,
relation_id TEXT NOT NULL,
source_service_id TEXT NOT NULL,
target_service_id TEXT,
relation_type TEXT NOT NULL,
fingerprint TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY(snapshot_id, relation_id),
FOREIGN KEY(snapshot_id) REFERENCES topology_snapshots(id) ON DELETE CASCADE
);
"""
)
@staticmethod
def _diff(value: object) -> DiscoveryDiff:
data = _obj(value)
return DiscoveryDiff(**{key: int(data.get(key, 0) or 0) for key in DiscoveryDiff.model_fields})
@classmethod
def _run(cls, row: sqlite3.Row | None) -> DiscoveryRun | None:
if row is None:
return None
return DiscoveryRun(
id=row["id"],
status=DiscoveryRunStatus(row["status"]),
started_at=datetime.fromisoformat(row["started_at"]),
completed_at=datetime.fromisoformat(row["completed_at"]) if row["completed_at"] else None,
source_counts={str(k): int(v) for k, v in _obj(row["source_counts"]).items()},
diff=cls._diff(row["diff"]),
error=row["error"],
)
@classmethod
def _source(cls, row: sqlite3.Row) -> ServiceSource:
return ServiceSource(
id=row["id"],
source_type=row["source_type"],
source_id=row["source_id"],
source_name=row["source_name"],
first_seen_at=datetime.fromisoformat(row["first_seen_at"]),
last_seen_at=datetime.fromisoformat(row["last_seen_at"]),
lifecycle=DiscoveryLifecycle(row["lifecycle"]),
managed=bool(row["managed"]),
metadata=_obj(row["metadata"]),
)
@classmethod
def _suggestion(cls, row: sqlite3.Row) -> TopologySuggestion:
return TopologySuggestion(
id=row["id"],
run_id=row["run_id"],
suggestion_type=row["suggestion_type"],
status=SuggestionStatus(row["status"]),
source_ref=row["source_ref"],
source_service_id=row["source_service_id"],
target_ref=row["target_ref"],
target_service_id=row["target_service_id"],
proposed_name=row["proposed_name"],
proposed_kind=row["proposed_kind"],
proposed_url=row["proposed_url"],
proposed_host=row["proposed_host"],
proposed_port=row["proposed_port"],
proposed_category=row["proposed_category"],
proposed_group=row["proposed_group"],
relation_type=row["relation_type"],
confidence=DiscoveryConfidence(row["confidence"]),
evidence=_list(row["evidence"]),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
@classmethod
def _evidence(cls, row: sqlite3.Row) -> RelationEvidence:
return RelationEvidence(
id=row["id"],
source_service_id=row["source_service_id"],
target_service_id=row["target_service_id"],
target_ref=row["target_ref"],
relation_type=RelationType(row["relation_type"]),
evidence_type=row["evidence_type"],
confidence=DiscoveryConfidence(row["confidence"]),
explanation=row["explanation"],
source_ref=row["source_ref"],
locked=bool(row["locked"]),
active=bool(row["active"]),
first_seen_at=datetime.fromisoformat(row["first_seen_at"]),
last_seen_at=datetime.fromisoformat(row["last_seen_at"]),
)
def start_run(self) -> DiscoveryRun:
run = DiscoveryRun(id=f"disc-{uuid4().hex[:12]}", status=DiscoveryRunStatus.DISCOVERING, started_at=_now())
with self._connect() as db:
# A process restart or an unexpected reconcile exception can leave
# a durable run in an active state. Close that stale marker before
# creating the next run so status never reports a phantom active
# discovery forever.
db.execute(
"UPDATE discovery_runs SET status=?, completed_at=?, error=COALESCE(error, ?) WHERE status IN (?, ?)",
(
DiscoveryRunStatus.FAILED.value,
run.started_at.isoformat(),
"Superseded by a newer discovery run",
DiscoveryRunStatus.DISCOVERING.value,
DiscoveryRunStatus.RECONCILING.value,
),
)
db.execute(
"INSERT INTO discovery_runs (id, status, started_at) VALUES (?, ?, ?)",
(run.id, run.status.value, run.started_at.isoformat()),
)
return run
def finish_run(
self,
run_id: str,
*,
status: DiscoveryRunStatus,
source_counts: dict[str, int],
diff: DiscoveryDiff,
error: str | None = None,
) -> DiscoveryRun:
completed = _now()
with self._connect() as db:
db.execute(
"UPDATE discovery_runs SET status=?, completed_at=?, source_counts=?, diff=?, error=? WHERE id=?",
(status.value, completed.isoformat(), _json(source_counts), _json(diff.model_dump()), error, run_id),
)
return self.get_run(run_id) or DiscoveryRun(
id=run_id,
status=status,
started_at=completed,
completed_at=completed,
source_counts=source_counts,
diff=diff,
error=error,
)
def get_run(self, run_id: str) -> DiscoveryRun | None:
with self._connect() as db:
row = db.execute("SELECT * FROM discovery_runs WHERE id = ?", (run_id,)).fetchone()
return self._run(row)
def last_completed(self) -> DiscoveryRun | None:
with self._connect() as db:
row = db.execute(
"SELECT * FROM discovery_runs WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1"
).fetchone()
return self._run(row)
def status(self) -> tuple[DiscoveryRun | None, DiscoveryRun | None]:
with self._connect() as db:
current_row = db.execute(
"SELECT * FROM discovery_runs WHERE status IN ('discovering', 'reconciling') ORDER BY started_at DESC LIMIT 1"
).fetchone()
return self._run(current_row), self.last_completed()
@staticmethod
def _snapshot_row(row: sqlite3.Row | None) -> TopologySnapshot | None:
if row is None:
return None
return TopologySnapshot(
id=row["id"],
run_id=row["run_id"],
created_at=datetime.fromisoformat(row["created_at"]),
revision=row["revision"],
service_count=int(row["service_count"]),
relationship_count=int(row["relationship_count"]),
summary={str(key): int(value) for key, value in _obj(row["summary"]).items()},
)
def ensure_baseline_snapshot(self) -> TopologySnapshot:
"""Create one migration baseline without manufacturing repeated no-op snapshots."""
with self._connect() as db:
existing = db.execute("SELECT * FROM topology_snapshots ORDER BY created_at DESC LIMIT 1").fetchone()
return self._snapshot_row(existing) if existing else self.capture_snapshot(run_id=None)
def capture_snapshot(self, *, run_id: str | None) -> TopologySnapshot:
"""Persist a compact canonical topology capture when its revision changes."""
services = sorted(self.store.list_services(), key=lambda item: item.id)
service_rows: list[tuple[str, str, str, dict[str, object]]] = []
relation_rows: list[tuple[str, str, str | None, str, str, dict[str, object]]] = []
for service in services:
payload: dict[str, object] = {
"id": service.id,
"name": service.name,
"kind": service.kind.value,
"source_ref": service.source_ref,
"application": service.application,
"domain": service.domain,
"component_role": service.component_role.value,
"category": service.category.value,
"criticality": service.criticality.value,
"visibility": service.visibility.value,
"lifecycle": service.lifecycle.value,
"monitoring_state": service.monitoring_state.value,
"depends_on": sorted(service.depends_on),
}
service_rows.append((service.id, service.name, sha256(_json(payload).encode()).hexdigest(), payload))
for target_id in sorted(service.depends_on):
relation_payload: dict[str, object] = {
"source_service_id": service.id,
"target_service_id": target_id,
"relation_type": RelationType.DEPENDS_ON.value,
}
relation_id = sha256(_json(relation_payload).encode()).hexdigest()[:24]
relation_rows.append(
(
relation_id,
service.id,
target_id,
RelationType.DEPENDS_ON.value,
sha256(_json(relation_payload).encode()).hexdigest(),
relation_payload,
)
)
revision_payload = {"services": [(item[0], item[2]) for item in service_rows], "relations": [(item[0], item[4]) for item in relation_rows]}
revision = sha256(_json(revision_payload).encode()).hexdigest()
with self._connect() as db:
existing = db.execute("SELECT * FROM topology_snapshots WHERE revision=?", (revision,)).fetchone()
if existing:
return self._snapshot_row(existing) # type: ignore[return-value]
created_at = _now()
summary = {
"visible_services": sum(1 for item in services if item.visibility.value == "visible"),
"active_services": sum(1 for item in services if item.lifecycle.value == "active"),
}
snapshot = TopologySnapshot(
id=f"topo-{uuid4().hex[:14]}",
run_id=run_id,
created_at=created_at,
revision=revision,
service_count=len(service_rows),
relationship_count=len(relation_rows),
summary=summary,
)
db.execute(
"INSERT INTO topology_snapshots VALUES (?, ?, ?, ?, ?, ?, ?)",
(snapshot.id, snapshot.run_id, snapshot.created_at.isoformat(), snapshot.revision, snapshot.service_count, snapshot.relationship_count, _json(snapshot.summary)),
)
db.executemany(
"INSERT INTO topology_snapshot_services VALUES (?, ?, ?, ?, ?)",
[(snapshot.id, service_id, name, fingerprint, _json(payload)) for service_id, name, fingerprint, payload in service_rows],
)
db.executemany(
"INSERT INTO topology_snapshot_relations VALUES (?, ?, ?, ?, ?, ?, ?)",
[(snapshot.id, relation_id, source_id, target_id, relation_type, fingerprint, _json(payload)) for relation_id, source_id, target_id, relation_type, fingerprint, payload in relation_rows],
)
self.record_change("topology_snapshot", f"Topology snapshot captured ({snapshot.service_count} services)")
return snapshot
def list_snapshots(self, *, limit: int = 50) -> list[TopologySnapshot]:
with self._connect() as db:
rows = db.execute("SELECT * FROM topology_snapshots ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
return [self._snapshot_row(row) for row in rows if self._snapshot_row(row) is not None] # type: ignore[list-item]
def topology_diff(self, *, after_snapshot_id: str, before_snapshot_id: str | None = None) -> TopologyDiff:
with self._connect() as db:
after = db.execute("SELECT * FROM topology_snapshots WHERE id=?", (after_snapshot_id,)).fetchone()
if after is None:
raise ValueError("Topology snapshot not found")
before = (
db.execute("SELECT * FROM topology_snapshots WHERE id=?", (before_snapshot_id,)).fetchone()
if before_snapshot_id
else db.execute("SELECT * FROM topology_snapshots WHERE created_at < ? ORDER BY created_at DESC LIMIT 1", (after["created_at"],)).fetchone()
)
after_services = {
row["service_id"]: row
for row in db.execute("SELECT * FROM topology_snapshot_services WHERE snapshot_id=?", (after_snapshot_id,)).fetchall()
}
before_services = (
{row["service_id"]: row for row in db.execute("SELECT * FROM topology_snapshot_services WHERE snapshot_id=?", (before["id"],)).fetchall()}
if before else {}
)
after_relations = {
row["relation_id"]: row
for row in db.execute("SELECT * FROM topology_snapshot_relations WHERE snapshot_id=?", (after_snapshot_id,)).fetchall()
}
before_relations = (
{row["relation_id"]: row for row in db.execute("SELECT * FROM topology_snapshot_relations WHERE snapshot_id=?", (before["id"],)).fetchall()}
if before else {}
)
added: list[TopologyChange] = []
removed: list[TopologyChange] = []
changed: list[TopologyChange] = []
for item_id, row in after_services.items():
payload = _obj(row["payload"])
if item_id not in before_services:
added.append(TopologyChange(change_type="added", entity_type="service", entity_id=item_id, name=row["name"], after=payload))
elif row["fingerprint"] != before_services[item_id]["fingerprint"]:
changed.append(TopologyChange(change_type="changed", entity_type="service", entity_id=item_id, name=row["name"], before=_obj(before_services[item_id]["payload"]), after=payload))
for item_id, row in before_services.items():
if item_id not in after_services:
removed.append(TopologyChange(change_type="removed", entity_type="service", entity_id=item_id, name=row["name"], before=_obj(row["payload"])))
for item_id, row in after_relations.items():
name = f"{row['source_service_id']} → {row['target_service_id'] or 'unknown'}"
if item_id not in before_relations:
added.append(TopologyChange(change_type="added", entity_type="relationship", entity_id=item_id, name=name, after=_obj(row["payload"])))
elif row["fingerprint"] != before_relations[item_id]["fingerprint"]:
changed.append(TopologyChange(change_type="changed", entity_type="relationship", entity_id=item_id, name=name, before=_obj(before_relations[item_id]["payload"]), after=_obj(row["payload"])))
for item_id, row in before_relations.items():
if item_id not in after_relations:
removed.append(TopologyChange(change_type="removed", entity_type="relationship", entity_id=item_id, name=f"{row['source_service_id']} → {row['target_service_id'] or 'unknown'}", before=_obj(row["payload"])))
return TopologyDiff(
before_snapshot_id=before["id"] if before else None,
after_snapshot_id=after_snapshot_id,
added=added,
removed=removed,
changed=changed,
summary={"added": len(added), "removed": len(removed), "changed": len(changed)},
)
def save_source(
self,
*,
source_type: str,
source_id: str,
source_name: str | None,
lifecycle: DiscoveryLifecycle,
metadata: dict[str, object],
managed: bool = True,
observed_at: datetime | None = None,
) -> ServiceSource:
at = observed_at or _now()
with self._connect() as db:
existing = db.execute(
"SELECT * FROM discovery_sources WHERE source_type=? AND source_id=?",
(source_type, source_id),
).fetchone()
source_pk = existing["id"] if existing else f"src-{uuid4().hex[:14]}"
first_seen = existing["first_seen_at"] if existing else at.isoformat()
db.execute(
"""
INSERT INTO discovery_sources
(id, source_type, source_id, source_name, first_seen_at, last_seen_at, lifecycle, managed, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source_type, source_id) DO UPDATE SET
source_name=excluded.source_name, last_seen_at=excluded.last_seen_at,
lifecycle=excluded.lifecycle, managed=excluded.managed, metadata=excluded.metadata
""",
(source_pk, source_type, source_id, source_name, first_seen, at.isoformat(), lifecycle.value, int(managed), _json(metadata)),
)
row = db.execute("SELECT * FROM discovery_sources WHERE id=?", (source_pk,)).fetchone()
return self._source(row)
def attach_source(self, service_id: str, source_id: str, role: str = "observed") -> None:
with self._connect() as db:
db.execute(
"INSERT OR IGNORE INTO service_sources (service_id, source_id, role) VALUES (?, ?, ?)",
(service_id, source_id, role),
)
def get_source(self, source_type: str, source_id: str) -> ServiceSource | None:
with self._connect() as db:
row = db.execute(
"SELECT * FROM discovery_sources WHERE source_type=? AND source_id=?",
(source_type, source_id),
).fetchone()
return self._source(row) if row else None
def save_observation(
self,
*,
run_id: str,
source: ServiceSource,
source_name: str,
lifecycle: DiscoveryLifecycle,
change_type: str,
metadata: dict[str, object],
observed_at: datetime | None = None,
) -> DiscoveryObservation:
at = observed_at or _now()
observation = DiscoveryObservation(
id=f"obs-{uuid4().hex[:14]}",
run_id=run_id,
source_id=source.id,
source_type=source.source_type,
source_name=source_name,
lifecycle=lifecycle,
change_type=change_type,
observed_at=at,
metadata=metadata,
)
with self._connect() as db:
db.execute(
"INSERT INTO discovery_observations VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(observation.id, run_id, source.id, source.source_type, source_name, lifecycle.value, change_type, at.isoformat(), _json(metadata)),
)
return observation
def save_suggestion(self, suggestion: TopologySuggestion, fingerprint: str) -> TopologySuggestion:
with self._connect() as db:
existing = db.execute(
"SELECT evidence, confidence FROM topology_suggestions WHERE fingerprint=?", (fingerprint,)
).fetchone()
merged_evidence = list(dict.fromkeys([
*(_list(existing["evidence"]) if existing else []),
*suggestion.evidence,
]))
confidence = _higher_confidence(
suggestion.confidence,
DiscoveryConfidence(existing["confidence"]) if existing else suggestion.confidence,
)
db.execute(
"""
INSERT INTO topology_suggestions
(id, run_id, suggestion_type, status, fingerprint, source_ref, source_service_id, target_ref,
target_service_id, proposed_name, proposed_kind, proposed_url, proposed_host, proposed_port,
proposed_category, proposed_group, relation_type, confidence, evidence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(fingerprint) DO UPDATE SET
run_id=excluded.run_id,
source_ref=excluded.source_ref,
source_service_id=excluded.source_service_id,
target_ref=excluded.target_ref,
target_service_id=excluded.target_service_id,
proposed_name=excluded.proposed_name,
proposed_kind=excluded.proposed_kind,
proposed_url=excluded.proposed_url,
proposed_host=excluded.proposed_host,
proposed_port=excluded.proposed_port,
proposed_category=excluded.proposed_category,
proposed_group=excluded.proposed_group,
relation_type=excluded.relation_type,
confidence=excluded.confidence,
evidence=excluded.evidence,
updated_at=excluded.updated_at
""",
(
suggestion.id,
suggestion.run_id,
suggestion.suggestion_type,
suggestion.status.value,
fingerprint,
suggestion.source_ref,
suggestion.source_service_id,
suggestion.target_ref,
suggestion.target_service_id,
suggestion.proposed_name,
suggestion.proposed_kind.value if suggestion.proposed_kind else None,
suggestion.proposed_url,
suggestion.proposed_host,
suggestion.proposed_port,
suggestion.proposed_category.value,
suggestion.proposed_group,
suggestion.relation_type.value if suggestion.relation_type else None,
confidence.value,
_json(merged_evidence),
suggestion.created_at.isoformat(),
suggestion.updated_at.isoformat(),
),
)
row = db.execute("SELECT * FROM topology_suggestions WHERE fingerprint=?", (fingerprint,)).fetchone()
return self._suggestion(row)
def list_suggestions(
self,
status: SuggestionStatus | None = None,
limit: int = 500,
run_id: str | None = None,
) -> list[TopologySuggestion]:
query = "SELECT * FROM topology_suggestions"
params: list[Any] = []
filters: list[str] = []
if status:
filters.append("status = ?")
params.append(status.value)
if run_id:
filters.append("run_id = ?")
params.append(run_id)
if filters:
query += " WHERE " + " AND ".join(filters)
query += " ORDER BY updated_at DESC LIMIT ?"
params.append(limit)
with self._connect() as db:
rows = db.execute(query, params).fetchall()
return [self._suggestion(row) for row in rows]
def get_suggestion(self, suggestion_id: str) -> TopologySuggestion | None:
with self._connect() as db:
row = db.execute("SELECT * FROM topology_suggestions WHERE id=?", (suggestion_id,)).fetchone()
return self._suggestion(row) if row else None
def set_suggestion_status(self, suggestion_id: str, status: SuggestionStatus) -> TopologySuggestion | None:
now = _now()
with self._connect() as db:
db.execute("UPDATE topology_suggestions SET status=?, updated_at=? WHERE id=?", (status.value, now.isoformat(), suggestion_id))
row = db.execute("SELECT * FROM topology_suggestions WHERE id=?", (suggestion_id,)).fetchone()
return self._suggestion(row) if row else None
def save_evidence(self, evidence: RelationEvidence) -> RelationEvidence:
with self._connect() as db:
existing = db.execute(
"""
SELECT * FROM relation_evidence
WHERE source_service_id=? AND target_service_id IS ? AND target_ref IS ?
AND relation_type=? AND evidence_type=?
""",
(evidence.source_service_id, evidence.target_service_id, evidence.target_ref, evidence.relation_type.value, evidence.evidence_type),
).fetchone()
if existing:
previous = self._evidence(existing)
evidence = evidence.model_copy(update={
"confidence": _higher_confidence(previous.confidence, evidence.confidence),
"explanation": "; ".join(dict.fromkeys(part for part in (previous.explanation, evidence.explanation) if part)),
"source_ref": evidence.source_ref or previous.source_ref,
"locked": previous.locked or evidence.locked,
"active": evidence.active,
"first_seen_at": min(previous.first_seen_at, evidence.first_seen_at),
})
db.execute(
"""
INSERT INTO relation_evidence
(id, source_service_id, target_service_id, target_ref, relation_type, evidence_type,
confidence, explanation, source_ref, locked, active, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source_service_id, target_service_id, target_ref, relation_type, evidence_type) DO UPDATE SET
target_service_id=excluded.target_service_id, confidence=excluded.confidence,
explanation=excluded.explanation, source_ref=excluded.source_ref,
locked=MAX(relation_evidence.locked, excluded.locked), active=excluded.active,
last_seen_at=excluded.last_seen_at
""",
(evidence.id, evidence.source_service_id, evidence.target_service_id, evidence.target_ref, evidence.relation_type.value, evidence.evidence_type, evidence.confidence.value, evidence.explanation, evidence.source_ref, int(evidence.locked), int(evidence.active), evidence.first_seen_at.isoformat(), evidence.last_seen_at.isoformat()),
)
row = db.execute(
"SELECT * FROM relation_evidence WHERE id=?",
(evidence.id,),
).fetchone()
if row is None:
row = db.execute(
"SELECT * FROM relation_evidence WHERE source_service_id=? AND target_service_id IS ? AND target_ref IS ? AND relation_type=? AND evidence_type=?",
(evidence.source_service_id, evidence.target_service_id, evidence.target_ref, evidence.relation_type.value, evidence.evidence_type),
).fetchone()
return self._evidence(row)
def list_logical_relations(self, service_id: str | None = None, active_only: bool = True) -> list[LogicalRelation]:
"""Return one canonical graph edge while retaining every evidence row.
`relation_evidence` is an append/merge audit record. Rendering every
row as an edge made a relation appear duplicated whenever independent
discovery sources agreed. The aggregation is deliberately query-time
so existing v1.3 evidence is preserved without a destructive rebuild.
"""
grouped: dict[tuple[str, str | None, str | None, RelationType], list[RelationEvidence]] = {}
for evidence in self.list_evidence(service_id=service_id, active_only=active_only):
key = (evidence.source_service_id, evidence.target_service_id, evidence.target_ref, evidence.relation_type)
grouped.setdefault(key, []).append(evidence)
result: list[LogicalRelation] = []
for (source_id, target_id, target_ref, relation_type), evidence_rows in grouped.items():
confidence = _higher_confidence(*(item.confidence for item in evidence_rows))
independent_sources = {f"{item.evidence_type}:{item.source_ref or item.explanation}" for item in evidence_rows}
if len(independent_sources) > 1 and confidence != DiscoveryConfidence.CONFIRMED:
confidence = DiscoveryConfidence(_CONFIDENCE_RANK_INV[_CONFIDENCE_RANK[confidence] + 1])
first_seen = min(item.first_seen_at for item in evidence_rows)
last_seen = max(item.last_seen_at for item in evidence_rows)
digest = sha256("|".join((source_id, target_id or "", target_ref or "", relation_type.value)).encode("utf-8")).hexdigest()[:16]
result.append(LogicalRelation(
id=f"relation-{digest}",
source_service_id=source_id,
target_service_id=target_id,
target_ref=target_ref,
relation_type=relation_type,
confidence=confidence,
active=any(item.active for item in evidence_rows),
locked=any(item.locked for item in evidence_rows),
first_seen_at=first_seen,
last_seen_at=last_seen,
evidence=sorted(evidence_rows, key=lambda item: item.last_seen_at, reverse=True),
))
return sorted(result, key=lambda item: item.last_seen_at, reverse=True)
def list_evidence(self, service_id: str | None = None, active_only: bool = True) -> list[RelationEvidence]:
query = "SELECT * FROM relation_evidence WHERE 1=1"
params: list[Any] = []
if service_id:
query += " AND (source_service_id=? OR target_service_id=?)"
params.extend([service_id, service_id])
if active_only:
query += " AND active=1"
query += " ORDER BY last_seen_at DESC"
with self._connect() as db:
rows = db.execute(query, params).fetchall()
return [self._evidence(row) for row in rows]
def get_evidence(self, evidence_id: str) -> RelationEvidence | None:
with self._connect() as db:
row = db.execute("SELECT * FROM relation_evidence WHERE id=?", (evidence_id,)).fetchone()
return self._evidence(row) if row else None
def get_evidence_for_relation(
self,
source_service_id: str,
target_service_id: str | None,
target_ref: str | None,
relation_type: RelationType,
evidence_type: str = "discovery",
) -> RelationEvidence | None:
with self._connect() as db:
row = db.execute(
"""
SELECT * FROM relation_evidence
WHERE source_service_id=? AND target_service_id IS ? AND target_ref IS ?
AND relation_type=? AND evidence_type=?
""",
(source_service_id, target_service_id, target_ref, relation_type.value, evidence_type),
).fetchone()
return self._evidence(row) if row else None
def set_evidence_state(
self,
evidence_id: str,
*,
active: bool | None = None,
locked: bool | None = None,
) -> RelationEvidence | None:
current = self.get_evidence(evidence_id)
if current is None:
return None
with self._connect() as db:
db.execute(
"UPDATE relation_evidence SET active=?, locked=?, last_seen_at=? WHERE id=?",
(int(current.active if active is None else active), int(current.locked if locked is None else locked), _now().isoformat(), evidence_id),
)
return self.get_evidence(evidence_id)
def evidence_for_service(self, service_id: str) -> list[RelationEvidence]:
return self.list_evidence(service_id, active_only=True)
def sources_for_service(self, service_id: str) -> list[ServiceSource]:
with self._connect() as db:
rows = db.execute(
"SELECT s.* FROM discovery_sources s JOIN service_sources ss ON ss.source_id=s.id WHERE ss.service_id=? ORDER BY s.source_type, s.source_name",
(service_id,),
).fetchall()
return [self._source(row) for row in rows]
def observations_for_run(self, run_id: str, limit: int = 1000) -> list[DiscoveryObservation]:
with self._connect() as db:
rows = db.execute(
"SELECT * FROM discovery_observations WHERE run_id=? ORDER BY source_name COLLATE NOCASE LIMIT ?",
(run_id, limit),
).fetchall()
return [
DiscoveryObservation(
id=row["id"],
run_id=row["run_id"],
source_id=row["source_id"],
source_type=row["source_type"],
source_name=row["source_name"],
lifecycle=DiscoveryLifecycle(row["lifecycle"]),
change_type=row["change_type"],
observed_at=datetime.fromisoformat(row["observed_at"]),
metadata=_obj(row["metadata"]),
)
for row in rows
]
def candidates_for_run(self, run_id: str, limit: int = 1000) -> list[dict[str, object]]:
candidates: list[dict[str, object]] = []
for observation in self.observations_for_run(run_id, limit):
candidate = observation.metadata.get("candidate")
if isinstance(candidate, dict):
candidates.append(candidate)
return candidates
def set_run_status(self, run_id: str, status: DiscoveryRunStatus) -> None:
with self._connect() as db:
db.execute("UPDATE discovery_runs SET status=? WHERE id=?", (status.value, run_id))
def record_change(
self,
event_type: str,
detail: str,
*,
service_id: str | None = None,
source_service_id: str | None = None,
target_service_id: str | None = None,
) -> None:
with self._connect() as db:
db.execute(
"INSERT INTO topology_change_events (id, event_type, service_id, source_service_id, target_service_id, detail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(f"change-{uuid4().hex[:14]}", event_type, service_id, source_service_id, target_service_id, detail, _now().isoformat()),
)
def save_override(self, target_type: str, target_id: str, field_name: str, value: object, locked: bool = True) -> None:
with self._connect() as db:
db.execute(
"INSERT INTO topology_overrides (target_type, target_id, field_name, value, locked, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(target_type, target_id, field_name) DO UPDATE SET value=excluded.value, locked=excluded.locked, updated_at=excluded.updated_at",
(target_type, target_id, field_name, _json(value), int(locked), _now().isoformat()),
)
def has_override(self, target_type: str, target_id: str, field_name: str) -> bool:
with self._connect() as db:
row = db.execute(
"SELECT 1 FROM topology_overrides WHERE target_type=? AND target_id=? AND field_name=? AND locked=1",
(target_type, target_id, field_name),
).fetchone()
return row is not None
def list_overrides(self, target_type: str | None = None, target_id: str | None = None) -> list[dict[str, object]]:
query = "SELECT target_type, target_id, field_name, value, locked, updated_at FROM topology_overrides WHERE 1=1"
params: list[Any] = []
if target_type:
query += " AND target_type=?"
params.append(target_type)
if target_id:
query += " AND target_id=?"
params.append(target_id)
with self._connect() as db:
rows = db.execute(query, params).fetchall()
return [
{
"target_type": row["target_type"],
"target_id": row["target_id"],
"field": row["field_name"],
"value": _obj(row["value"]) if row["value"].startswith("{") else row["value"],
"locked": bool(row["locked"]),
"updated_at": row["updated_at"],
}
for row in rows
]
+682
View File
@@ -0,0 +1,682 @@
from __future__ import annotations
import os
import re
import socket
import time
from dataclasses import dataclass
from urllib.parse import urlparse
import docker
import httpx
from docker.errors import DockerException
from .docker_provider import DockerProviderError, docker_client_for_inventory, docker_provider_mode
from .models import (
DiscoveryCategory,
DiscoveryConfidence,
DiscoveryLifecycle,
DiscoveryVisibility,
DockerCandidate,
DockerDiscoveryResult,
DockerPort,
DockerSuggestion,
EvidenceScope,
ProbeEndpointCandidate,
ProbeEndpointSource,
ProbeReachability,
Service,
ServiceKind,
)
ALLOWED_SCHEMES = {"unix", "tcp", "http", "https"}
_RELATION_ENV = re.compile(r"(URL|HOST|HOSTNAME|ADDRESS|ENDPOINT|DSN|DATABASE|REDIS|POSTGRES|MYSQL|MONGO|BROKER|UPSTREAM|S3|STORAGE)", re.IGNORECASE)
_SECRET_ENV = re.compile(r"(PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|AUTHORIZATION|BEARER)", re.IGNORECASE)
_SECRET_LABEL = re.compile(
r"(PASSWORD|PASSWD|SECRET|TOKEN|(?:API|ACCESS|PRIVATE|SIGNING|ENCRYPTION)[_-]?KEY|"
r"CREDENTIAL|AUTH(?:ORIZATION)?|BEARER|COOKIE)",
re.IGNORECASE,
)
_HTTP_CONTAINER_PORTS = {
80, 443, 3000, 3001, 5000, 6060, 8000, 8080, 8081, 8181, 8188,
8265, 8989, 9000, 9443, 9494, 11434,
}
@dataclass(frozen=True)
class AppOpsInfo:
app_id: str
category: str | None
criticality: str | None
health: str | None
display_name: str | None
discovery_confidence: float | None = None
policy_profile: str | None = None
app_type: str | None = None
reasons: tuple[str, ...] = ()
@dataclass(frozen=True)
class AppOpsInventory:
by_container: dict[str, AppOpsInfo]
available: bool
detail: str = ""
@dataclass(frozen=True)
class DockerHealthObservation:
"""The small, health-only subset needed by the recurring observer."""
container_id: str
name: str
stable_source_ref: str
state: str
health: str | None
healthcheck_present: bool
@dataclass(frozen=True)
class ProxyRoute:
domains: tuple[str, ...]
forward_host: str
forward_port: int
forward_scheme: str
def _normal_name(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-")
def stable_source_ref(name: str, labels: dict[str, str]) -> str:
project = labels.get("com.docker.compose.project", "").strip()
service = labels.get("com.docker.compose.service", "").strip()
if project and service:
return f"docker:compose:{_normal_name(project)}:{_normal_name(service)}"
return f"docker:name:{_normal_name(name)}"
def _legacy_source_ref(container_id: str, image_name: str) -> str:
return f"docker:{container_id[:12]}@{image_name}"
def _safe_labels(labels: dict[str, str]) -> dict[str, str]:
prefixes = ("servicemap.", "com.docker.compose.", "net.unraid.docker.", "org.opencontainers.image.")
return {
key: value
for key, value in labels.items()
if key.startswith(prefixes) and not _SECRET_LABEL.search(key)
}
def _classify(name: str, image: str, labels: dict[str, str], appops: AppOpsInfo | None = None) -> DiscoveryCategory:
explicit = labels.get("servicemap.category", "").strip().casefold()
if explicit in {item.value for item in DiscoveryCategory}:
return DiscoveryCategory(explicit)
# AppOps category is application metadata. It is helpful only when its
# discovery confidence is high enough; a low-confidence registry row must
# not override deterministic Docker or operator classification.
if appops and appops.category and (appops.discovery_confidence or 0) >= 0.75:
mapped = {
"database": DiscoveryCategory.DATABASE,
"database_admin": DiscoveryCategory.DATABASE,
"cache": DiscoveryCategory.CACHE,
"network": DiscoveryCategory.INFRASTRUCTURE,
"monitoring": DiscoveryCategory.MONITORING,
"media": DiscoveryCategory.APPLICATION,
"development": DiscoveryCategory.DEVELOPMENT,
"storage": DiscoveryCategory.STORAGE,
"infrastructure": DiscoveryCategory.INFRASTRUCTURE,
}.get(appops.category.casefold())
if mapped:
return mapped
value = f"{name} {image}".casefold()
if any(token in value for token in ("postgres", "mariadb", "mysql", "mongo", "qdrant")):
return DiscoveryCategory.DATABASE
if "redis" in value:
return DiscoveryCategory.CACHE
if any(token in value for token in ("nginx", "traefik", "caddy", "proxy")):
return DiscoveryCategory.PROXY
if any(token in value for token in ("prometheus", "grafana", "zabbix", "netdata", "cadvisor", "loki", "alloy")):
return DiscoveryCategory.MONITORING
if any(token in value for token in ("worker", "agent", "runner", "backup", "scheduler", "node")):
return DiscoveryCategory.BACKGROUND_WORKER
if any(token in value for token in ("portainer", "dockdeck", "unraid", "watchtower", "appops", "gitea")):
return DiscoveryCategory.INFRASTRUCTURE
if any(token in value for token in ("dev", "forge", "lab", "builder", "runner")):
return DiscoveryCategory.DEVELOPMENT
return DiscoveryCategory.APPLICATION
def _visibility(name: str, state: str, category: DiscoveryCategory, labels: dict[str, str]) -> DiscoveryVisibility:
if labels.get("servicemap.ignore", "false").casefold() == "true":
return DiscoveryVisibility.IGNORED
if state != "running":
return DiscoveryVisibility.HIDDEN
if category == DiscoveryCategory.SYSTEM:
return DiscoveryVisibility.IGNORED
if labels.get("servicemap.visible", "true").casefold() == "false":
return DiscoveryVisibility.HIDDEN
return DiscoveryVisibility.VISIBLE
def _kind_and_endpoint(
name: str,
ports: list[DockerPort],
labels: dict[str, str],
probe_host: str | None = None,
) -> tuple[ServiceKind, str | None, str | None, int | None, list[str]]:
"""Select only an endpoint ServiceMap can reach from its own network.
Docker's embedded DNS is scoped to a container network. A name such as
``postgres`` is useful discovery evidence but cannot become an active
probe merely because it exists in another project's network.
"""
probe_host = (probe_host or os.getenv("SERVICEMAP_PROBE_HOST") or "").strip()
explicit_kind = labels.get("servicemap.probe", "").casefold()
explicit_port = labels.get("servicemap.port", "")
tcp_ports = [item for item in ports if item.container_port.endswith("/tcp")]
def container_port(item: DockerPort) -> int | None:
raw = item.container_port.split("/", 1)[0]
return int(raw) if raw.isdigit() else None
explicit_value = int(explicit_port) if explicit_port.isdigit() else None
selected = next((item for item in tcp_ports if container_port(item) == explicit_value), None)
if selected is None:
published = [item for item in tcp_ports if item.host_port is not None]
# The mapped host port is arbitrary. Select using the container's
# listener port so 8080 -> 8070 remains an HTTP candidate.
selected = next((item for item in published if container_port(item) in _HTTP_CONTAINER_PORTS), None)
selected = selected or (published[0] if published else (tcp_ports[0] if tcp_ports else None))
container_listener_port = container_port(selected) if selected else None
host_port = selected.host_port if selected else None
port = explicit_value or container_listener_port
evidence = ["container metadata"]
if explicit_kind in {"http", "tcp", "dns", "tls"}:
kind = ServiceKind(explicit_kind)
evidence.append("servicemap.probe label")
elif port in _HTTP_CONTAINER_PORTS:
kind = ServiceKind.HTTP
evidence.append("HTTP-like container port")
elif port:
kind = ServiceKind.TCP
evidence.append("published TCP port")
else:
return ServiceKind.TCP, None, name, None, evidence
if kind == ServiceKind.HTTP:
protocol = "https" if port in {443, 8443, 9443} else "http"
if host_port is not None and probe_host:
# HTTP services are represented by a URL in the canonical model;
# carrying the published port alongside it creates an invalid
# mixed endpoint payload during reconciliation.
return kind, f"{protocol}://{probe_host}:{host_port}", None, None, evidence + ["host-published endpoint"]
return kind, None, name, port, evidence + ["internal container endpoint candidate"]
if kind == ServiceKind.DNS:
return kind, None, name, None, evidence
if host_port is not None and probe_host:
return kind, None, probe_host, host_port, evidence + ["host-published endpoint"]
return kind, None, name, port, evidence + ["internal container endpoint candidate"]
def _preflight_candidate(candidate: ProbeEndpointCandidate) -> ProbeEndpointCandidate:
"""Verify only host-published endpoints before allowing active probes.
Discovery never probes Docker-internal DNS names and never scans paths: the
endpoint must already come from a Docker port mapping or explicit label.
"""
if candidate.source not in {
ProbeEndpointSource.PUBLISHED_PORT,
ProbeEndpointSource.EXPLICIT_LABEL,
}:
return candidate
parsed = urlparse(candidate.url) if candidate.url else None
hostname = parsed.hostname if parsed else candidate.host
port = parsed.port if parsed else candidate.port
if not hostname or not port:
return candidate.model_copy(update={"preflight_detail": "No host-published endpoint to preflight"})
started = time.perf_counter()
try:
with socket.create_connection((hostname, port), timeout=0.75):
pass
elapsed = round((time.perf_counter() - started) * 1000, 1)
if candidate.kind != ServiceKind.HTTP or not candidate.url:
return candidate.model_copy(update={
"reachability": ProbeReachability.VERIFIED,
"primary": True,
"preflight_detail": "TCP connect succeeded",
"preflight_latency_ms": elapsed,
})
with httpx.Client(timeout=1.25, follow_redirects=False) as client:
response = client.request("HEAD", candidate.url)
if response.status_code == 405:
response = client.request("GET", candidate.url)
return candidate.model_copy(update={
"reachability": ProbeReachability.VERIFIED,
"primary": True,
"preflight_detail": f"HTTP {response.status_code} responded",
"preflight_latency_ms": elapsed,
})
except (OSError, httpx.HTTPError) as exc:
return candidate.model_copy(update={
"reachability": ProbeReachability.UNVERIFIED,
"primary": False,
"preflight_detail": f"Preflight failed: {type(exc).__name__}",
})
def _probe_candidates(
suggestion: DockerSuggestion | None,
*,
healthcheck_present: bool,
) -> list[ProbeEndpointCandidate]:
candidates: list[ProbeEndpointCandidate] = []
if suggestion:
source = ProbeEndpointSource.EXPLICIT_LABEL if "servicemap.probe label" in suggestion.evidence else ProbeEndpointSource.PUBLISHED_PORT
reachability = ProbeReachability.UNVERIFIED if "host-published endpoint" in suggestion.evidence else ProbeReachability.INTERNAL_ONLY
candidates.append(
ProbeEndpointCandidate(
source=source if reachability == ProbeReachability.UNVERIFIED else ProbeEndpointSource.CONTAINER_INTERNAL,
kind=suggestion.kind,
url=suggestion.url,
host=suggestion.host,
port=suggestion.port,
reachability=reachability,
confidence=DiscoveryConfidence.MEDIUM if reachability == ProbeReachability.UNVERIFIED else DiscoveryConfidence.LOW,
evidence="; ".join(suggestion.evidence),
)
)
if healthcheck_present:
candidates.append(
ProbeEndpointCandidate(
source=ProbeEndpointSource.DOCKER_HEALTHCHECK,
kind=suggestion.kind if suggestion else ServiceKind.TCP,
reachability=ProbeReachability.VERIFIED,
confidence=DiscoveryConfidence.HIGH,
evidence="Docker healthcheck present (observed without a network probe)",
)
)
return candidates
def safe_environment_evidence(env: list[str]) -> list[str]:
evidence: list[str] = []
for entry in env:
name, separator, value = entry.partition("=")
if not separator or not value or _SECRET_ENV.search(name) or not _RELATION_ENV.search(name):
continue
try:
parsed = urlparse(value if "://" in value else f"//{value}")
hostname = parsed.hostname
except ValueError:
# Docker environments frequently contain comma-separated host
# lists or malformed IPv6-like values. Discovery evidence is
# best-effort; one invalid value must not abort the complete scan.
continue
if not hostname or hostname.casefold() in {"localhost", "127.0.0.1", "0.0.0.0"}:
continue
try:
port = parsed.port
except ValueError:
# A parseable hostname with a non-numeric suffix is still useful
# relation evidence; omit only the unsafe port component.
port = None
suffix = f":{port}" if port else ""
evidence.append(f"{name} hostname={hostname}{suffix}")
return sorted(set(evidence))
def _appops_inventory(appops_url: str | None) -> AppOpsInventory:
if not appops_url:
return AppOpsInventory({}, False, "AppOps URL is not configured")
base = appops_url.rstrip("/")
try:
mappings_response = httpx.get(f"{base}/api/inventory/mappings", timeout=5)
apps_response = httpx.get(f"{base}/api/inventory/apps", timeout=5)
mappings_response.raise_for_status()
apps_response.raise_for_status()
mappings = mappings_response.json()
apps = apps_response.json()
except (httpx.HTTPError, ValueError) as exc:
return AppOpsInventory({}, False, f"AppOps inventory unavailable: {type(exc).__name__}")
app_by_id = {str(item.get("id")): item for item in apps if isinstance(item, dict)}
result: dict[str, AppOpsInfo] = {}
for mapping in mappings if isinstance(mappings, list) else []:
if not isinstance(mapping, dict):
continue
container = str(mapping.get("containerName") or "")
app_id = str(mapping.get("appId") or "")
app = app_by_id.get(app_id, {})
try:
confidence = float(app.get("discovery_confidence")) if app.get("discovery_confidence") is not None else None
except (TypeError, ValueError):
confidence = None
if container:
result[container.casefold()] = AppOpsInfo(
app_id=app_id,
category=str(app.get("category")) if app.get("category") else None,
criticality=str(app.get("criticality")) if app.get("criticality") else None,
health=str(app.get("health_status")) if app.get("health_status") else None,
display_name=str(app.get("display_name")) if app.get("display_name") else None,
discovery_confidence=max(0.0, min(confidence, 1.0)) if confidence is not None else None,
policy_profile=str(app.get("policy_profile")) if app.get("policy_profile") else None,
app_type=str(app.get("app_type")) if app.get("app_type") else None,
reasons=tuple(str(item) for item in (app.get("discovery_reasons") or [])[:3]),
)
return AppOpsInventory(result, True, f"{len(result)} container mappings loaded")
def appops_inventory(appops_url: str | None) -> AppOpsInventory:
"""Read AppOps health inventory without triggering Docker discovery."""
return _appops_inventory(appops_url)
def observe_container_health(docker_host: str) -> list[DockerHealthObservation]:
"""Read bounded Docker runtime/health facts without discovery side effects.
This deliberately avoids endpoint preflight, relationship inference,
suggestion persistence, reconciliation and snapshot capture. It is safe
to run on the observation cadence because it cannot rewrite topology or
operator-owned configuration.
"""
parsed = urlparse(docker_host)
if parsed.scheme not in ALLOWED_SCHEMES:
raise ValueError("docker_host must use unix://, tcp://, http:// or https://")
client = None
try:
client = (
docker.DockerClient(base_url=docker_host, timeout=5)
if docker_provider_mode() == "socket"
else docker_client_for_inventory(docker_host)
)
client.ping()
observations: list[DockerHealthObservation] = []
for container in client.containers.list(all=True):
attrs = container.attrs or {}
state = attrs.get("State", {}) or {}
config = attrs.get("Config", {}) or {}
raw_labels = {str(key): str(value) for key, value in (config.get("Labels", {}) or {}).items()}
name = str(getattr(container, "name", "unknown")).lstrip("/")
observations.append(
DockerHealthObservation(
container_id=container.id[:12],
name=name,
stable_source_ref=stable_source_ref(name, raw_labels),
state=str(state.get("Status", getattr(container, "status", "unknown"))),
health=(
str(state.get("Health", {}).get("Status"))
if isinstance(state.get("Health"), dict) and state.get("Health", {}).get("Status") is not None
else None
),
healthcheck_present=bool(config.get("Healthcheck")),
)
)
return observations
except (DockerException, DockerProviderError) as exc:
raise RuntimeError(f"Docker observation unavailable: {type(exc).__name__}") from exc
finally:
close = getattr(client, "close", None)
if callable(close):
close()
def _appops_proxy_routes(appops_url: str | None) -> list[ProxyRoute]:
if not appops_url:
return []
try:
payload = httpx.get(f"{appops_url.rstrip('/')}/api/apps/npm/proxy-hosts", timeout=5).json()
except (httpx.HTTPError, ValueError):
return []
items = payload.get("items", []) if isinstance(payload, dict) else payload
routes: list[ProxyRoute] = []
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict) or item.get("enabled") is False:
continue
domains = tuple(sorted({str(domain).strip() for domain in item.get("domainNames", []) if str(domain).strip()}))
host = str(item.get("forwardHost") or "").strip()
try:
port = int(item.get("forwardPort"))
except (TypeError, ValueError):
continue
scheme = str(item.get("forwardScheme") or "http").casefold()
if not domains or not host or not 1 <= port <= 65535 or scheme not in {"http", "https"}:
continue
routes.append(ProxyRoute(domains=domains, forward_host=host, forward_port=port, forward_scheme=scheme))
return routes
def _suggestions(
name: str,
networks: list[str],
ports: list[DockerPort],
labels: dict[str, str],
*,
image: str,
state: str,
appops: AppOpsInfo | None = None,
stable_ref: str,
probe_host: str | None = None,
) -> list[DockerSuggestion]:
if labels.get("servicemap.enabled", "true").casefold() == "false":
return []
kind, url, host, port, evidence = _kind_and_endpoint(name, ports, labels, probe_host)
depends = [item.strip() for item in labels.get("servicemap.depends_on", "").split(",") if item.strip()]
compose_depends = labels.get("com.docker.compose.depends_on", "")
if compose_depends:
depends.extend(item.split(":", 1)[0].strip() for item in compose_depends.split(",") if item.strip())
evidence.append("Compose depends_on metadata")
if depends:
evidence.append("explicit dependency metadata")
if appops:
confidence = f" ({appops.discovery_confidence:.2f})" if appops.discovery_confidence is not None else ""
evidence.append(f"AppOps application mapping{confidence}; application scope only")
evidence.extend(appops.reasons)
category = _classify(name, image, labels, appops)
return [
DockerSuggestion(
name=labels.get("servicemap.name", appops.display_name if appops and appops.display_name else name),
kind=kind,
host=host,
port=port,
url=url,
group=labels.get("servicemap.group") or (f"Compose · {labels.get('com.docker.compose.project')}" if labels.get("com.docker.compose.project") else category.value.replace("_", " ").title()),
evidence=sorted(set(evidence)),
confidence="high" if labels.get("servicemap.probe") or labels.get("servicemap.name") or compose_depends or (appops and (appops.discovery_confidence or 0) >= 0.75) else "medium",
depends_on_names=sorted(set(depends)),
stable_source_ref=stable_ref,
category=category,
visibility=_visibility(name, state, category, labels),
)
]
def discover_containers(
docker_host: str,
existing_services: list[Service] | None = None,
*,
include_stopped: bool = True,
appops_url: str | None = None,
probe_host: str | None = None,
) -> DockerDiscoveryResult:
parsed = urlparse(docker_host)
if parsed.scheme not in ALLOWED_SCHEMES:
raise ValueError("docker_host must use unix://, tcp://, http:// or https://")
# Keep the direct client import at this boundary so existing deployments
# and deterministic SDK-contract tests retain the socket behaviour. Broker
# mode is explicit and never falls back here.
client = docker.DockerClient(base_url=docker_host, timeout=5) if docker_provider_mode() == "socket" else docker_client_for_inventory(docker_host)
try:
client.ping()
containers = client.containers.list(all=include_stopped)
existing_services = existing_services or []
appops_inventory = _appops_inventory(appops_url)
appops = appops_inventory.by_container
configured_probe_host = (probe_host or os.getenv("SERVICEMAP_PROBE_HOST") or "").strip() or None
proxy_routes = _appops_proxy_routes(appops_url)
candidates: list[DockerCandidate] = []
discovered_refs: set[str] = set()
for container in containers:
attrs = container.attrs or {}
state = attrs.get("State", {}) or {}
config = attrs.get("Config", {}) or {}
network_settings = attrs.get("NetworkSettings", {}) or {}
raw_ports = network_settings.get("Ports", {}) or {}
ports: list[DockerPort] = []
for container_port, bindings in raw_ports.items():
if not bindings:
ports.append(DockerPort(container_port=container_port))
continue
for binding in bindings:
raw_host_port = binding.get("HostPort")
host_port = str(raw_host_port) if raw_host_port is not None else ""
ports.append(
DockerPort(
container_port=container_port,
host_ip=binding.get("HostIp") or None,
host_port=int(host_port) if host_port.isdigit() else None,
)
)
image_tags = getattr(container.image, "tags", []) or []
image_name = image_tags[0] if image_tags else str(config.get("Image", "unknown"))
name = str(getattr(container, "name", "unknown")).lstrip("/")
health = state.get("Health", {}).get("Status") if isinstance(state.get("Health"), dict) else None
state_name = str(state.get("Status", getattr(container, "status", "unknown")))
networks = sorted((network_settings.get("Networks", {}) or {}).keys())
raw_labels = {str(key): str(value) for key, value in (config.get("Labels", {}) or {}).items()}
labels = _safe_labels(raw_labels)
stable_ref = stable_source_ref(name, raw_labels)
legacy_ref = _legacy_source_ref(container.id, image_name)
discovered_refs.add(stable_ref)
matching_source = next(
(
service
for service in existing_services
if service.source_ref in {stable_ref, legacy_ref}
or (service.source_ref and service.source_ref.removeprefix("docker:").startswith(container.id[:12]))
),
None,
)
matching_name = next((service for service in existing_services if service.name.casefold() == name.casefold()), None)
appops_info = appops.get(name.casefold())
category = _classify(name, image_name, raw_labels, appops_info)
visibility = _visibility(name, state_name, category, raw_labels)
if matching_source is not None:
import_state = "already imported" if matching_source.source_ref in {stable_ref, legacy_ref} else "changed"
elif matching_name is not None:
import_state = "already imported"
else:
import_state = "new"
suggestions = _suggestions(
name,
networks,
ports,
raw_labels,
image=image_name,
state=state_name,
appops=appops_info,
stable_ref=stable_ref,
probe_host=configured_probe_host,
)
for suggestion in suggestions:
suggestion.source_type = "docker"
suggestion.source_ref = legacy_ref
env_evidence = safe_environment_evidence([str(item) for item in (config.get("Env") or [])])
probe_candidates = _probe_candidates(
suggestions[0] if suggestions else None,
healthcheck_present=bool(config.get("Healthcheck")),
)
if state_name == "running":
probe_candidates = [_preflight_candidate(item) for item in probe_candidates]
candidates.append(
DockerCandidate(
container_id=container.id[:12],
name=name,
image=image_name,
state=state_name,
health=health,
networks=networks,
ports=ports,
labels=labels,
import_state=import_state,
suggestions=suggestions,
stable_source_ref=stable_ref,
compose_project=raw_labels.get("com.docker.compose.project"),
compose_service=raw_labels.get("com.docker.compose.service"),
category=category,
visibility=visibility,
lifecycle=DiscoveryLifecycle.ACTIVE if state_name == "running" else DiscoveryLifecycle.STALE,
healthcheck_present=bool(config.get("Healthcheck")),
environment_evidence=env_evidence,
proxy_route_evidence=[],
appops_app_id=appops_info.app_id if appops_info else None,
appops_category=appops_info.category if appops_info else None,
appops_criticality=appops_info.criticality if appops_info else None,
appops_health=appops_info.health if appops_info else None,
appops_scope=EvidenceScope.APPLICATION if appops_info else None,
appops_discovery_confidence=appops_info.discovery_confidence if appops_info else None,
appops_policy_profile=appops_info.policy_profile if appops_info else None,
appops_app_type=appops_info.app_type if appops_info else None,
probe_candidates=probe_candidates,
)
)
for candidate in candidates:
candidate.dependency_suggestions = sorted(
other.name
for other in candidates
if other.name != candidate.name and set(candidate.networks).intersection(other.networks)
)
# Reverse-proxy metadata is useful evidence, but it is never turned
# into a health dependency automatically. Persist only domains and the
# sanitized upstream tuple; AppOps metadata such as certificate or
# Let's Encrypt fields is intentionally discarded.
by_name = {candidate.name.casefold(): candidate for candidate in candidates}
by_published_port = {
port.host_port: candidate
for candidate in candidates
for port in candidate.ports
if port.host_port is not None
}
for route in proxy_routes:
target = by_name.get(route.forward_host.casefold()) or by_published_port.get(route.forward_port)
if target:
target.proxy_route_evidence.append(
f"Reverse proxy route: {', '.join(route.domains)} -> {route.forward_scheme}://{route.forward_host}:{route.forward_port}"
)
for service in existing_services:
if service.source_type != "docker" or not service.source_ref:
continue
if service.source_ref not in discovered_refs and service.lifecycle not in {DiscoveryLifecycle.RETIRED, DiscoveryLifecycle.IGNORED}:
candidates.append(
DockerCandidate(
container_id=service.source_ref.removeprefix("docker:")[:12],
name=service.name,
image=service.container_image or "not found",
state="missing",
health=None,
networks=[],
ports=[],
import_state="missing",
stable_source_ref=service.source_ref,
category=service.category,
visibility=service.visibility,
lifecycle=DiscoveryLifecycle.MISSING,
)
)
candidates.sort(key=lambda item: item.name.casefold())
return DockerDiscoveryResult(
docker_host=docker_host,
candidates=candidates,
proxy_routes=len(proxy_routes),
appops_available=appops_inventory.available,
appops_detail=appops_inventory.detail,
)
except (DockerException, DockerProviderError) as exc:
raise ValueError(f"Docker discovery failed: {exc}") from exc
finally:
client.close()
+334
View File
@@ -0,0 +1,334 @@
"""Docker inventory providers with an explicit security boundary.
The legacy Unix-socket provider is retained for compatibility. The broker
provider is opt-in and deliberately never falls back to the socket: a failed
broker configuration must be visible to the operator rather than silently
re-expanding ServiceMap's Docker privilege boundary.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
import docker
import httpx
class DockerProviderError(ValueError):
pass
_BROKER_CONNECT_TIMEOUT_SECONDS = 2.0
_BROKER_OPERATION_TIMEOUT_SECONDS = 5.0
_BROKER_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
_BROKER_MAX_CONTAINER_ITEMS = 10_000
_BROKER_PATH = "/v1/containers"
_BROKER_BEARER_TOKEN = re.compile(r"[^\x21-\x7e]")
def _invalid_inventory() -> DockerProviderError:
return DockerProviderError("Docker broker returned an invalid inventory response")
def _contains_ascii_control(value: str) -> bool:
return any(ord(char) < 32 or ord(char) == 127 for char in value)
@dataclass
class BrokerImage:
tags: list[str]
@dataclass
class BrokerContainer:
id: str
name: str
status: str
attrs: dict[str, Any]
image: BrokerImage
class BrokerContainers:
def __init__(self, provider: BrokerDockerClient) -> None: # type: ignore[name-defined]
self.provider = provider
def list(self, *, all: bool = True) -> list[BrokerContainer]:
del all # Broker registration scope is deliberate; it returns all allowed targets.
payload = self.provider._get(_BROKER_PATH)
items = payload.get("items")
if not isinstance(items, list) or len(items) > _BROKER_MAX_CONTAINER_ITEMS:
raise _invalid_inventory()
if any(not isinstance(item, dict) for item in items):
raise _invalid_inventory()
return [self.provider._container(item) for item in items]
class BrokerDockerClient:
"""Minimal Docker SDK-shaped client over the registered read broker."""
def __init__(self, base_url: str, token: str) -> None:
self.base_url = self._validated_base_url(base_url)
if (
not isinstance(token, str)
or not token
or len(token) > 8192
or _BROKER_BEARER_TOKEN.search(token) is not None
):
raise DockerProviderError("Docker broker token is invalid")
# Keep the credential out of client-wide headers: health checks are
# public and only the one fixed inventory request needs authorization.
self._authorization = f"Bearer {token}"
self.http = httpx.Client(
timeout=httpx.Timeout(
_BROKER_OPERATION_TIMEOUT_SECONDS,
connect=_BROKER_CONNECT_TIMEOUT_SECONDS,
pool=_BROKER_CONNECT_TIMEOUT_SECONDS,
),
follow_redirects=False,
trust_env=False,
)
self.containers = BrokerContainers(self)
@staticmethod
def _validated_base_url(base_url: str) -> str:
if (
not isinstance(base_url, str)
or not base_url
or any(ord(char) < 33 for char in base_url)
):
raise DockerProviderError("Docker broker URL is invalid")
try:
parsed = urlsplit(base_url)
# Accessing .port also rejects malformed or out-of-range ports.
_ = parsed.port
except ValueError:
raise DockerProviderError("Docker broker URL is invalid") from None
if (
parsed.scheme.casefold() not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or "\\" in parsed.path
or any(part == ".." for part in parsed.path.split("/"))
):
raise DockerProviderError("Docker broker URL is invalid")
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))
@classmethod
def from_environment(cls) -> BrokerDockerClient:
# Never accept a request-provided Docker host as a broker destination:
# that could otherwise send the dedicated bearer token to an attacker-
# controlled endpoint. Broker identity is deployment configuration only.
url = (os.getenv("SERVICEMAP_DOCKER_BROKER_URL") or "").strip()
token_file = (os.getenv("SERVICEMAP_DOCKER_BROKER_TOKEN_FILE") or "").strip()
if not url or not token_file:
raise DockerProviderError(
"Broker mode requires SERVICEMAP_DOCKER_BROKER_URL and SERVICEMAP_DOCKER_BROKER_TOKEN_FILE"
)
path = Path(token_file)
if not path.is_file():
raise DockerProviderError("Docker broker token file is unavailable")
try:
token = path.read_text(encoding="utf-8").strip()
except OSError:
raise DockerProviderError("Docker broker token file cannot be read") from None
if not token or len(token) > 8192 or _BROKER_BEARER_TOKEN.search(token) is not None:
raise DockerProviderError("Docker broker token file is invalid")
return cls(url, token)
def _get(self, path: str) -> dict[str, Any]:
if path != _BROKER_PATH:
raise DockerProviderError("Docker broker operation is unsupported")
try:
with self.http.stream(
"GET",
f"{self.base_url}{path}",
headers={"Authorization": self._authorization},
) as response:
response.raise_for_status()
if (
response.headers.get("content-type", "").split(";", 1)[0].strip().casefold()
!= "application/json"
):
raise _invalid_inventory()
length = response.headers.get("content-length")
if length is not None:
try:
if int(length) < 0 or int(length) > _BROKER_MAX_RESPONSE_BYTES:
raise _invalid_inventory()
except ValueError:
raise _invalid_inventory() from None
raw = bytearray()
for chunk in response.iter_bytes():
if len(raw) + len(chunk) > _BROKER_MAX_RESPONSE_BYTES:
raise _invalid_inventory()
raw.extend(chunk)
except httpx.HTTPError:
raise DockerProviderError("Docker broker request failed") from None
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise DockerProviderError("Docker broker returned invalid JSON") from None
if not isinstance(payload, dict):
raise _invalid_inventory()
return payload
def ping(self) -> bool:
# /healthz is intentionally unprivileged; an authenticated inventory
# request immediately follows and remains the actual authorization test.
try:
with self.http.stream("GET", f"{self.base_url}/healthz") as response:
response.raise_for_status()
except httpx.HTTPError:
raise DockerProviderError("Docker broker health check failed") from None
return True
@staticmethod
def _container(item: dict[str, Any]) -> BrokerContainer:
def text_field(field: str, *, optional_default: str | None = None) -> str:
if field not in item:
if optional_default is not None:
return optional_default
raise _invalid_inventory()
value = item[field]
if optional_default is not None and value is None:
return optional_default
if (
not isinstance(value, str)
or not value
or len(value) > 4096
or _contains_ascii_control(value)
):
raise _invalid_inventory()
return value
ref = text_field("containerRef")
name = text_field("name").lstrip("/")
if not name:
raise _invalid_inventory()
state = text_field("state")
health = text_field("health", optional_default="none")
image = text_field("image")
labels_value = item.get("labels")
if not isinstance(labels_value, dict) or any(
not isinstance(key, str)
or not isinstance(value, str)
or not key
or len(key) > 4096
or len(value) > 4096
or _contains_ascii_control(key + value)
for key, value in labels_value.items()
):
raise _invalid_inventory()
networks_value = item.get("networks")
if not isinstance(networks_value, list) or any(
not isinstance(name, str)
or not name
or len(name) > 4096
or _contains_ascii_control(name)
for name in networks_value
):
raise _invalid_inventory()
# The deployed broker reports Docker health but does not yet emit an
# explicit healthcheckPresent flag. A concrete health status itself
# proves a healthcheck exists; preserve that discovery signal.
healthcheck_present = item.get("healthcheckPresent", health.casefold() not in {"none", "unknown"})
if not isinstance(healthcheck_present, bool):
raise _invalid_inventory()
ports: dict[str, list[dict[str, str]]] = {}
raw_ports = item.get("ports")
if not isinstance(raw_ports, dict):
raise _invalid_inventory()
for key, bindings in raw_ports.items():
if (
not isinstance(key, str)
or not key
or len(key) > 128
or _contains_ascii_control(key)
):
raise _invalid_inventory()
if bindings is None:
ports[key] = []
continue
if not isinstance(bindings, list):
raise _invalid_inventory()
ports[key] = []
for binding in bindings:
if not isinstance(binding, dict):
raise _invalid_inventory()
host_ip = binding.get("hostIp", "")
host_port = binding.get("hostPort", "")
if host_ip is None:
host_ip = ""
if host_port is None:
host_port = ""
if (
not isinstance(host_ip, str)
or not isinstance(host_port, (str, int))
or isinstance(host_port, bool)
):
raise _invalid_inventory()
host_port = str(host_port)
if (
len(host_ip) > 128
or _contains_ascii_control(host_ip + host_port)
or (
host_port
and (
len(host_port) > 5
or not all("0" <= char <= "9" for char in host_port)
or not 1 <= int(host_port) <= 65535
)
)
):
raise _invalid_inventory()
ports[key].append({"HostIp": host_ip, "HostPort": host_port})
return BrokerContainer(
id=ref,
name=name,
status=state,
attrs={
"State": {
"Status": state,
"Health": {"Status": health} if health != "none" else {},
},
"Config": {
"Image": image,
"Labels": labels_value,
"Env": [],
**({"Healthcheck": {"BrokerReported": True}} if healthcheck_present else {}),
},
"NetworkSettings": {
"Networks": {name: {} for name in networks_value},
"Ports": ports,
},
},
image=BrokerImage(tags=[image]),
)
def close(self) -> None:
self.http.close()
def docker_client_for_inventory(docker_host: str):
provider = os.getenv("SERVICEMAP_DOCKER_PROVIDER", "socket").strip().casefold()
if provider == "socket":
return docker.DockerClient(base_url=docker_host, timeout=5)
if provider == "broker":
return BrokerDockerClient.from_environment()
raise DockerProviderError("SERVICEMAP_DOCKER_PROVIDER must be 'socket' or 'broker'")
def docker_provider_mode() -> str:
return os.getenv("SERVICEMAP_DOCKER_PROVIDER", "socket").strip().casefold() or "socket"
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import asyncio
import json
from queue import Empty, Full, Queue
from threading import Lock
from typing import Any
class EventBus:
"""Small bounded fan-out bus used by the SSE endpoint.
Persistence is handled by SQLite; this bus only wakes currently connected
clients. A queue per client keeps one slow browser from blocking probes or
other subscribers.
"""
def __init__(self, max_queue_size: int = 100) -> None:
self.max_queue_size = max_queue_size
self._subscribers: set[Queue[dict[str, Any]]] = set()
self._lock = Lock()
def subscribe(self) -> Queue[dict[str, Any]]:
queue: Queue[dict[str, Any]] = Queue(maxsize=self.max_queue_size)
with self._lock:
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: Queue[dict[str, Any]]) -> None:
with self._lock:
self._subscribers.discard(queue)
def publish(self, payload: dict[str, Any]) -> None:
with self._lock:
subscribers = tuple(self._subscribers)
for queue in subscribers:
try:
queue.put_nowait(payload)
except Full:
# Drop the oldest event for a lagging client, then retain the
# newest state notification. The durable event journal remains
# the source of truth if a client reconnects.
try:
queue.get_nowait()
except Empty:
pass
try:
queue.put_nowait(payload)
except Full:
pass
event_bus = EventBus()
def sse_payload(payload: dict[str, Any]) -> str:
event_type = str(payload.get("event_type", "update"))
return f"event: {event_type}\ndata: {json.dumps(payload, default=str)}\n\n"
async def next_event(queue: Queue[dict[str, Any]], timeout: float = 15.0) -> dict[str, Any] | None:
try:
return await asyncio.to_thread(queue.get, True, timeout)
except Empty:
return None
+134
View File
@@ -0,0 +1,134 @@
"""Health-evidence selection policy.
The policy is deliberately small and pure. Storage retains every provider
claim; this module decides which *current component-scoped* claim can answer
the direct-health question. It keeps aggregate AppOps health useful in the
inspector without ever treating it as a component probe.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from .models import (
EvidenceFreshness,
EvidenceProvider,
EvidenceScope,
HealthEvidence,
HealthResolution,
HealthResolutionReason,
HealthStatus,
)
_PROVIDER_PRIORITY = {
EvidenceProvider.MANUAL: 100,
EvidenceProvider.PROBE: 90,
EvidenceProvider.DOCKER: 80,
EvidenceProvider.APPOPS: 40,
EvidenceProvider.RUNTIME: 20,
}
def appops_health_status(value: str | None) -> HealthStatus | None:
"""Keep AppOps aggregate health scoped to the application it describes."""
return {
"unhealthy": HealthStatus.DOWN,
"down": HealthStatus.DOWN,
"warning": HealthStatus.DEGRADED,
"degraded": HealthStatus.DEGRADED,
"healthy": HealthStatus.HEALTHY,
}.get((value or "").casefold())
def utc_now() -> datetime:
return datetime.now(UTC)
def freshness_for(evidence: HealthEvidence, *, now: datetime | None = None) -> EvidenceFreshness:
"""Return freshness without mutating the persisted observation.
The bounded stale window is solely explanatory: stale and expired claims
are both excluded from health selection. Showing the distinction helps an
operator understand whether a provider is merely late or genuinely old.
"""
now = now or utc_now()
if evidence.valid_until >= now:
return EvidenceFreshness.FRESH
ttl = max(evidence.valid_until - evidence.observed_at, timedelta(seconds=1))
return (
EvidenceFreshness.STALE if evidence.valid_until + ttl >= now else EvidenceFreshness.EXPIRED
)
def with_freshness(evidence: HealthEvidence, *, now: datetime | None = None) -> HealthEvidence:
return evidence.model_copy(update={"freshness": freshness_for(evidence, now=now)})
def resolve_health(
evidence: list[HealthEvidence], *, now: datetime | None = None
) -> HealthResolution:
"""Resolve current direct health from independently persisted evidence."""
now = now or utc_now()
annotated = [with_freshness(item, now=now) for item in evidence]
eligible = [
item
for item in annotated
if item.active
and item.scope == EvidenceScope.COMPONENT
and item.freshness == EvidenceFreshness.FRESH
]
if not eligible:
component_claims = [
item for item in annotated if item.scope == EvidenceScope.COMPONENT and item.active
]
app_claims = [
item for item in annotated if item.scope == EvidenceScope.APPLICATION and item.active
]
if component_claims:
reason_code = (
HealthResolutionReason.STALE_EVIDENCE
if any(item.freshness == EvidenceFreshness.STALE for item in component_claims)
else HealthResolutionReason.EXPIRED_EVIDENCE
)
elif app_claims:
reason_code = HealthResolutionReason.APPLICATION_SCOPED_ONLY
else:
reason_code = HealthResolutionReason.NO_CURRENT_COMPONENT_EVIDENCE
explanation = (
"Only application-scoped evidence is current; it is shown for provenance and is not "
"used as component health."
if app_claims
else "No current component-scoped health evidence is available."
)
return HealthResolution(reason_code=reason_code, explanation=explanation, evidence=annotated)
ordered = sorted(
eligible,
key=lambda item: (
_PROVIDER_PRIORITY[item.provider],
item.confidence,
item.observed_at,
item.received_at,
item.id,
),
reverse=True,
)
selected = ordered[0]
conflict = len({item.status for item in eligible}) > 1
scope_note = " Conflicting current component signals are retained below." if conflict else ""
return HealthResolution(
status=selected.status,
selected_evidence_id=selected.id,
selected_provider=selected.provider,
confidence=selected.confidence,
reason_code=HealthResolutionReason.SELECTED_COMPONENT_EVIDENCE,
explanation=(
f"{selected.provider.value} selected from current component-scoped evidence "
f"(confidence {selected.confidence:.2f}).{scope_note}"
),
conflict=conflict,
evidence=annotated,
)
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
from .models import HealthStatus, Service
from .store import Store
SEVERITY = {
HealthStatus.MAINTENANCE: 0,
HealthStatus.UNMONITORED: 0,
HealthStatus.UNKNOWN: 0,
HealthStatus.HEALTHY: 1,
HealthStatus.RECOVERING: 2,
HealthStatus.DEGRADED: 3,
HealthStatus.DOWN: 4,
}
def _direct_status(service: Service, store: Store) -> HealthStatus:
return store.service_health(service.id)
def _dependency_status(service: Service, store: Store, visiting: set[str]) -> HealthStatus:
if not service.depends_on:
return HealthStatus.HEALTHY
states: list[HealthStatus] = []
for dependency_id in service.depends_on:
dependency = store.get_service(dependency_id)
if dependency is None:
states.append(HealthStatus.UNKNOWN)
continue
states.append(_effective_status(dependency, store, visiting))
return max(states, key=lambda status: SEVERITY[status])
def _effective_status(service: Service, store: Store, visiting: set[str]) -> HealthStatus:
if service.id in visiting:
return HealthStatus.UNKNOWN
next_visiting = set(visiting)
next_visiting.add(service.id)
own = _direct_status(service, store)
if own == HealthStatus.MAINTENANCE:
return own
dependency = _dependency_status(service, store, next_visiting)
if own == HealthStatus.DOWN:
return own
if dependency == HealthStatus.DOWN:
return HealthStatus.DEGRADED
if dependency in {HealthStatus.DEGRADED, HealthStatus.RECOVERING} and own in {
HealthStatus.HEALTHY,
HealthStatus.RECOVERING,
}:
return HealthStatus.DEGRADED
return own
def dependency_status(service: Service, store: Store) -> HealthStatus:
return _dependency_status(service, store, {service.id})
def effective_status(service: Service, store: Store) -> HealthStatus:
return _effective_status(service, store, set())
def status_maps(
services: list[Service],
*,
direct_statuses: dict[str, HealthStatus] | None = None,
) -> tuple[dict[str, HealthStatus], dict[str, HealthStatus]]:
"""Calculate the complete topology in memory for one bounded API read."""
by_id = {service.id: service for service in services}
def legacy_direct_state(service: Service) -> HealthStatus:
runtime_state = (service.runtime_state or "").casefold()
runtime_health = (service.runtime_health or "").casefold()
if not service.enabled:
return HealthStatus.UNKNOWN
if service.maintenance_enabled:
return HealthStatus.MAINTENANCE
if runtime_health == "unhealthy" or runtime_state in {"exited", "dead", "stopped"}:
return HealthStatus.DOWN
if runtime_state == "restarting":
return HealthStatus.DEGRADED
if not service.monitoring_enabled:
if runtime_health == "healthy":
return HealthStatus.HEALTHY
return HealthStatus.UNMONITORED if service.monitoring_state.value in {"unmonitored", "observed"} else HealthStatus.UNKNOWN
return service.state
direct = {
service.id: (direct_statuses.get(service.id, HealthStatus.UNKNOWN) if direct_statuses else legacy_direct_state(service))
for service in services
}
effective: dict[str, HealthStatus] = {}
dependency: dict[str, HealthStatus] = {}
def visit(service_id: str, visiting: set[str]) -> HealthStatus:
if service_id in effective:
return effective[service_id]
if service_id in visiting:
return HealthStatus.UNKNOWN
service = by_id.get(service_id)
if service is None:
return HealthStatus.UNKNOWN
next_visiting = {*visiting, service_id}
own = direct[service_id]
if not service.depends_on:
dependency[service_id] = HealthStatus.HEALTHY
effective[service_id] = own
return own
dependencies = [visit(item, next_visiting) for item in service.depends_on]
dependency_state = max(dependencies, key=lambda status: SEVERITY[status])
dependency[service_id] = dependency_state
if own == HealthStatus.MAINTENANCE or own == HealthStatus.DOWN:
result = own
elif dependency_state == HealthStatus.DOWN or (
dependency_state in {HealthStatus.DEGRADED, HealthStatus.RECOVERING}
and own in {HealthStatus.HEALTHY, HealthStatus.RECOVERING}
):
result = HealthStatus.DEGRADED
else:
result = own
effective[service_id] = result
return result
for service in services:
visit(service.id, set())
return effective, dependency
def validate_dependencies(
depends_on: list[str],
store: Store,
*,
service_id: str | None = None,
) -> None:
if len(depends_on) != len(set(depends_on)):
raise ValueError("Dependencies must be unique")
if service_id and service_id in depends_on:
raise ValueError("A service cannot depend on itself")
services = store.list_services()
known_ids = {service.id for service in services}
missing = [dependency_id for dependency_id in depends_on if dependency_id not in known_ids]
if missing:
raise ValueError(f"Unknown dependency ids: {', '.join(missing)}")
if service_id is None:
return
graph = {service.id: list(service.depends_on) for service in services}
graph[service_id] = list(depends_on)
visiting: set[str] = set()
visited: set[str] = set()
def visit(node: str) -> None:
if node in visiting:
raise ValueError("Dependency graph contains a cycle")
if node in visited:
return
visiting.add(node)
for dependency_id in graph.get(node, []):
visit(dependency_id)
visiting.remove(node)
visited.add(node)
for node in graph:
visit(node)
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from .health import dependency_status, effective_status
from .models import (
Criticality,
HealthStatus,
IncidentCandidate,
IncidentOverview,
IncidentRecord,
IncidentStatus,
Service,
)
from .store import Store
def _reverse_dependencies(services: list[Service]) -> dict[str, list[str]]:
reverse: dict[str, list[str]] = {service.id: [] for service in services}
for service in services:
for dependency_id in service.depends_on:
reverse.setdefault(dependency_id, []).append(service.id)
return reverse
def affected_service_ids(root_id: str, services: list[Service]) -> list[str]:
reverse = _reverse_dependencies(services)
seen: set[str] = set()
queue = list(reverse.get(root_id, []))
while queue:
current = queue.pop(0)
if current in seen or current == root_id:
continue
seen.add(current)
queue.extend(reverse.get(current, []))
return sorted(seen)
def incident_overview(store: Store) -> IncidentOverview:
services = store.list_services()
by_id = {service.id: service for service in services}
effective = {service.id: effective_status(service, store) for service in services}
dependency = {service.id: dependency_status(service, store) for service in services}
down_ids = sorted(service_id for service_id, state in effective.items() if state == HealthStatus.DOWN)
degraded_ids = sorted(
service_id for service_id, state in effective.items() if state in {HealthStatus.DEGRADED, HealthStatus.RECOVERING}
)
candidates: list[IncidentCandidate] = []
for service in services:
state = effective[service.id]
if state != HealthStatus.DOWN or service.maintenance_enabled:
continue
down_dependencies = [
dependency_id
for dependency_id in service.depends_on
if effective.get(dependency_id) == HealthStatus.DOWN
]
if down_dependencies:
continue
affected_ids = affected_service_ids(service.id, services)
affected_names = [by_id[item].name for item in affected_ids if item in by_id]
reason = "Service is down and no configured dependency is also down"
if dependency[service.id] == HealthStatus.UNKNOWN:
reason += "; at least one dependency has unknown health"
candidates.append(
IncidentCandidate(
service_id=service.id,
service_name=service.name,
status=state,
reason=reason,
affected_service_ids=affected_ids,
affected_service_names=affected_names,
)
)
candidates.sort(key=lambda item: (-len(item.affected_service_ids), item.service_name.lower()))
return IncidentOverview(
generated_at=datetime.now(UTC),
active=bool(down_ids or degraded_ids),
candidates=candidates,
degraded_service_ids=degraded_ids,
down_service_ids=down_ids,
incidents=[],
)
def _severity(service: Service) -> Criticality:
return service.criticality
def sync_incidents(store: Store, *, resolution_reason: str = "Root cause recovered; incident resolved") -> list[IncidentRecord]:
"""Reconcile the current graph with the durable incident journal.
A root service has at most one active incident. Repeated probe cycles only
update its affected set; a later independent outage gets a new id.
"""
overview = incident_overview(store)
services = {service.id: service for service in store.list_services()}
now = datetime.now(UTC)
active: dict[str, IncidentRecord] = {}
for incident in sorted(
store.list_incidents(active_only=True),
key=lambda item: (item.detected_at, item.id),
):
if incident.root_service_id in active:
store.upsert_incident(incident.model_copy(update={
"status": IncidentStatus.RESOLVED,
"resolved_at": now,
"latest_update_at": now,
}))
store.append_incident_event(
incident.id,
"incident.duplicate_resolved",
"Duplicate active incident closed; the earliest incident remains active",
incident.root_service_id,
)
continue
active[incident.root_service_id] = incident
for candidate in overview.candidates:
service = services.get(candidate.service_id)
if service is None:
continue
incident = active.get(candidate.service_id)
if incident is None:
incident = IncidentRecord(
id=str(uuid4()),
status=IncidentStatus.OPEN,
root_service_id=candidate.service_id,
root_service_name=candidate.service_name,
started_at=now,
detected_at=now,
title=f"{candidate.service_name} is down",
root_cause_reason=candidate.reason,
affected_service_ids=candidate.affected_service_ids,
affected_service_names=candidate.affected_service_names,
severity=_severity(service),
latest_update_at=now,
)
store.upsert_incident(incident)
store.append_incident_event(
incident.id,
"incident.opened",
f"Incident opened: {candidate.service_name} is the likely root cause",
candidate.service_id,
)
active[candidate.service_id] = incident
elif incident.affected_service_ids != candidate.affected_service_ids:
updated = incident.model_copy(
update={
"affected_service_ids": candidate.affected_service_ids,
"affected_service_names": candidate.affected_service_names,
"latest_update_at": now,
"root_cause_reason": candidate.reason,
}
)
store.upsert_incident(updated)
store.append_incident_event(incident.id, "incident.updated", "Downstream impact recalculated", candidate.service_id)
for root_id, incident in list(active.items()):
if any(candidate.service_id == root_id for candidate in overview.candidates):
continue
root = services.get(root_id)
if (
(root is None or store.service_health(root_id) not in {
HealthStatus.DOWN,
HealthStatus.DEGRADED,
HealthStatus.RECOVERING,
})
and incident.status != IncidentStatus.RESOLVED
):
resolved = incident.model_copy(
update={"status": IncidentStatus.RESOLVED, "resolved_at": now, "latest_update_at": now}
)
store.upsert_incident(resolved)
store.append_incident_event(incident.id, "incident.resolved", resolution_reason, root_id)
return store.list_incidents(active_only=False)
+743
View File
@@ -0,0 +1,743 @@
from __future__ import annotations
import asyncio
import os
import sqlite3
from contextlib import asynccontextmanager, suppress
from datetime import timedelta
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query, Response, status
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from .auth import AuthConfig, AuthorizationError, authorize_request
from .config_io import export_document, import_document, parse_document, serialize_document
from .demo import DEMO_ROOT_CAUSE_NAME, DEMO_SCENARIO_NAME, build_demo_records
from .discovery import DiscoveryManager
from .discovery_store import DiscoveryStore
from .docker_discovery import discover_containers
from .docker_provider import docker_provider_mode
from .events import event_bus, next_event, sse_payload
from .health import dependency_status, effective_status, status_maps, validate_dependencies
from .incidents import incident_overview, sync_incidents
from .models import (
AvailabilitySummary,
BulkProbeResult,
ConfigImportRequest,
ConfigImportResult,
DemoMutationResult,
DemoState,
DiscoveryObservation,
DiscoveryReconcileRequest,
DiscoveryResult,
DiscoveryRunRequest,
DiscoveryStatus,
DockerDiscoveryRequest,
DockerDiscoveryResult,
EdgeActionRequest,
EdgeMutationRequest,
EventRecord,
HealthResolution,
IncidentOverview,
LogicalRelation,
MaintenanceRequest,
OverviewSummary,
ProbeRepairReport,
ProbeResult,
ProviderStatus,
ReconcileResult,
RelationEvidence,
SemanticTopology,
Service,
ServiceCreate,
ServiceSnapshot,
ServiceUpdate,
SuggestionStatus,
TopologyDiff,
TopologySnapshot,
TopologySuggestion,
)
from .observation import ObservationManager
from .probes import probe_many, probe_service
from .scheduler import scheduled_probe_loop
from .semantic import build_semantic_topology
from .store import Store
store = Store()
discovery_store = DiscoveryStore(store)
discovery_manager = DiscoveryManager(store, discovery_store)
def _env_int(name: str, default: int, minimum: int = 0) -> int:
try:
return max(minimum, int(os.getenv(name, str(default))))
except ValueError:
return default
def _env_float(name: str, default: float, minimum: float = 0.1) -> float:
try:
return max(minimum, float(os.getenv(name, str(default))))
except ValueError:
return default
PROBE_INTERVAL = _env_int("SERVICEMAP_PROBE_INTERVAL", 0)
PROBE_TIMEOUT = _env_float("SERVICEMAP_PROBE_TIMEOUT", 5.0)
PROBE_RETRIES = _env_int("SERVICEMAP_PROBE_RETRIES", 1)
PROBE_CONCURRENCY = _env_int("SERVICEMAP_PROBE_CONCURRENCY", 10, minimum=1)
RETENTION_DAYS = _env_int("SERVICEMAP_RETENTION_DAYS", 30, minimum=1)
EVENT_RETENTION_DAYS = _env_int("SERVICEMAP_EVENT_RETENTION_DAYS", 90, minimum=1)
DISCOVERY_INTERVAL = _env_int("SERVICEMAP_DISCOVERY_INTERVAL_SECONDS", 0, minimum=0)
OBSERVATION_INTERVAL = _env_int("SERVICEMAP_OBSERVATION_INTERVAL_SECONDS", 60, minimum=10)
observation_manager = ObservationManager(store, interval_seconds=OBSERVATION_INTERVAL)
def _require_configured_discovery_targets(
docker_host: str | None = None, appops_url: str | None = None
) -> None:
"""Request bodies cannot redirect an unauthenticated LAN deployment's inventory reads."""
configured_docker = os.getenv("SERVICEMAP_DOCKER_HOST", "unix:///var/run/docker.sock")
configured_appops = os.getenv("SERVICEMAP_APPOPS_URL", "")
if docker_host and docker_host != configured_docker:
raise HTTPException(status_code=403, detail="Docker discovery target is not configured")
if appops_url and appops_url.rstrip("/") != configured_appops.rstrip("/"):
raise HTTPException(status_code=403, detail="AppOps discovery target is not configured")
@asynccontextmanager
async def lifespan(_: FastAPI):
task: asyncio.Task[None] | None = None
discovery_task: asyncio.Task[None] | None = None
observation_task: asyncio.Task[None] | None = None
if PROBE_INTERVAL > 0:
task = asyncio.create_task(
scheduled_probe_loop(
store,
interval_seconds=PROBE_INTERVAL,
timeout_seconds=PROBE_TIMEOUT,
retries=PROBE_RETRIES,
concurrency=PROBE_CONCURRENCY,
retention_days=RETENTION_DAYS,
)
)
if DISCOVERY_INTERVAL > 0:
async def discovery_loop() -> None:
while True:
await asyncio.sleep(DISCOVERY_INTERVAL)
if store.demo_active():
continue
try:
request = DiscoveryRunRequest()
result = await asyncio.to_thread(discovery_manager.run, request)
await asyncio.to_thread(
discovery_manager.reconcile,
DiscoveryReconcileRequest(run_id=result.run.id, apply_high_confidence=False),
)
except (OSError, RuntimeError, ValueError):
# Discovery is review-first; a failed background run is
# persisted and must not take down the monitoring loop.
continue
discovery_task = asyncio.create_task(discovery_loop())
async def observation_loop() -> None:
while True:
if not store.demo_active():
try:
await asyncio.to_thread(observation_manager.refresh)
except (OSError, RuntimeError, ValueError):
# Provider availability is persisted by the observer; an
# unavailable provider must not stop scheduled probing.
pass
await asyncio.sleep(OBSERVATION_INTERVAL)
observation_task = asyncio.create_task(observation_loop())
try:
yield
finally:
if task:
task.cancel()
with suppress(asyncio.CancelledError):
await task
if discovery_task:
discovery_task.cancel()
with suppress(asyncio.CancelledError):
await discovery_task
if observation_task:
observation_task.cancel()
with suppress(asyncio.CancelledError):
await observation_task
app = FastAPI(title="ServiceMap API", version="1.6.2", lifespan=lifespan)
@app.middleware("http")
async def authorization_boundary(request, call_next):
try:
authorize_request(request)
except HTTPException as exc:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
return await call_next(request)
def _snapshot(service: Service) -> ServiceSnapshot:
services = store.list_services()
dependents = [item.name for item in services if service.id in item.depends_on]
return ServiceSnapshot(
service=service,
latest_probe=store.latest_probe(service.id),
availability_24h=store.availability_summary(service.id, 24),
dependency_status=dependency_status(service, store),
effective_status=effective_status(service, store),
direct_status=store.service_health(service.id),
flapping=service.flapping,
flap_count=service.flap_count,
next_probe_at=store.next_probe_at(service, PROBE_INTERVAL),
dependents=dependents,
sources=discovery_store.sources_for_service(service.id),
evidence=discovery_store.evidence_for_service(service.id),
health_evidence=store.health_evidence_for_service(service.id),
health_resolution=store.health_resolution(service.id),
)
def _name_conflict(name: str, *, exclude_id: str | None = None) -> bool:
existing = store.get_service_by_name(name)
return existing is not None and existing.id != exclude_id
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/v1/meta")
def meta() -> dict[str, str | int | float | bool]:
try:
auth_mode = AuthConfig.from_environment().mode
except AuthorizationError:
auth_mode = "misconfigured"
return {
"name": "ServiceMap",
"version": "1.6.2",
"stage": "v1",
"scheduled_probing": PROBE_INTERVAL > 0,
"probe_interval_seconds": PROBE_INTERVAL,
"probe_timeout_seconds": PROBE_TIMEOUT,
"probe_retries": PROBE_RETRIES,
"probe_concurrency": PROBE_CONCURRENCY,
"retention_days": RETENTION_DAYS,
"event_retention_days": EVENT_RETENTION_DAYS,
"sse": True,
"discovery": True,
"discovery_interval_seconds": DISCOVERY_INTERVAL,
"observation_interval_seconds": OBSERVATION_INTERVAL,
"docker_configured": bool(os.getenv("SERVICEMAP_DOCKER_HOST")),
"docker_provider": docker_provider_mode(),
"appops_configured": bool(os.getenv("SERVICEMAP_APPOPS_URL")),
"auth_mode": auth_mode,
}
def _service_snapshots(
*, include_hidden: bool = False, include_context: bool = True
) -> list[ServiceSnapshot]:
all_services = store.list_services()
services = (
all_services
if include_hidden
else [
service
for service in all_services
if service.visibility.value == "visible" and service.lifecycle.value not in {"retired", "ignored"}
]
)
latest = store.latest_probes([service.id for service in services])
availability = store.availability_summaries([service.id for service in services], 24)
evidence_by_service = store.health_evidence_for_services([service.id for service in all_services])
# Calculate propagation over the complete canonical graph first. Hidden
# infrastructure nodes may still sit between a visible service and its
# dependency; filtering before this calculation would turn that impact
# into a misleading unknown state.
direct_all, resolutions = store.service_healths(
[service.id for service in all_services], evidence_by_service=evidence_by_service
)
effective_all, dependency_all = status_maps(all_services, direct_statuses=direct_all)
dependent_names = {
service.id: [item.name for item in services if service.id in item.depends_on]
for service in services
}
return [
ServiceSnapshot(
service=service,
latest_probe=latest.get(service.id),
availability_24h=availability.get(service.id),
dependency_status=dependency_all.get(service.id, service.state),
effective_status=effective_all.get(service.id, service.state),
direct_status=direct_all.get(service.id, service.state),
flapping=service.flapping,
flap_count=service.flap_count,
next_probe_at=(
latest[service.id].checked_at + timedelta(seconds=service.interval_seconds or PROBE_INTERVAL)
if service.id in latest and (service.interval_seconds or PROBE_INTERVAL) > 0
else None
),
dependents=dependent_names.get(service.id, []),
sources=discovery_store.sources_for_service(service.id) if include_context else [],
evidence=discovery_store.evidence_for_service(service.id) if include_context else [],
health_evidence=evidence_by_service.get(service.id, []),
health_resolution=resolutions.get(service.id, HealthResolution()),
)
for service in services
]
@app.get("/api/v1/services", response_model=list[ServiceSnapshot])
def list_services(include_hidden: bool = Query(default=False)) -> list[ServiceSnapshot]:
return _service_snapshots(include_hidden=include_hidden)
@app.post("/api/v1/services", response_model=Service, status_code=status.HTTP_201_CREATED)
def create_service(payload: ServiceCreate) -> Service:
if _name_conflict(payload.name):
raise HTTPException(status_code=409, detail="Service name already exists")
try:
validate_dependencies(payload.depends_on, store)
return store.create_service(payload)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="Service name already exists") from exc
@app.get("/api/v1/services/{service_id}", response_model=ServiceSnapshot)
def get_service(service_id: str) -> ServiceSnapshot:
service = store.get_service(service_id)
if service is None:
raise HTTPException(status_code=404, detail="Service not found")
return _snapshot(service)
@app.put("/api/v1/services/{service_id}", response_model=Service)
def update_service(service_id: str, payload: ServiceUpdate) -> Service:
before = store.get_service(service_id)
if before is None:
raise HTTPException(status_code=404, detail="Service not found")
if _name_conflict(payload.name, exclude_id=service_id):
raise HTTPException(status_code=409, detail="Service name already exists")
try:
validate_dependencies(payload.depends_on, store, service_id=service_id)
updated = store.update_service(service_id, payload)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="Service name already exists") from exc
if updated is None:
raise HTTPException(status_code=404, detail="Service not found")
if before.source_type:
discovery_manager.record_service_override(before, updated)
return updated
@app.delete("/api/v1/services/{service_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_service(service_id: str) -> None:
if not store.delete_service(service_id):
raise HTTPException(status_code=404, detail="Service not found")
@app.post("/api/v1/services/{service_id}/probe", response_model=ProbeResult)
async def run_probe(
service_id: str,
timeout_seconds: float | None = Query(default=None, ge=0.1, le=30),
retries: int | None = Query(default=None, ge=0, le=5),
) -> ProbeResult:
if store.demo_active():
raise HTTPException(status_code=409, detail="Explore demo uses synthetic probe data; live probing is paused")
service = store.get_service(service_id)
if service is None:
raise HTTPException(status_code=404, detail="Service not found")
result = await probe_service(
service,
timeout_seconds=timeout_seconds,
retries=retries,
trigger="manual",
default_timeout_seconds=PROBE_TIMEOUT,
default_retries=PROBE_RETRIES,
)
return store.save_probe(result)
@app.post("/api/v1/probe-all", response_model=BulkProbeResult)
async def run_all_probes(
timeout_seconds: float | None = Query(default=None, ge=0.1, le=30),
retries: int | None = Query(default=None, ge=0, le=5),
concurrency: int = Query(default=PROBE_CONCURRENCY, ge=1, le=50),
) -> BulkProbeResult:
if store.demo_active():
return BulkProbeResult(requested=0, completed=0, results=[])
services = [service for service in store.list_services() if service.enabled and service.monitoring_enabled]
results = await probe_many(
services,
timeout_seconds=timeout_seconds,
retries=retries,
concurrency=concurrency,
trigger="manual",
default_timeout_seconds=PROBE_TIMEOUT,
default_retries=PROBE_RETRIES,
)
for result in results:
store.save_probe(result)
return BulkProbeResult(requested=len(services), completed=len(results), results=results)
@app.get("/api/v1/services/{service_id}/history", response_model=list[ProbeResult])
def probe_history(service_id: str, limit: int = Query(default=50, ge=1, le=500)) -> list[ProbeResult]:
if store.get_service(service_id) is None:
raise HTTPException(status_code=404, detail="Service not found")
return store.probe_history(service_id, limit)
@app.get("/api/v1/services/{service_id}/availability", response_model=AvailabilitySummary)
def availability(service_id: str, hours: int = Query(default=24, ge=1, le=24 * 90)) -> AvailabilitySummary:
if store.get_service(service_id) is None:
raise HTTPException(status_code=404, detail="Service not found")
return store.availability_summary(service_id, hours)
@app.get("/api/v1/services/{service_id}/evidence", response_model=HealthResolution)
def service_evidence(service_id: str) -> HealthResolution:
if store.get_service(service_id) is None:
raise HTTPException(status_code=404, detail="Service not found")
return store.health_resolution(service_id)
@app.get("/api/v1/providers", response_model=list[ProviderStatus])
def providers() -> list[ProviderStatus]:
return store.provider_statuses()
@app.post("/api/v1/services/{service_id}/maintenance", response_model=Service)
def maintenance(service_id: str, payload: MaintenanceRequest) -> Service:
updated = store.set_maintenance(service_id, payload.enabled, payload.reason)
if updated is None:
raise HTTPException(status_code=404, detail="Service not found")
return updated
@app.get("/api/v1/incidents", response_model=IncidentOverview)
def incidents() -> IncidentOverview:
sync_incidents(store)
overview = incident_overview(store)
overview.incidents = store.list_incidents(active_only=False)
return overview
@app.get("/api/v1/incidents/{incident_id}")
def get_incident(incident_id: str) -> dict[str, object]:
incident = store.get_incident(incident_id)
if incident is None:
raise HTTPException(status_code=404, detail="Incident not found")
return {"incident": incident.model_dump(mode="json"), "timeline": [item.model_dump(mode="json") for item in store.incident_timeline(incident_id)]}
@app.post("/api/v1/incidents/{incident_id}/acknowledge")
def acknowledge_incident(incident_id: str) -> dict[str, object]:
try:
incident = store.acknowledge_incident(incident_id)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if incident is None:
raise HTTPException(status_code=404, detail="Incident not found")
return {"incident": incident.model_dump(mode="json"), "timeline": [item.model_dump(mode="json") for item in store.incident_timeline(incident_id)]}
@app.get("/api/v1/events", response_model=list[EventRecord])
def events(limit: int = Query(default=50, ge=1, le=500), service_id: str | None = None) -> list[EventRecord]:
return store.list_events(limit=limit, service_id=service_id)
@app.get("/api/v1/events/stream")
async def event_stream() -> StreamingResponse:
queue = event_bus.subscribe()
async def stream():
try:
yield ": connected\n\n"
while True:
payload = await next_event(queue)
if payload is None:
yield ": keepalive\n\n"
else:
yield sse_payload(payload)
finally:
event_bus.unsubscribe(queue)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.get("/api/v1/overview", response_model=OverviewSummary)
def overview() -> OverviewSummary:
sync_incidents(store)
snapshots = _service_snapshots(include_hidden=False)
overview_state = incident_overview(store)
counts: dict[str, int] = {}
coverage: dict[str, int] = {}
for snapshot in snapshots:
counts[snapshot.effective_status.value] = counts.get(snapshot.effective_status.value, 0) + 1
state = snapshot.service.monitoring_state.value
coverage[state] = coverage.get(state, 0) + 1
return OverviewSummary(
generated_at=overview_state.generated_at,
total_services=len(snapshots),
health_counts=counts,
active_incidents=len(store.list_incidents(active_only=True)),
likely_root_causes=[candidate.service_name for candidate in overview_state.candidates],
flapping_services=[snapshot.service.name for snapshot in snapshots if snapshot.flapping],
recent_events=store.list_events(limit=10),
monitoring_coverage=coverage,
)
@app.get("/api/v1/config")
def export_config(format: str = Query(default="yaml", pattern="^(yaml|json)$")) -> Response:
document = export_document(store)
content = serialize_document(document, format)
media_type = "application/json" if format == "json" else "application/yaml"
extension = "json" if format == "json" else "yaml"
return Response(content=content, media_type=media_type, headers={"Content-Disposition": f'attachment; filename="servicemap.{extension}"'})
@app.post("/api/v1/config/import", response_model=ConfigImportResult)
def import_config(payload: ConfigImportRequest) -> ConfigImportResult:
try:
document = parse_document(payload.content, payload.format)
return import_document(store, document, payload.mode)
except (ValueError, sqlite3.IntegrityError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
def _demo_state() -> DemoState:
if not store.demo_active():
return DemoState(active=False)
sync_incidents(store)
overview_state = incident_overview(store)
candidate = overview_state.candidates[0] if overview_state.candidates else None
return DemoState(
active=True,
synthetic=True,
scenario_name=DEMO_SCENARIO_NAME,
service_count=len(store.list_services()),
root_cause_service_id=candidate.service_id if candidate else None,
root_cause_service_name=candidate.service_name if candidate else DEMO_ROOT_CAUSE_NAME,
affected_service_ids=candidate.affected_service_ids if candidate else [],
affected_service_names=candidate.affected_service_names if candidate else [],
affected_count=len(candidate.affected_service_ids) if candidate else 0,
)
def _demo_result(result: ConfigImportResult | None = None, *, restored: bool = False) -> DemoMutationResult:
state = _demo_state()
return DemoMutationResult(
**state.model_dump(),
created=result.created if result else 0,
updated=result.updated if result else 0,
total=result.total if result else state.service_count,
restored=restored,
)
@app.get("/api/v1/demo", response_model=DemoState)
def demo_state() -> DemoState:
return _demo_state()
@app.post("/api/v1/demo", response_model=DemoMutationResult)
def load_demo() -> DemoMutationResult:
services, probes = build_demo_records()
result = store.activate_demo(services, probes)
sync_incidents(store)
return _demo_result(result)
@app.post("/api/v1/demo/reset", response_model=DemoMutationResult)
def reset_demo() -> DemoMutationResult:
services, probes = build_demo_records()
result = store.activate_demo(services, probes)
sync_incidents(store)
return _demo_result(result)
@app.post("/api/v1/demo/exit", response_model=DemoMutationResult)
async def exit_demo() -> DemoMutationResult:
restored = store.exit_demo()
# Refresh live provider evidence immediately after restoring the saved
# production topology. Test stores intentionally do not inherit the
# process-global observer, but production always does.
if restored and observation_manager.store is store:
await asyncio.to_thread(observation_manager.refresh)
return _demo_result(restored=restored)
@app.post("/api/v1/discovery/run", response_model=DiscoveryResult)
def run_discovery(payload: DiscoveryRunRequest | None = None) -> DiscoveryResult:
if store.demo_active():
raise HTTPException(status_code=409, detail="Exit Explore demo before discovering production infrastructure")
request = payload or DiscoveryRunRequest()
_require_configured_discovery_targets(request.docker_host, request.appops_url)
try:
return discovery_manager.run(request)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.get("/api/v1/discovery/status", response_model=DiscoveryStatus)
def discovery_status() -> DiscoveryStatus:
return DiscoveryStatus(**discovery_manager.status())
@app.get("/api/v1/discovery/items", response_model=list[DiscoveryObservation])
def discovery_items(run_id: str | None = None) -> list[DiscoveryObservation]:
selected = discovery_store.get_run(run_id) if run_id else discovery_store.last_completed()
return discovery_store.observations_for_run(selected.id) if selected else []
@app.get("/api/v1/discovery/suggestions", response_model=list[TopologySuggestion])
def discovery_suggestions(
status_filter: str | None = Query(default=None, alias="status"),
) -> list[TopologySuggestion]:
try:
suggestion_status = SuggestionStatus(status_filter) if status_filter else None
except ValueError as exc:
raise HTTPException(status_code=422, detail="Unknown suggestion status") from exc
return discovery_store.list_suggestions(status=suggestion_status)
@app.post("/api/v1/discovery/suggestions/{suggestion_id}/accept", response_model=TopologySuggestion)
def accept_discovery_suggestion(suggestion_id: str) -> TopologySuggestion:
try:
return discovery_manager.suggestion_action(suggestion_id, "confirm")
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/v1/discovery/suggestions/{suggestion_id}/reject", response_model=TopologySuggestion)
def reject_discovery_suggestion(suggestion_id: str) -> TopologySuggestion:
try:
return discovery_manager.suggestion_action(suggestion_id, "reject")
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/v1/discovery/reconcile", response_model=ReconcileResult)
def reconcile_discovery(payload: DiscoveryReconcileRequest | None = None) -> ReconcileResult:
if store.demo_active():
raise HTTPException(status_code=409, detail="Exit Explore demo before reconciling production infrastructure")
try:
return discovery_manager.reconcile(payload or DiscoveryReconcileRequest())
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@app.post("/api/v1/discovery/probes/repair", response_model=ProbeRepairReport)
def repair_discovered_probes(
dry_run: bool = Query(default=True),
run_id: str | None = Query(default=None),
) -> ProbeRepairReport:
if store.demo_active():
raise HTTPException(status_code=409, detail="Exit Explore demo before repairing production probe endpoints")
try:
return discovery_manager.repair_probe_reachability(run_id=run_id, dry_run=dry_run)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@app.get("/api/v1/topology/edges", response_model=list[RelationEvidence])
def topology_edges() -> list[RelationEvidence]:
return discovery_store.list_evidence()
@app.get("/api/v1/topology/relations", response_model=list[LogicalRelation])
def topology_relations() -> list[LogicalRelation]:
"""Canonical relationships: one graph edge, all supporting evidence retained."""
return discovery_store.list_logical_relations()
@app.get("/api/v1/topology/snapshots", response_model=list[TopologySnapshot])
def topology_snapshots(limit: int = Query(default=30, ge=1, le=200)) -> list[TopologySnapshot]:
return discovery_store.list_snapshots(limit=limit)
@app.get("/api/v1/topology/diff", response_model=TopologyDiff)
def topology_diff(
after_snapshot_id: str = Query(...),
before_snapshot_id: str | None = Query(default=None),
) -> TopologyDiff:
try:
return discovery_store.topology_diff(
after_snapshot_id=after_snapshot_id,
before_snapshot_id=before_snapshot_id,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get("/api/v1/topology/semantic", response_model=SemanticTopology)
def semantic_topology(include_hidden: bool = Query(default=False)) -> SemanticTopology:
snapshots = _service_snapshots(include_hidden=include_hidden, include_context=False)
incident_state = incident_overview(store)
root_ids = {candidate.service_id for candidate in incident_state.candidates}
affected_ids = {
service_id
for candidate in incident_state.candidates
for service_id in candidate.affected_service_ids
}
return build_semantic_topology(
snapshots,
discovery_store.list_logical_relations(),
root_service_ids=root_ids,
affected_service_ids=affected_ids,
)
@app.post("/api/v1/topology/edges", response_model=RelationEvidence, status_code=status.HTTP_201_CREATED)
def create_topology_edge(payload: EdgeMutationRequest) -> RelationEvidence:
try:
return discovery_manager.apply_edge(payload)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@app.post("/api/v1/topology/edges/{evidence_id}/action", response_model=RelationEvidence)
def topology_edge_action(evidence_id: str, payload: EdgeActionRequest) -> RelationEvidence:
try:
return discovery_manager.edge_action(evidence_id, payload.action)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@app.post("/api/v1/docker/discover", response_model=DockerDiscoveryResult)
def docker_discover(payload: DockerDiscoveryRequest) -> DockerDiscoveryResult:
_require_configured_discovery_targets(payload.docker_host)
try:
return discover_containers(
payload.docker_host,
store.list_services(),
appops_url=os.getenv("SERVICEMAP_APPOPS_URL"),
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@app.post("/api/v1/maintenance/prune")
def prune_history(retention_days: int = Query(default=RETENTION_DAYS, ge=1, le=3650)) -> dict[str, int]:
return {"deleted_probe_results": store.prune_probes(retention_days, EVENT_RETENTION_DAYS)}
static_dir = Path(os.getenv("SERVICEMAP_STATIC_DIR", "/app/static"))
if static_dir.is_dir():
app.mount("/", StaticFiles(directory=static_dir, html=True), name="frontend")
+862
View File
@@ -0,0 +1,862 @@
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from urllib.parse import urlparse
from pydantic import AliasChoices, BaseModel, Field, model_validator
class ServiceKind(StrEnum):
HTTP = "http"
TCP = "tcp"
DNS = "dns"
TLS = "tls"
class HealthStatus(StrEnum):
UNKNOWN = "unknown"
UNMONITORED = "unmonitored"
HEALTHY = "healthy"
DEGRADED = "degraded"
DOWN = "down"
RECOVERING = "recovering"
MAINTENANCE = "maintenance"
class EvidenceProvider(StrEnum):
"""Systems that can make a health claim about an infrastructure subject."""
PROBE = "active_probe"
DOCKER = "docker"
APPOPS = "appops"
RUNTIME = "runtime"
MANUAL = "manual"
class EvidenceScope(StrEnum):
"""Whether a claim describes one component or an aggregate application."""
COMPONENT = "component"
APPLICATION = "application"
class EvidenceFreshness(StrEnum):
FRESH = "fresh"
STALE = "stale"
EXPIRED = "expired"
class HealthResolutionReason(StrEnum):
"""Machine-readable explanation for a health answer or lack of one."""
SELECTED_COMPONENT_EVIDENCE = "selected_component_evidence"
APPLICATION_SCOPED_ONLY = "application_scoped_only"
NO_CURRENT_COMPONENT_EVIDENCE = "no_current_component_evidence"
STALE_EVIDENCE = "stale_evidence"
EXPIRED_EVIDENCE = "expired_evidence"
SELECTED_APPLICATION_EVIDENCE = "selected_application_evidence"
COMPONENT_FAILURE = "component_failure"
COMPONENT_IMPACT = "component_dependency_impact"
COMPONENT_COVERAGE = "component_coverage"
NO_APPLICATION_EVIDENCE = "no_application_evidence"
class ProviderHealth(StrEnum):
HEALTHY = "healthy"
DEGRADED = "degraded"
UNAVAILABLE = "unavailable"
UNKNOWN = "unknown"
class MonitoringState(StrEnum):
"""How a service is currently observed, independently of its health."""
ACTIVE = "active_probe"
DOCKER_HEALTH = "docker_health"
APPOPS = "appops_observation"
OBSERVED = "observed"
UNMONITORED = "unmonitored"
DISABLED = "disabled"
class ProbeEndpointSource(StrEnum):
MANUAL = "manual"
EXPLICIT_LABEL = "explicit_label"
KNOWN_URL = "known_url"
APPOPS = "appops"
REVERSE_PROXY = "reverse_proxy"
PUBLISHED_PORT = "published_port"
DOCKER_HEALTHCHECK = "docker_healthcheck"
CONTAINER_INTERNAL = "container_internal"
DISCOVERED_ENVIRONMENT = "discovered_environment"
class ProbeReachability(StrEnum):
VERIFIED = "verified"
REACHABLE = "reachable"
UNVERIFIED = "unverified"
INTERNAL_ONLY = "internal_only"
NOT_CONFIGURED = "not_configured"
class Criticality(StrEnum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class IncidentStatus(StrEnum):
OPEN = "open"
ACKNOWLEDGED = "acknowledged"
RESOLVED = "resolved"
class DiscoveryCategory(StrEnum):
APPLICATION = "application"
DATABASE = "database"
CACHE = "cache"
PROXY = "proxy"
MONITORING = "monitoring"
DEVELOPMENT = "development"
STORAGE = "storage"
INFRASTRUCTURE = "infrastructure"
BACKGROUND_WORKER = "background_worker"
SYSTEM = "system"
UNKNOWN = "unknown"
class ComponentRole(StrEnum):
"""The operational role a component plays inside an application."""
FRONTEND = "frontend"
API = "api"
DATABASE = "database"
CACHE = "cache"
PROXY = "proxy"
WORKER = "worker"
STORAGE = "storage"
MONITORING = "monitoring"
AGENT = "agent"
SCHEDULER = "scheduler"
AUXILIARY = "auxiliary"
UNKNOWN = "unknown"
class DiscoveryVisibility(StrEnum):
VISIBLE = "visible"
HIDDEN = "hidden"
IGNORED = "ignored"
class DiscoveryLifecycle(StrEnum):
ACTIVE = "active"
MISSING = "missing"
STALE = "stale"
IGNORED = "ignored"
RETIRED = "retired"
class DiscoveryConfidence(StrEnum):
CONFIRMED = "confirmed"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class DiscoveryRunStatus(StrEnum):
IDLE = "idle"
DISCOVERING = "discovering"
RECONCILING = "reconciling"
COMPLETED = "completed"
FAILED = "failed"
class SuggestionStatus(StrEnum):
OPEN = "open"
ACCEPTED = "accepted"
REJECTED = "rejected"
SUPERSEDED = "superseded"
class RelationType(StrEnum):
DEPENDS_ON = "depends_on"
ROUTES_TO = "routes_to"
USES_STORAGE = "uses_storage"
COMMUNICATES_WITH = "communicates_with"
class ServiceCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
kind: ServiceKind
url: str | None = None
host: str | None = Field(default=None, validation_alias=AliasChoices("host", "hostname"))
port: int | None = Field(default=None, ge=1, le=65535)
depends_on: list[str] = Field(default_factory=list)
description: str | None = Field(default=None, max_length=500)
tags: list[str] = Field(default_factory=list, max_length=20)
group: str | None = Field(default=None, max_length=80)
environment: str | None = Field(default=None, max_length=80)
owner: str | None = Field(default=None, max_length=120)
enabled: bool = True
monitoring_enabled: bool = True
monitoring_state: MonitoringState = MonitoringState.ACTIVE
probe_source: ProbeEndpointSource = ProbeEndpointSource.MANUAL
probe_reachability: ProbeReachability = ProbeReachability.VERIFIED
probe_confidence: DiscoveryConfidence = DiscoveryConfidence.CONFIRMED
runtime_state: str | None = Field(default=None, max_length=40)
runtime_health: str | None = Field(default=None, max_length=40)
application: str | None = Field(default=None, max_length=120)
domain: str | None = Field(default=None, max_length=80)
component_role: ComponentRole = ComponentRole.UNKNOWN
application_locked: bool = False
domain_locked: bool = False
component_role_locked: bool = False
criticality: Criticality = Criticality.MEDIUM
category: DiscoveryCategory = DiscoveryCategory.UNKNOWN
visibility: DiscoveryVisibility = DiscoveryVisibility.VISIBLE
lifecycle: DiscoveryLifecycle = DiscoveryLifecycle.ACTIVE
source_type: str | None = Field(default=None, max_length=40)
source_ref: str | None = Field(default=None, max_length=240)
first_seen_at: datetime | None = None
last_seen_at: datetime | None = None
last_discovery_status: str | None = Field(default=None, max_length=40)
container_image: str | None = Field(default=None, max_length=240)
compose_project: str | None = Field(default=None, max_length=120)
layout_x: float | None = None
layout_y: float | None = None
layout_pinned: bool = False
interval_seconds: int | None = Field(default=None, ge=1, le=86400)
timeout_seconds: float | None = Field(default=None, ge=0.1, le=60)
retries: int | None = Field(default=None, ge=0, le=5)
failure_threshold: int = Field(default=1, ge=1, le=20)
recovery_threshold: int = Field(default=1, ge=1, le=20)
expected_status_min: int | None = Field(default=None, ge=100, le=599)
expected_status_max: int | None = Field(default=None, ge=100, le=599)
availability_target: float | None = Field(default=None, ge=0, le=100)
maintenance_enabled: bool = False
maintenance_reason: str | None = Field(default=None, max_length=240)
@model_validator(mode="after")
def validate_endpoint(self) -> ServiceCreate:
if not self.monitoring_enabled and not self.url and not self.host and self.port is None:
return self
if self.kind == ServiceKind.HTTP and not self.url:
raise ValueError("HTTP services require url")
if self.kind == ServiceKind.HTTP:
parsed = urlparse(self.url or "")
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("HTTP services require an absolute http(s) url")
if self.host or self.port:
raise ValueError("HTTP services must use url instead of host/port")
if self.kind in {ServiceKind.TCP, ServiceKind.TLS} and (not self.host or self.port is None):
raise ValueError(f"{self.kind.value.upper()} services require host and port")
if self.kind == ServiceKind.DNS and not self.host:
raise ValueError("DNS services require hostname in host")
if self.kind in {ServiceKind.TCP, ServiceKind.TLS, ServiceKind.DNS} and self.url:
raise ValueError(f"{self.kind.value.upper()} services must use host/port instead of url")
if self.kind == ServiceKind.DNS and self.port is not None:
raise ValueError("DNS services use a hostname without a port")
if (
self.expected_status_min is not None
and self.expected_status_max is not None
and self.expected_status_min > self.expected_status_max
):
raise ValueError("expected_status_min cannot exceed expected_status_max")
return self
class ServiceUpdate(ServiceCreate):
pass
class Service(ServiceCreate):
id: str
created_at: datetime
updated_at: datetime
state: HealthStatus = HealthStatus.UNKNOWN
failure_streak: int = 0
recovery_streak: int = 0
flapping: bool = False
flap_count: int = 0
last_state_change_at: datetime | None = None
class ProbeResult(BaseModel):
id: str
service_id: str
status: HealthStatus
latency_ms: float | None
detail: str
checked_at: datetime
trigger: str = "manual"
metadata: dict[str, object] = Field(default_factory=dict)
class HealthEvidence(BaseModel):
"""An immutable-ish, provider-scoped claim used to resolve direct health.
Application-scoped evidence is intentionally retained for provenance, but
cannot be promoted to a component's direct health without an explicit
component-level observation from that provider.
"""
id: str
subject_type: str = Field(default="service", max_length=40)
subject_id: str = Field(max_length=240)
service_id: str | None = None
provider: EvidenceProvider
provider_ref: str | None = Field(default=None, max_length=240)
status: HealthStatus
scope: EvidenceScope = EvidenceScope.COMPONENT
confidence: float = Field(default=1.0, ge=0, le=1)
observed_at: datetime
valid_until: datetime
received_at: datetime
summary: str = Field(default="", max_length=500)
metadata: dict[str, object] = Field(default_factory=dict)
active: bool = True
superseded_by: str | None = None
freshness: EvidenceFreshness = EvidenceFreshness.FRESH
class HealthResolution(BaseModel):
status: HealthStatus = HealthStatus.UNKNOWN
selected_evidence_id: str | None = None
selected_provider: EvidenceProvider | None = None
confidence: float | None = Field(default=None, ge=0, le=1)
reason_code: HealthResolutionReason = HealthResolutionReason.NO_CURRENT_COMPONENT_EVIDENCE
explanation: str = "No current component-scoped health evidence is available."
conflict: bool = False
evidence: list[HealthEvidence] = Field(default_factory=list)
class ApplicationHealthResolution(BaseModel):
"""Aggregate health answer that keeps application and component scopes separate."""
status: HealthStatus = HealthStatus.UNKNOWN
confidence: float | None = Field(default=None, ge=0, le=1)
explanation: str = "No current application or component health evidence is available."
reason_code: HealthResolutionReason = HealthResolutionReason.NO_APPLICATION_EVIDENCE
selected_evidence_id: str | None = None
selected_provider: EvidenceProvider | None = None
evidence_freshness: EvidenceFreshness | None = None
conflict: bool = False
resolved_components: int = 0
unresolved_components: int = 0
component_coverage_percent: float = 0
class ProviderStatus(BaseModel):
provider: EvidenceProvider
status: ProviderHealth = ProviderHealth.UNKNOWN
observed_at: datetime
valid_until: datetime
detail: str = Field(default="", max_length=500)
metadata: dict[str, object] = Field(default_factory=dict)
class ServiceSnapshot(BaseModel):
service: Service
latest_probe: ProbeResult | None = None
availability_24h: AvailabilitySummary | None = None
dependency_status: HealthStatus = HealthStatus.UNKNOWN
effective_status: HealthStatus = HealthStatus.UNKNOWN
direct_status: HealthStatus = HealthStatus.UNKNOWN
flapping: bool = False
flap_count: int = 0
next_probe_at: datetime | None = None
dependents: list[str] = Field(default_factory=list)
sources: list[ServiceSource] = Field(default_factory=list)
evidence: list[RelationEvidence] = Field(default_factory=list)
health_evidence: list[HealthEvidence] = Field(default_factory=list)
health_resolution: HealthResolution = Field(default_factory=HealthResolution)
class AvailabilitySummary(BaseModel):
hours: int = 24
total_checks: int = 0
healthy_checks: int = 0
degraded_checks: int = 0
down_checks: int = 0
uptime_percent: float | None = None
average_latency_ms: float | None = None
last_checked_at: datetime | None = None
target_percent: float | None = None
meets_target: bool | None = None
class BulkProbeResult(BaseModel):
requested: int
completed: int
results: list[ProbeResult]
class IncidentCandidate(BaseModel):
service_id: str
service_name: str
status: HealthStatus
reason: str
affected_service_ids: list[str] = Field(default_factory=list)
affected_service_names: list[str] = Field(default_factory=list)
class IncidentRecord(BaseModel):
id: str
status: IncidentStatus
root_service_id: str
root_service_name: str
started_at: datetime
detected_at: datetime
acknowledged_at: datetime | None = None
resolved_at: datetime | None = None
title: str
root_cause_reason: str
affected_service_ids: list[str] = Field(default_factory=list)
affected_service_names: list[str] = Field(default_factory=list)
severity: Criticality = Criticality.MEDIUM
latest_update_at: datetime
class IncidentEvent(BaseModel):
id: str
incident_id: str
event_type: str
message: str
created_at: datetime
service_id: str | None = None
class EventRecord(BaseModel):
id: str
event_type: str
service_id: str | None = None
incident_id: str | None = None
from_status: HealthStatus | None = None
to_status: HealthStatus | None = None
message: str
metadata: dict[str, object] = Field(default_factory=dict)
created_at: datetime
class IncidentOverview(BaseModel):
generated_at: datetime
active: bool
candidates: list[IncidentCandidate]
degraded_service_ids: list[str]
down_service_ids: list[str]
incidents: list[IncidentRecord] = Field(default_factory=list)
class ServiceSource(BaseModel):
id: str
source_type: str
source_id: str
source_name: str | None = None
first_seen_at: datetime
last_seen_at: datetime
lifecycle: DiscoveryLifecycle = DiscoveryLifecycle.ACTIVE
managed: bool = True
metadata: dict[str, object] = Field(default_factory=dict)
class RelationEvidence(BaseModel):
id: str
source_service_id: str
target_service_id: str | None = None
target_ref: str | None = None
relation_type: RelationType = RelationType.DEPENDS_ON
evidence_type: str
confidence: DiscoveryConfidence
explanation: str
source_ref: str | None = None
locked: bool = False
active: bool = True
first_seen_at: datetime
last_seen_at: datetime
class LogicalRelation(BaseModel):
"""One canonical relationship with all independent supporting evidence."""
id: str
source_service_id: str
target_service_id: str | None = None
target_ref: str | None = None
relation_type: RelationType = RelationType.DEPENDS_ON
confidence: DiscoveryConfidence
active: bool = True
locked: bool = False
first_seen_at: datetime
last_seen_at: datetime
evidence: list[RelationEvidence] = Field(default_factory=list)
class SemanticComponent(BaseModel):
service_id: str
name: str
kind: ServiceKind
application_id: str
domain_id: str
role: ComponentRole = ComponentRole.UNKNOWN
direct_status: HealthStatus
effective_status: HealthStatus
unknown_reason: HealthResolutionReason | None = None
runtime_state: str | None = None
runtime_health: str | None = None
monitoring_state: MonitoringState
latest_latency_ms: float | None = None
dependency_ids: list[str] = Field(default_factory=list)
dependent_ids: list[str] = Field(default_factory=list)
root_cause: bool = False
affected: bool = False
class SemanticApplication(BaseModel):
id: str
name: str
domain_id: str
component_ids: list[str] = Field(default_factory=list)
component_count: int = 0
roles: list[ComponentRole] = Field(default_factory=list)
status: HealthStatus
status_explanation: str
health_resolution: ApplicationHealthResolution = Field(default_factory=ApplicationHealthResolution)
dependency_application_ids: list[str] = Field(default_factory=list)
dependent_application_ids: list[str] = Field(default_factory=list)
monitored_components: int = 0
unmonitored_components: int = 0
resolved_components: int = 0
unresolved_components: int = 0
component_coverage_percent: float = 0
root_cause: bool = False
affected: bool = False
class SemanticDomain(BaseModel):
id: str
name: str
application_ids: list[str] = Field(default_factory=list)
application_count: int = 0
status: HealthStatus
resolved_application_count: int = 0
unresolved_application_count: int = 0
application_coverage_percent: float = 0
incident_count: int = 0
root_cause: bool = False
affected: bool = False
class SemanticTopology(BaseModel):
generated_at: datetime
domains: list[SemanticDomain] = Field(default_factory=list)
applications: list[SemanticApplication] = Field(default_factory=list)
components: list[SemanticComponent] = Field(default_factory=list)
relations: list[LogicalRelation] = Field(default_factory=list)
monitoring_coverage: dict[str, int] = Field(default_factory=dict)
class DiscoveryObservation(BaseModel):
id: str
run_id: str
source_id: str
source_type: str
source_name: str
lifecycle: DiscoveryLifecycle
change_type: str
observed_at: datetime
metadata: dict[str, object] = Field(default_factory=dict)
class TopologySuggestion(BaseModel):
id: str
run_id: str
suggestion_type: str
status: SuggestionStatus = SuggestionStatus.OPEN
source_ref: str | None = None
source_service_id: str | None = None
target_ref: str | None = None
target_service_id: str | None = None
proposed_name: str | None = None
proposed_kind: ServiceKind | None = None
proposed_url: str | None = None
proposed_host: str | None = None
proposed_port: int | None = None
proposed_category: DiscoveryCategory = DiscoveryCategory.UNKNOWN
proposed_group: str | None = None
relation_type: RelationType | None = None
confidence: DiscoveryConfidence = DiscoveryConfidence.LOW
evidence: list[str] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class DiscoveryDiff(BaseModel):
new: int = 0
changed: int = 0
missing: int = 0
unchanged: int = 0
stale: int = 0
new_suggestions: int = 0
class DiscoveryRun(BaseModel):
id: str
status: DiscoveryRunStatus
started_at: datetime
completed_at: datetime | None = None
source_counts: dict[str, int] = Field(default_factory=dict)
diff: DiscoveryDiff = Field(default_factory=DiscoveryDiff)
error: str | None = None
class TopologySnapshot(BaseModel):
"""Compact, immutable capture of the canonical topology after a run."""
id: str
run_id: str | None = None
created_at: datetime
revision: str
service_count: int = 0
relationship_count: int = 0
summary: dict[str, int] = Field(default_factory=dict)
class TopologyChange(BaseModel):
change_type: str
entity_type: str
entity_id: str
name: str
before: dict[str, object] | None = None
after: dict[str, object] | None = None
class TopologyDiff(BaseModel):
before_snapshot_id: str | None = None
after_snapshot_id: str
added: list[TopologyChange] = Field(default_factory=list)
removed: list[TopologyChange] = Field(default_factory=list)
changed: list[TopologyChange] = Field(default_factory=list)
summary: dict[str, int] = Field(default_factory=dict)
class DiscoveryRunRequest(BaseModel):
docker_host: str | None = None
appops_url: str | None = None
include_stopped: bool = False
class DiscoveryReconcileRequest(BaseModel):
run_id: str | None = None
apply_high_confidence: bool = False
include_stopped: bool = False
class EdgeMutationRequest(BaseModel):
source_service_id: str
target_service_id: str
relation_type: RelationType = RelationType.DEPENDS_ON
lock: bool = True
explanation: str = Field(default="Manual topology relationship", max_length=500)
class EdgeActionRequest(BaseModel):
action: str = Field(pattern="^(confirm|reject|lock|remove)$")
class ConfigService(ServiceCreate):
pass
class ConfigDocument(BaseModel):
# Version 1 remains accepted. Version 2 is emitted when the document uses
# the richer service fields; the parser keeps the old empty v2 rejection
# compatibility used by the v1 contract tests.
version: int = Field(default=2, ge=1, le=2)
services: list[ConfigService] = Field(default_factory=list)
class ConfigImportRequest(BaseModel):
content: str
format: str = Field(pattern="^(yaml|json)$")
mode: str = Field(default="merge", pattern="^(merge|replace)$")
class ConfigImportResult(BaseModel):
created: int
updated: int
total: int
class DemoState(BaseModel):
active: bool
synthetic: bool = False
scenario_name: str | None = None
service_count: int = 0
root_cause_service_id: str | None = None
root_cause_service_name: str | None = None
affected_service_ids: list[str] = Field(default_factory=list)
affected_service_names: list[str] = Field(default_factory=list)
affected_count: int = 0
class DemoMutationResult(DemoState):
created: int = 0
updated: int = 0
total: int = 0
restored: bool = False
class DockerDiscoveryRequest(BaseModel):
docker_host: str = "unix:///var/run/docker.sock"
class DockerPort(BaseModel):
container_port: str
host_ip: str | None = None
host_port: int | None = None
class DockerSuggestion(BaseModel):
name: str
kind: ServiceKind
host: str | None = None
port: int | None = None
url: str | None = None
group: str | None = None
evidence: list[str] = Field(default_factory=list)
confidence: str = "low"
depends_on_names: list[str] = Field(default_factory=list)
source_type: str | None = None
source_ref: str | None = None
stable_source_ref: str | None = None
category: DiscoveryCategory = DiscoveryCategory.UNKNOWN
visibility: DiscoveryVisibility = DiscoveryVisibility.VISIBLE
relation_type: RelationType = RelationType.DEPENDS_ON
class ProbeEndpointCandidate(BaseModel):
"""A reachable-or-reviewable endpoint discovered for a Docker workload."""
source: ProbeEndpointSource
kind: ServiceKind
url: str | None = None
host: str | None = None
port: int | None = Field(default=None, ge=1, le=65535)
reachability: ProbeReachability = ProbeReachability.UNVERIFIED
confidence: DiscoveryConfidence = DiscoveryConfidence.LOW
primary: bool = False
evidence: str = ""
preflight_detail: str | None = None
preflight_latency_ms: float | None = None
class DockerCandidate(BaseModel):
container_id: str
name: str
image: str
state: str
health: str | None = None
networks: list[str]
ports: list[DockerPort]
labels: dict[str, str] = Field(default_factory=dict)
import_state: str = "new"
suggestions: list[DockerSuggestion] = Field(default_factory=list)
dependency_suggestions: list[str] = Field(default_factory=list)
stable_source_ref: str | None = None
compose_project: str | None = None
compose_service: str | None = None
category: DiscoveryCategory = DiscoveryCategory.UNKNOWN
visibility: DiscoveryVisibility = DiscoveryVisibility.VISIBLE
lifecycle: DiscoveryLifecycle = DiscoveryLifecycle.ACTIVE
healthcheck_present: bool = False
environment_evidence: list[str] = Field(default_factory=list)
proxy_route_evidence: list[str] = Field(default_factory=list)
appops_app_id: str | None = None
appops_category: str | None = None
appops_criticality: str | None = None
appops_health: str | None = None
appops_scope: EvidenceScope | None = None
appops_discovery_confidence: float | None = Field(default=None, ge=0, le=1)
appops_policy_profile: str | None = None
appops_app_type: str | None = None
probe_candidates: list[ProbeEndpointCandidate] = Field(default_factory=list)
class DockerDiscoveryResult(BaseModel):
docker_host: str
candidates: list[DockerCandidate]
proxy_routes: int = 0
appops_available: bool = False
appops_detail: str = ""
class DiscoveryStatus(BaseModel):
current: DiscoveryRun | None = None
last_completed: DiscoveryRun | None = None
active: bool = False
class DiscoveryResult(DockerDiscoveryResult):
run: DiscoveryRun
suggestions: list[TopologySuggestion] = Field(default_factory=list)
observations: list[DiscoveryObservation] = Field(default_factory=list)
appops_available: bool = False
appops_mappings: int = 0
proxy_routes: int = 0
class ReconcileResult(BaseModel):
run: DiscoveryRun
created: int = 0
updated: int = 0
unchanged: int = 0
missing: int = 0
retired: int = 0
accepted_relations: int = 0
open_suggestions: int = 0
service_ids: list[str] = Field(default_factory=list)
class ProbeRepairChange(BaseModel):
service_id: str
service_name: str
previous_monitoring_state: MonitoringState
next_monitoring_state: MonitoringState
previous_endpoint: str | None = None
next_endpoint: str | None = None
next_probe_source: ProbeEndpointSource | None = None
next_probe_confidence: DiscoveryConfidence | None = None
reason: str
class ProbeRepairReport(BaseModel):
run_id: str | None = None
dry_run: bool = True
inspected: int = 0
changed: int = 0
disabled_unsafe: int = 0
enabled_published: int = 0
docker_health_observed: int = 0
appops_observed: int = 0
runtime_observed: int = 0
unmonitored: int = 0
resolved_incidents: int = 0
changes: list[ProbeRepairChange] = Field(default_factory=list)
class OverviewSummary(BaseModel):
generated_at: datetime
total_services: int
health_counts: dict[str, int]
active_incidents: int
likely_root_causes: list[str] = Field(default_factory=list)
flapping_services: list[str] = Field(default_factory=list)
recent_events: list[EventRecord] = Field(default_factory=list)
monitoring_coverage: dict[str, int] = Field(default_factory=dict)
class MaintenanceRequest(BaseModel):
enabled: bool
reason: str | None = Field(default=None, max_length=240)
+206
View File
@@ -0,0 +1,206 @@
"""Recurring, side-effect-bounded infrastructure health observation."""
from __future__ import annotations
import os
import threading
from dataclasses import dataclass
from datetime import timedelta
from uuid import uuid4
from .docker_discovery import appops_inventory, observe_container_health
from .docker_provider import docker_provider_mode
from .evidence import appops_health_status, utc_now
from .models import (
EvidenceProvider,
EvidenceScope,
HealthEvidence,
HealthStatus,
ProviderHealth,
ProviderStatus,
)
from .store import Store
def observation_ttl(interval_seconds: int) -> timedelta:
"""Provider observation TTL with room for one delayed refresh cycle."""
return timedelta(seconds=max(150, interval_seconds * 3))
def _appops_status(value: str | None) -> HealthStatus | None:
return appops_health_status(value)
def _docker_status(state: str, health: str | None) -> HealthStatus | None:
if (health or "").casefold() in {"unhealthy", "down"} or state.casefold() in {"exited", "dead", "stopped"}:
return HealthStatus.DOWN
if (health or "").casefold() == "degraded" or state.casefold() == "restarting":
return HealthStatus.DEGRADED
if (health or "").casefold() == "healthy":
return HealthStatus.HEALTHY
return None
@dataclass(frozen=True)
class ObservationResult:
docker_observations: int = 0
appops_observations: int = 0
skipped: bool = False
class ObservationManager:
"""Refresh provider evidence only; discovery and reconciliation stay manual."""
def __init__(self, store: Store, *, interval_seconds: int) -> None:
self.store = store
self.interval_seconds = interval_seconds
self._lock = threading.Lock()
@staticmethod
def _docker_host() -> str:
return os.getenv("SERVICEMAP_DOCKER_HOST", "unix:///var/run/docker.sock")
@staticmethod
def _appops_url() -> str | None:
return os.getenv("SERVICEMAP_APPOPS_URL")
def refresh(self) -> ObservationResult:
if self.store.demo_active() or not self._lock.acquire(blocking=False):
return ObservationResult(skipped=True)
try:
now = utc_now()
valid_until = now + observation_ttl(self.interval_seconds)
services_by_ref = {
service.source_ref: service
for service in self.store.list_services()
if service.source_ref
}
persisted_app_links = self.store.application_evidence_links(EvidenceProvider.APPOPS)
appops = appops_inventory(self._appops_url())
if appops.available:
self.store.set_provider_status(
ProviderStatus(
provider=EvidenceProvider.APPOPS,
status=ProviderHealth.HEALTHY,
observed_at=now,
valid_until=valid_until,
detail=appops.detail,
metadata={"mapping_count": len(appops.by_container)},
)
)
else:
self.store.set_provider_status(
ProviderStatus(
provider=EvidenceProvider.APPOPS,
status=ProviderHealth.UNAVAILABLE,
observed_at=now,
valid_until=valid_until,
detail=appops.detail,
)
)
linked_apps: dict[str, set[str]] = {
app_id: set(service_ids) for app_id, service_ids in persisted_app_links.items()
}
docker_count = 0
try:
docker_observations = observe_container_health(self._docker_host())
if (
docker_provider_mode() == "broker"
and not docker_observations
and any(ref.startswith("docker:") for ref in services_by_ref)
):
raise ValueError("Docker broker returned no containers for configured services")
self.store.set_provider_status(
ProviderStatus(
provider=EvidenceProvider.DOCKER,
status=ProviderHealth.HEALTHY,
observed_at=now,
valid_until=valid_until,
detail=f"{len(docker_observations)} containers observed",
metadata={"container_count": len(docker_observations)},
)
)
for observation in docker_observations:
service = services_by_ref.get(observation.stable_source_ref)
if service is None:
continue
docker_count += 1
appops_info = appops.by_container.get(observation.name.casefold())
if appops_info:
linked_apps.setdefault(appops_info.app_id, set()).add(service.id)
status = _docker_status(observation.state, observation.health)
if status is None:
continue
self.store.record_health_evidence(
HealthEvidence(
id=str(uuid4()),
subject_type="service",
subject_id=service.id,
service_id=service.id,
provider=EvidenceProvider.DOCKER,
provider_ref=observation.stable_source_ref,
status=status,
scope=EvidenceScope.COMPONENT,
confidence=0.95 if observation.healthcheck_present else 0.8,
observed_at=now,
valid_until=valid_until,
received_at=now,
summary="Docker healthcheck" if observation.healthcheck_present else "Docker runtime state",
metadata={
"container_id": observation.container_id,
"state": observation.state,
"health": observation.health,
},
)
)
except (OSError, RuntimeError, ValueError) as exc:
self.store.set_provider_status(
ProviderStatus(
provider=EvidenceProvider.DOCKER,
status=ProviderHealth.UNAVAILABLE,
observed_at=now,
valid_until=valid_until,
detail=str(exc)[:500],
)
)
appops_count = 0
if appops.available:
for app_id, service_ids in linked_apps.items():
info = next(
(item for item in appops.by_container.values() if item.app_id == app_id),
None,
)
status = _appops_status(info.health if info else None)
if info is None or status is None or not service_ids:
continue
appops_count += 1
self.store.record_health_evidence(
HealthEvidence(
id=str(uuid4()),
subject_type="application",
subject_id=f"appops:{app_id}",
provider=EvidenceProvider.APPOPS,
provider_ref=app_id,
status=status,
scope=EvidenceScope.APPLICATION,
confidence=info.discovery_confidence if info.discovery_confidence is not None else 0.5,
observed_at=now,
valid_until=valid_until,
received_at=now,
summary="AppOps application health; never used as component direct health",
metadata={
"app_id": app_id,
"category": info.category,
"policy_profile": info.policy_profile,
"app_type": info.app_type,
"discovery_confidence": info.discovery_confidence,
},
),
service_ids=sorted(service_ids),
)
return ObservationResult(docker_observations=docker_count, appops_observations=appops_count)
finally:
self._lock.release()
+204
View File
@@ -0,0 +1,204 @@
from __future__ import annotations
import asyncio
import contextvars
import os
import socket
import ssl
from datetime import UTC, datetime
from time import perf_counter
from uuid import uuid4
import httpx
from .models import HealthStatus, ProbeResult, Service, ServiceKind
_probe_metadata: contextvars.ContextVar[dict[str, object] | None] = contextvars.ContextVar(
"servicemap_probe_metadata", default=None
)
_service_locks: dict[str, asyncio.Lock] = {}
def _lock_for(service_id: str) -> asyncio.Lock:
lock = _service_locks.get(service_id)
if lock is None:
lock = asyncio.Lock()
_service_locks[service_id] = lock
return lock
def _http_status(status_code: int, service: Service) -> HealthStatus:
minimum = service.expected_status_min
maximum = service.expected_status_max
if minimum is not None or maximum is not None:
if (minimum is not None and status_code < minimum) or (maximum is not None and status_code > maximum):
return HealthStatus.DOWN if status_code >= 500 else HealthStatus.DEGRADED
return HealthStatus.HEALTHY
if status_code < 400:
return HealthStatus.HEALTHY
if status_code < 500:
return HealthStatus.DEGRADED
return HealthStatus.DOWN
async def _probe_once_with_metadata(service: Service, timeout_seconds: float) -> tuple[HealthStatus, str, dict[str, object]]:
if service.kind == ServiceKind.HTTP:
async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client:
response = await client.get(service.url or "")
status = _http_status(response.status_code, service)
return status, f"HTTP {response.status_code}", {"status_code": response.status_code}
if service.kind == ServiceKind.DNS:
started = perf_counter()
infos = await asyncio.wait_for(
asyncio.get_running_loop().getaddrinfo(service.host, None, type=socket.SOCK_STREAM),
timeout=timeout_seconds,
)
addresses = sorted({str(info[4][0]) for info in infos if info[4]})
if not addresses:
return HealthStatus.DOWN, f"DNS {service.host} returned no addresses", {"addresses": []}
return (
HealthStatus.HEALTHY,
f"DNS {service.host} resolved ({round((perf_counter() - started) * 1000, 2)} ms)",
{"addresses": addresses[:20]},
)
if service.kind == ServiceKind.TLS:
context = ssl.create_default_context()
reader, writer = await asyncio.wait_for(
asyncio.open_connection(service.host, service.port, ssl=context, server_hostname=service.host),
timeout=timeout_seconds,
)
del reader
ssl_object = writer.get_extra_info("ssl_object")
metadata: dict[str, object] = {"hostname": service.host, "port": service.port}
detail = f"TLS {service.host}:{service.port} certificate reachable"
status = HealthStatus.HEALTHY
if ssl_object is not None:
certificate = ssl_object.getpeercert() or {}
not_after = certificate.get("notAfter") if isinstance(certificate, dict) else None
if not_after:
expires_at = datetime.fromtimestamp(ssl.cert_time_to_seconds(not_after), UTC)
days_remaining = (expires_at - datetime.now(UTC)).total_seconds() / 86400
metadata.update({"expires_at": expires_at.isoformat(), "days_remaining": round(days_remaining, 2)})
warning_days = max(1, int(os.getenv("SERVICEMAP_TLS_WARNING_DAYS", "30")))
if days_remaining <= 0:
status = HealthStatus.DOWN
detail = f"TLS certificate expired on {expires_at.date().isoformat()}"
elif days_remaining <= warning_days:
status = HealthStatus.DEGRADED
detail = f"TLS certificate expires in {round(days_remaining, 1)} days"
writer.close()
await writer.wait_closed()
return status, detail, metadata
_, writer = await asyncio.wait_for(
asyncio.open_connection(service.host, service.port),
timeout=timeout_seconds,
)
writer.close()
await writer.wait_closed()
return HealthStatus.HEALTHY, f"TCP {service.host}:{service.port} reachable", {}
async def _probe_once(service: Service, timeout_seconds: float) -> tuple[HealthStatus, str]:
status, detail, metadata = await _probe_once_with_metadata(service, timeout_seconds)
_probe_metadata.set(metadata)
return status, detail
async def probe_service(
service: Service,
timeout_seconds: float | None = 5.0,
retries: int | None = 1,
*,
trigger: str = "manual",
default_timeout_seconds: float = 5.0,
default_retries: int = 1,
) -> ProbeResult:
if not service.enabled or not service.monitoring_enabled:
return ProbeResult(
id=str(uuid4()),
service_id=service.id,
status=HealthStatus.UNKNOWN,
latency_ms=None,
detail="Monitoring disabled for service",
checked_at=datetime.now(UTC),
trigger=trigger,
)
timeout = service.timeout_seconds if timeout_seconds is None else timeout_seconds
timeout = timeout if timeout is not None else default_timeout_seconds
retry_count = service.retries if retries is None else retries
retry_count = retry_count if retry_count is not None else default_retries
attempts = max(1, retry_count + 1)
status = HealthStatus.DOWN
detail = "Probe failed"
latency_ms: float | None = None
metadata: dict[str, object] = {}
async with _lock_for(service.id):
for attempt in range(1, attempts + 1):
_probe_metadata.set({})
started = perf_counter()
try:
status, detail = await _probe_once(service, timeout)
metadata = _probe_metadata.get() or {}
latency_ms = round((perf_counter() - started) * 1000, 2)
if attempt > 1:
detail = f"{detail} after {attempt} attempts"
break
except (TimeoutError, httpx.HTTPError, OSError, ssl.SSLError, socket.gaierror) as exc:
latency_ms = round((perf_counter() - started) * 1000, 2)
detail = f"{type(exc).__name__}: {exc}"
if isinstance(exc, ssl.CertificateError):
status = HealthStatus.DOWN
detail = f"TLS hostname mismatch: {exc}"
if attempt < attempts:
await asyncio.sleep(min(0.2 * attempt, 1.0))
return ProbeResult(
id=str(uuid4()),
service_id=service.id,
status=status,
latency_ms=latency_ms,
detail=detail[:500],
checked_at=datetime.now(UTC),
trigger=trigger,
metadata=metadata,
)
async def probe_many(
services: list[Service],
*,
timeout_seconds: float | None = 5.0,
retries: int | None = 1,
concurrency: int = 10,
trigger: str = "manual",
default_timeout_seconds: float = 5.0,
default_retries: int = 1,
) -> list[ProbeResult]:
semaphore = asyncio.Semaphore(max(1, concurrency))
async def run(service: Service) -> ProbeResult:
async with semaphore:
if trigger == "manual" and default_timeout_seconds == 5.0 and default_retries == 1:
return await probe_service(service, timeout_seconds=timeout_seconds, retries=retries)
if trigger == "manual":
return await probe_service(
service,
timeout_seconds=timeout_seconds,
retries=retries,
default_timeout_seconds=default_timeout_seconds,
default_retries=default_retries,
)
return await probe_service(
service,
timeout_seconds=timeout_seconds,
retries=retries,
trigger=trigger,
default_timeout_seconds=default_timeout_seconds,
default_retries=default_retries,
)
return list(await asyncio.gather(*(run(service) for service in services)))
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import asyncio
import logging
from time import monotonic
from .probes import probe_many
from .store import Store
logger = logging.getLogger(__name__)
async def scheduled_probe_loop(
store: Store,
*,
interval_seconds: int,
timeout_seconds: float,
retries: int,
concurrency: int,
retention_days: int,
) -> None:
next_due: dict[str, float] = {}
while True:
sleep_seconds = interval_seconds
try:
# The Explore demo is deliberately synthetic. Pausing scheduled
# probes while it is active keeps its seeded incident and history
# deterministic and avoids any dependency on external services.
if not getattr(store, "demo_active", lambda: False)():
services = store.list_services()
now = monotonic()
due = [
service
for service in services
if service.enabled
and service.monitoring_enabled
and now >= next_due.get(service.id, 0)
]
if due:
results = await probe_many(
due,
timeout_seconds=None,
retries=None,
concurrency=concurrency,
trigger="scheduled",
default_timeout_seconds=timeout_seconds,
default_retries=retries,
)
for result in results:
store.save_probe(result)
for service in due:
next_due[service.id] = now + (service.interval_seconds or interval_seconds)
next_intervals = [
service.interval_seconds or interval_seconds
for service in services
if service.enabled and service.monitoring_enabled
]
sleep_seconds = min([interval_seconds, *next_intervals]) if next_intervals else interval_seconds
else:
sleep_seconds = interval_seconds
store.prune_probes(retention_days)
except asyncio.CancelledError:
raise
except Exception: # pragma: no cover - defensive daemon boundary
logger.exception("Scheduled probe cycle failed")
await asyncio.sleep(max(1, sleep_seconds))
+429
View File
@@ -0,0 +1,429 @@
"""Deterministic semantic topology derived from the canonical service graph.
The discovery store remains the source of truth. This module deliberately
does not persist a second topology: a domain, application and component view
can therefore evolve without risking probe history or an operator's graph.
"""
from __future__ import annotations
import hashlib
import re
from collections import defaultdict
from datetime import UTC, datetime
from .evidence import with_freshness
from .models import (
ApplicationHealthResolution,
ComponentRole,
Criticality,
EvidenceFreshness,
EvidenceScope,
HealthResolutionReason,
HealthStatus,
LogicalRelation,
SemanticApplication,
SemanticComponent,
SemanticDomain,
SemanticTopology,
Service,
ServiceSnapshot,
)
_ROLE_BY_CATEGORY = {
"database": ComponentRole.DATABASE,
"cache": ComponentRole.CACHE,
"proxy": ComponentRole.PROXY,
"background_worker": ComponentRole.WORKER,
"storage": ComponentRole.STORAGE,
"monitoring": ComponentRole.MONITORING,
}
_ROLE_HINTS = (
(ComponentRole.DATABASE, ("postgres", "mariadb", "mysql", "mongo", "influx", "database", "db")),
(ComponentRole.CACHE, ("redis", "memcache", "cache")),
(ComponentRole.PROXY, ("proxy", "nginx", "traefik", "caddy", "swag", "haproxy", "cloudflare")),
(ComponentRole.WORKER, ("worker", "runner", "queue", "task")),
(ComponentRole.STORAGE, ("minio", "storage", "s3", "volume", "files")),
(ComponentRole.MONITORING, ("grafana", "prometheus", "zabbix", "netdata", "loki", "uptime")),
(ComponentRole.AGENT, ("agent", "exporter", "sidecar")),
(ComponentRole.SCHEDULER, ("scheduler", "cron", "schedule")),
(ComponentRole.FRONTEND, ("frontend", "webui", "dashboard", "ui")),
(ComponentRole.API, ("api", "backend", "server")),
)
_DOMAIN_HINTS = (
("Edge", ("proxy", "nginx", "traefik", "caddy", "swag", "cloudflare", "authelia", "auth")),
("Media", ("plex", "jellyfin", "sonarr", "radarr", "lidarr", "readarr", "bazarr", "media", "tautulli")),
("Dev", ("gitea", "git", "code", "dev", "ci", "runner")),
("Monitoring", ("grafana", "prometheus", "zabbix", "netdata", "loki", "uptime", "monitor")),
("AI", ("ollama", "open webui", "comfy", "ai", "llm")),
("Storage", ("postgres", "mariadb", "mysql", "mongo", "redis", "minio", "storage", "nextcloud", "file")),
("Automation", ("n8n", "worker", "scheduler", "cron", "automation", "arr")),
("Productivity", ("book", "paperless", "vault", "wiki", "project", "calendar", "office")),
)
def _slug(prefix: str, value: str) -> str:
normalized = re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-") or "unknown"
digest = hashlib.sha1(value.casefold().encode("utf-8")).hexdigest()[:8]
return f"{prefix}:{normalized[:48]}-{digest}"
def component_role(service: Service) -> ComponentRole:
if service.component_role_locked or service.component_role != ComponentRole.UNKNOWN:
return service.component_role
category_role = _ROLE_BY_CATEGORY.get(service.category.value)
if category_role:
return category_role
haystack = " ".join(filter(None, (service.name, service.container_image, service.source_ref, service.description))).casefold()
for role, hints in _ROLE_HINTS:
if any(hint in haystack for hint in hints):
return role
# A monitoring gap says nothing about the component's workload role. Keep
# it unknown until source metadata, a deterministic hint or an operator
# override supplies evidence.
return ComponentRole.UNKNOWN
def application_name(service: Service) -> str:
"""Use stable provenance before falling back to a standalone application.
Category is intentionally *not* used as a shared application name. Doing
so was the old "Independent · Category" compaction bug: unrelated services
looked like one stack. Categories still inform domain derivation below.
"""
if service.application:
return service.application.strip()
if service.compose_project:
return service.compose_project.replace("_", " ").replace("-", " ").title()
ref = service.source_ref or ""
compose = re.match(r"docker:compose:([^:]+):", ref, flags=re.IGNORECASE)
if compose:
return compose.group(1).replace("_", " ").replace("-", " ").title()
name = re.sub(r"(?:[-_ ](?:server|web|api|db|database|redis|worker|frontend|backend))$", "", service.name, flags=re.IGNORECASE).strip()
return name or service.name
def application_identity(service: Service) -> tuple[str, str]:
"""Return a stable grouping key plus its operator-facing display name.
Compose provenance wins over a generic display label. This keeps two
unrelated ``PostgreSQL`` containers from being merged merely because an
aggregate provider happens to use the same name for both applications.
Explicit operator grouping remains authoritative.
"""
name = application_name(service)
if service.application_locked and service.application:
return f"operator:{service.application.casefold().strip()}", name
if service.compose_project:
return f"compose:{service.compose_project.casefold().strip()}", name
ref = service.source_ref or ""
compose = re.match(r"docker:compose:([^:]+):", ref, flags=re.IGNORECASE)
if compose:
return f"compose:{compose.group(1).casefold()}", name
if ref:
return f"source:{ref.casefold()}", name
if service.application:
return f"application:{service.application.casefold().strip()}", name
return f"service:{service.id}", name
def domain_name(service: Service, role: ComponentRole, application: str) -> str:
if service.domain:
return service.domain.strip()
haystack = " ".join(filter(None, (application, service.name, service.container_image, service.category.value))).casefold()
for domain, hints in _DOMAIN_HINTS:
if any(hint in haystack for hint in hints):
return domain
if role in {ComponentRole.DATABASE, ComponentRole.CACHE, ComponentRole.STORAGE}:
return "Storage"
if role in {ComponentRole.PROXY}:
return "Edge"
if role in {ComponentRole.MONITORING, ComponentRole.AGENT}:
return "Monitoring"
return "Infrastructure" if service.category.value in {"infrastructure", "system"} else "Productivity"
def _application_resolution(
members: list[ServiceSnapshot], roles: dict[str, ComponentRole]
) -> ApplicationHealthResolution:
"""Resolve application health without promoting aggregate evidence to a component.
A fresh, high-confidence application observation can answer the health of
its application even where individual components are intentionally not
probed. It can never change a component's direct status. Conversely a
concrete component failure always outweighs a weak aggregate healthy
claim, which preserves conflict visibility instead of masking incidents.
"""
resolved = [
item
for item in members
if item.direct_status not in {HealthStatus.UNKNOWN, HealthStatus.UNMONITORED}
]
unresolved = len(members) - len(resolved)
coverage = round((len(resolved) / len(members)) * 100, 1) if members else 0.0
application_evidence = {
item.id: with_freshness(item)
for snapshot in members
for item in snapshot.health_evidence
if item.active and item.scope == EvidenceScope.APPLICATION
}
fresh_claims = [
item
for item in application_evidence.values()
if item.freshness == EvidenceFreshness.FRESH
]
selected = max(
fresh_claims,
key=lambda item: (item.confidence, item.observed_at, item.received_at, item.id),
default=None,
)
direct_down = [item for item in members if item.direct_status == HealthStatus.DOWN]
impacted = [
item
for item in members
if item.effective_status in {HealthStatus.DOWN, HealthStatus.DEGRADED, HealthStatus.RECOVERING}
and item.direct_status != HealthStatus.DOWN
]
conflicting_aggregate = bool(
selected
and (
(selected.status == HealthStatus.HEALTHY and direct_down)
or (selected.status in {HealthStatus.DOWN, HealthStatus.DEGRADED} and resolved)
)
)
common = {
"selected_evidence_id": selected.id if selected else None,
"selected_provider": selected.provider if selected else None,
"evidence_freshness": selected.freshness if selected else None,
"conflict": conflicting_aggregate,
"resolved_components": len(resolved),
"unresolved_components": unresolved,
"component_coverage_percent": coverage,
}
if direct_down:
primary = [
item
for item in direct_down
if item.service.criticality == Criticality.CRITICAL
or roles[item.service.id] in {ComponentRole.FRONTEND, ComponentRole.API, ComponentRole.PROXY}
]
status = HealthStatus.DOWN if len(members) == 1 or len(direct_down) == len(resolved) or primary else HealthStatus.DEGRADED
names = ", ".join(item.service.name for item in direct_down[:3])
return ApplicationHealthResolution(
status=status,
confidence=max((item.health_resolution.confidence or 1.0 for item in direct_down), default=None),
explanation=f"{names} has current component failure evidence; aggregate health does not override it.",
reason_code=HealthResolutionReason.COMPONENT_FAILURE,
**common,
)
if impacted:
names = ", ".join(item.service.name for item in impacted[:3])
return ApplicationHealthResolution(
status=HealthStatus.DEGRADED,
confidence=selected.confidence if selected else None,
explanation=f"{names} is impacted through a dependency health signal.",
reason_code=HealthResolutionReason.COMPONENT_IMPACT,
**common,
)
if selected and selected.confidence >= 0.7 and selected.status not in {HealthStatus.UNKNOWN, HealthStatus.UNMONITORED}:
return ApplicationHealthResolution(
status=selected.status,
confidence=selected.confidence,
explanation=(
f"{selected.provider.value} application observation selected "
f"(confidence {selected.confidence:.2f}); component coverage is {coverage:.0f}% ."
),
reason_code=HealthResolutionReason.SELECTED_APPLICATION_EVIDENCE,
**common,
)
if resolved:
status = HealthStatus.HEALTHY
if any(item.direct_status in {HealthStatus.DEGRADED, HealthStatus.RECOVERING} for item in resolved):
status = HealthStatus.DEGRADED
return ApplicationHealthResolution(
status=status,
explanation=f"{len(resolved)} of {len(members)} components have current direct health evidence.",
reason_code=HealthResolutionReason.COMPONENT_COVERAGE,
**common,
)
stale = list(application_evidence.values())
if stale:
reason = (
HealthResolutionReason.STALE_EVIDENCE
if any(item.freshness == EvidenceFreshness.STALE for item in stale)
else HealthResolutionReason.EXPIRED_EVIDENCE
)
return ApplicationHealthResolution(
status=HealthStatus.UNKNOWN,
explanation="Application evidence is no longer current; waiting for a provider refresh.",
reason_code=reason,
**common,
)
if members and all(item.direct_status == HealthStatus.UNMONITORED for item in members):
return ApplicationHealthResolution(
status=HealthStatus.UNMONITORED,
explanation="No active health signal is configured for this application.",
reason_code=HealthResolutionReason.NO_APPLICATION_EVIDENCE,
**common,
)
return ApplicationHealthResolution(
status=HealthStatus.UNKNOWN,
explanation="No current application or component health evidence is available.",
reason_code=HealthResolutionReason.NO_APPLICATION_EVIDENCE,
**common,
)
def build_semantic_topology(
snapshots: list[ServiceSnapshot],
relations: list[LogicalRelation],
root_service_ids: set[str] | None = None,
affected_service_ids: set[str] | None = None,
) -> SemanticTopology:
root_service_ids = root_service_ids or set()
affected_service_ids = affected_service_ids or set()
snapshot_by_id = {item.service.id: item for item in snapshots}
roles = {item.service.id: component_role(item.service) for item in snapshots}
apps_by_key: dict[str, list[ServiceSnapshot]] = defaultdict(list)
app_names: dict[str, str] = {}
app_domain_names: dict[str, str] = {}
component_app_ids: dict[str, str] = {}
components: list[SemanticComponent] = []
dependent_ids: dict[str, list[str]] = defaultdict(list)
for snapshot in snapshots:
for dependency_id in snapshot.service.depends_on:
dependent_ids[dependency_id].append(snapshot.service.id)
for snapshot in snapshots:
application_key, application = application_identity(snapshot.service)
apps_by_key[application_key].append(snapshot)
app_names[application_key] = application
role = roles[snapshot.service.id]
app_domain_names.setdefault(application_key, domain_name(snapshot.service, role, application))
component_app_ids[snapshot.service.id] = _slug("app", application_key)
applications: list[SemanticApplication] = []
application_by_id: dict[str, SemanticApplication] = {}
domain_name_by_id: dict[str, str] = {}
for application_key, members in sorted(apps_by_key.items(), key=lambda item: app_names[item[0]].casefold()):
name = app_names[application_key]
app_id = _slug("app", application_key)
domain = app_domain_names[application_key]
domain_id = _slug("domain", domain)
domain_name_by_id[domain_id] = domain
resolution = _application_resolution(members, roles)
unmonitored = sum(item.direct_status == HealthStatus.UNMONITORED for item in members)
app = SemanticApplication(
id=app_id,
name=name,
domain_id=domain_id,
component_ids=[item.service.id for item in members],
component_count=len(members),
roles=sorted({roles[item.service.id] for item in members}, key=lambda role: role.value),
status=resolution.status,
status_explanation=resolution.explanation,
health_resolution=resolution,
monitored_components=resolution.resolved_components,
unmonitored_components=unmonitored,
resolved_components=resolution.resolved_components,
unresolved_components=resolution.unresolved_components,
component_coverage_percent=resolution.component_coverage_percent,
root_cause=any(item.service.id in root_service_ids for item in members),
affected=any(item.service.id in affected_service_ids for item in members),
)
applications.append(app)
application_by_id[app_id] = app
for snapshot in snapshots:
app_id = component_app_ids[snapshot.service.id]
app = application_by_id[app_id]
components.append(SemanticComponent(
service_id=snapshot.service.id,
name=snapshot.service.name,
kind=snapshot.service.kind,
application_id=app_id,
domain_id=app.domain_id,
role=roles[snapshot.service.id],
direct_status=snapshot.direct_status,
effective_status=snapshot.effective_status,
unknown_reason=(
snapshot.health_resolution.reason_code
if snapshot.direct_status == HealthStatus.UNKNOWN
else None
),
runtime_state=snapshot.service.runtime_state,
runtime_health=snapshot.service.runtime_health,
monitoring_state=snapshot.service.monitoring_state,
latest_latency_ms=snapshot.latest_probe.latency_ms if snapshot.latest_probe else None,
dependency_ids=[item for item in snapshot.service.depends_on if item in snapshot_by_id],
dependent_ids=dependent_ids.get(snapshot.service.id, []),
root_cause=snapshot.service.id in root_service_ids,
affected=snapshot.service.id in affected_service_ids,
))
app_dependencies: dict[str, set[str]] = defaultdict(set)
app_dependents: dict[str, set[str]] = defaultdict(set)
for relation in relations:
if not relation.active or not relation.target_service_id:
continue
source_app = component_app_ids.get(relation.source_service_id)
target_app = component_app_ids.get(relation.target_service_id)
if source_app and target_app and source_app != target_app:
app_dependencies[source_app].add(target_app)
app_dependents[target_app].add(source_app)
for app in applications:
app.dependency_application_ids = sorted(app_dependencies[app.id])
app.dependent_application_ids = sorted(app_dependents[app.id])
apps_by_domain: dict[str, list[SemanticApplication]] = defaultdict(list)
domain_names: dict[str, str] = {}
for app in applications:
apps_by_domain[app.domain_id].append(app)
domain_names[app.domain_id] = domain_name_by_id[app.domain_id]
domains: list[SemanticDomain] = []
for domain_id, members in sorted(apps_by_domain.items(), key=lambda item: domain_names[item[0]].casefold()):
down = [item for item in members if item.status == HealthStatus.DOWN]
degraded = [
item for item in members if item.status in {HealthStatus.DEGRADED, HealthStatus.RECOVERING}
]
healthy = [item for item in members if item.status == HealthStatus.HEALTHY]
resolved = [
item
for item in members
if item.status not in {HealthStatus.UNKNOWN, HealthStatus.UNMONITORED}
]
if down:
status = HealthStatus.DOWN
elif degraded:
status = HealthStatus.DEGRADED
elif healthy:
status = HealthStatus.HEALTHY
elif members and all(item.status == HealthStatus.UNMONITORED for item in members):
status = HealthStatus.UNMONITORED
else:
status = HealthStatus.UNKNOWN
domains.append(SemanticDomain(
id=domain_id,
name=domain_names[domain_id],
application_ids=[item.id for item in members],
application_count=len(members),
status=status,
resolved_application_count=len(resolved),
unresolved_application_count=len(members) - len(resolved),
application_coverage_percent=round((len(resolved) / len(members)) * 100, 1) if members else 0,
incident_count=sum(item.root_cause for item in members),
root_cause=any(item.root_cause for item in members),
affected=any(item.affected for item in members),
))
coverage: dict[str, int] = defaultdict(int)
for snapshot in snapshots:
coverage[snapshot.service.monitoring_state.value] += 1
return SemanticTopology(
generated_at=datetime.now(UTC),
domains=domains,
applications=applications,
components=components,
relations=relations,
monitoring_coverage=dict(coverage),
)
+2012
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
[project]
name = "servicemap-backend"
version = "1.6.2"
description = "ServiceMap API, topology intelligence and probe engine"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.116,<1",
"uvicorn[standard]>=0.35,<1",
"httpx>=0.28,<1",
"pydantic>=2.11,<3",
"pyyaml>=6.0,<7",
"docker>=7.1,<8",
"PyJWT[crypto]>=2.10,<3",
]
[project.optional-dependencies]
dev = [
"pytest>=8.4,<9",
"ruff>=0.12,<1",
]
[tool.pytest.ini_options]
pythonpath = ["."]
[tool.ruff]
line-length = 100
+106
View File
@@ -0,0 +1,106 @@
#
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# pip-compile --extra=dev --output-file=backend/requirements-dev.lock --strip-extras backend/pyproject.toml
#
annotated-doc==0.0.5
# via fastapi
annotated-types==0.8.0
# via pydantic
anyio==4.15.1
# via
# httpx
# starlette
# watchfiles
certifi==2026.7.22
# via
# httpcore
# httpx
# requests
cffi==2.1.1
# via cryptography
charset-normalizer==3.5.1
# via requests
click==8.5.0
# via uvicorn
colorama==0.4.6
# via pytest
cryptography==50.0.1
# via pyjwt
docker==7.2.0
# via servicemap-backend (backend/pyproject.toml)
fastapi==0.141.1
# via servicemap-backend (backend/pyproject.toml)
h11==0.16.0
# via
# httpcore
# uvicorn
httpcore==1.0.9
# via httpx
httptools==0.8.0
# via uvicorn
httpx==0.28.1
# via servicemap-backend (backend/pyproject.toml)
idna==3.19
# via
# anyio
# httpx
# requests
iniconfig==2.3.0
# via pytest
packaging==26.3
# via pytest
pluggy==1.6.0
# via pytest
pycparser==3.0
# via cffi
pydantic==2.13.5
# via
# fastapi
# servicemap-backend (backend/pyproject.toml)
pydantic-core==2.46.5
# via pydantic
pygments==2.21.0
# via pytest
pyjwt==2.14.0
# via
# pyjwt
# servicemap-backend (backend/pyproject.toml)
pytest==8.4.2
# via servicemap-backend (backend/pyproject.toml)
python-dotenv==1.2.3
# via uvicorn
pywin32==312 ; sys_platform == "win32"
# via docker
pyyaml==6.0.3
# via
# servicemap-backend (backend/pyproject.toml)
# uvicorn
requests==2.34.2
# via docker
ruff==0.16.7
# via servicemap-backend (backend/pyproject.toml)
starlette==1.6.0
# via fastapi
typing-extensions==4.16.0
# via
# anyio
# fastapi
# pydantic
# pydantic-core
# typing-inspection
typing-inspection==0.4.4
# via
# fastapi
# pydantic
urllib3==2.7.0
# via
# docker
# requests
uvicorn==0.52.4
# via servicemap-backend (backend/pyproject.toml)
watchfiles==1.2.0
# via uvicorn
websockets==17.1
# via uvicorn
+92
View File
@@ -0,0 +1,92 @@
#
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# pip-compile --output-file=backend/requirements.lock --strip-extras backend/pyproject.toml
#
annotated-doc==0.0.5
# via fastapi
annotated-types==0.8.0
# via pydantic
anyio==4.15.1
# via
# httpx
# starlette
# watchfiles
certifi==2026.7.22
# via
# httpcore
# httpx
# requests
cffi==2.1.1
# via cryptography
charset-normalizer==3.5.1
# via requests
click==8.5.0
# via uvicorn
cryptography==50.0.1
# via pyjwt
docker==7.2.0
# via servicemap-backend (backend/pyproject.toml)
fastapi==0.141.1
# via servicemap-backend (backend/pyproject.toml)
h11==0.16.0
# via
# httpcore
# uvicorn
httpcore==1.0.9
# via httpx
httptools==0.8.0
# via uvicorn
httpx==0.28.1
# via servicemap-backend (backend/pyproject.toml)
idna==3.19
# via
# anyio
# httpx
# requests
pycparser==3.0
# via cffi
pydantic==2.13.5
# via
# fastapi
# servicemap-backend (backend/pyproject.toml)
pydantic-core==2.46.5
# via pydantic
pyjwt==2.14.0
# via
# pyjwt
# servicemap-backend (backend/pyproject.toml)
python-dotenv==1.2.3
# via uvicorn
pywin32==312 ; sys_platform == "win32"
# via docker
pyyaml==6.0.3
# via
# servicemap-backend (backend/pyproject.toml)
# uvicorn
requests==2.34.2
# via docker
starlette==1.6.0
# via fastapi
typing-extensions==4.16.0
# via
# anyio
# fastapi
# pydantic
# pydantic-core
# typing-inspection
typing-inspection==0.4.4
# via
# fastapi
# pydantic
urllib3==2.7.0
# via
# docker
# requests
uvicorn==0.52.4
# via servicemap-backend (backend/pyproject.toml)
watchfiles==1.2.0
# via uvicorn
websockets==17.1
# via uvicorn
+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"