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)