863 lines
27 KiB
Python
863 lines
27 KiB
Python
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)
|