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,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")
|
||||
Reference in new issue
Block a user