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