744 lines
30 KiB
Python
744 lines
30 KiB
Python
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")
|