from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime, timezone from enum import StrEnum from typing import Any DNS_RECORDS = "dns_records" PROXY_HOSTS = "proxy_hosts" CERTIFICATES = "certificates" class CapabilityOperation(StrEnum): DISCOVER = "discover" APPLY = "apply" VERIFY = "verify" class PlanOperation(StrEnum): CREATE = "create" UPDATE = "update" REPLACE = "replace" DELETE = "delete" NOOP = "noop" SKIP = "skip" @dataclass(frozen=True, order=True) class Capability: object_type: str operation: CapabilityOperation def __post_init__(self) -> None: object_type = self.object_type.strip().lower() if not object_type: raise ValueError("Capability object_type cannot be empty") object.__setattr__(self, "object_type", object_type) @dataclass(frozen=True) class Endpoint: plugin: str address: str label: str | None = None def __post_init__(self) -> None: plugin = self.plugin.strip().lower() address = self.address.strip().rstrip("/").lower() if not plugin: raise ValueError("Endpoint plugin cannot be empty") if not address: raise ValueError("Endpoint address cannot be empty") object.__setattr__(self, "plugin", plugin) object.__setattr__(self, "address", address) @property def identity(self) -> str: return f"{self.plugin}://{self.address}" @dataclass(frozen=True, order=True) class ObjectReference: relation: str object_type: str local_key: str | None = None logical_key: str | None = None def __post_init__(self) -> None: relation = self.relation.strip().lower() object_type = self.object_type.strip().lower() if not relation: raise ValueError("Object reference relation cannot be empty") if not object_type: raise ValueError( "Object reference object_type cannot be empty" ) if (self.local_key is None) == (self.logical_key is None): raise ValueError( "Object reference requires exactly one of " "local_key or logical_key" ) object.__setattr__(self, "relation", relation) object.__setattr__(self, "object_type", object_type) if self.local_key is not None: if not isinstance(self.local_key, str): raise ValueError( "Object reference local_key must be a string" ) local_key = self.local_key.strip() if not local_key: raise ValueError( "Object reference local_key cannot be empty" ) object.__setattr__(self, "local_key", local_key) if self.logical_key is not None: if not isinstance(self.logical_key, str): raise ValueError( "Object reference logical_key must be a string" ) logical_key = self.logical_key.strip() if not logical_key: raise ValueError( "Object reference logical_key cannot be empty" ) object.__setattr__(self, "logical_key", logical_key) def _normalized_references( references, ) -> tuple[ObjectReference, ...]: normalized = tuple(references) if any( not isinstance(reference, ObjectReference) for reference in normalized ): raise ValueError( "Object references must be ObjectReference instances" ) if len(set(normalized)) != len(normalized): raise ValueError("Object references cannot contain duplicates") return normalized @dataclass(frozen=True) class DiscoveredObject: object_type: str local_key: str payload: Mapping[str, Any] references: tuple[ObjectReference, ...] = () def __post_init__(self) -> None: object.__setattr__( self, "references", _normalized_references(self.references), ) @dataclass(frozen=True) class NormalizedObject: object_type: str logical_key: str payload: Mapping[str, Any] local_key: str | None = None references: tuple[ObjectReference, ...] = () def __post_init__(self) -> None: object.__setattr__( self, "references", _normalized_references(self.references), ) @dataclass(frozen=True) class RetireRequest: object: NormalizedObject def __post_init__(self) -> None: if self.object.local_key is None: raise ValueError( "Retired object requires an endpoint-local key" ) @dataclass(frozen=True) class ApplyResult: object: NormalizedObject detail: str | None = None retirements: tuple[RetireRequest, ...] = () def __post_init__(self) -> None: if self.object.local_key is None: raise ValueError( "Applied object requires an endpoint-local key" ) retirements = tuple(self.retirements) if any( not isinstance(retirement, RetireRequest) for retirement in retirements ): raise ValueError( "ApplyResult retirements must be RetireRequest instances" ) seen: set[tuple[str, str]] = set() for retirement in retirements: retired = retirement.object identity = ( retired.object_type, retired.local_key, ) if identity in seen: raise ValueError( "ApplyResult retirements cannot contain duplicates" ) seen.add(identity) if ( retired.object_type == self.object.object_type and retired.local_key == self.object.local_key ): raise ValueError( "ApplyResult cannot retire the newly applied object" ) object.__setattr__( self, "retirements", retirements, ) if self.detail is not None: detail = self.detail.strip() object.__setattr__( self, "detail", detail or None, ) @dataclass(frozen=True) class Observation: endpoint_id: str object: NormalizedObject observed_at: datetime = field( default_factory=lambda: datetime.now(timezone.utc) ) @dataclass(frozen=True) class PlanAction: endpoint_id: str operation: PlanOperation desired: NormalizedObject | None = None current: NormalizedObject | None = None reason: str | None = None @dataclass(frozen=True) class EndpointPlan: endpoint_id: str actions: tuple[PlanAction, ...] retirements: tuple[RetireRequest, ...] = () def __post_init__(self) -> None: retirements = tuple(self.retirements) if any( not isinstance(retirement, RetireRequest) for retirement in retirements ): raise ValueError( "EndpointPlan retirements must be " "RetireRequest instances" ) identities = [ ( retirement.object.object_type, retirement.object.local_key, ) for retirement in retirements ] if len(set(identities)) != len(identities): raise ValueError( "EndpointPlan retirements cannot contain duplicates" ) object.__setattr__( self, "retirements", retirements, )