Files
ServiceMap-Public/backend/app/observation.py
T

207 lines
8.7 KiB
Python

"""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()