227 lines
5.9 KiB
Python
227 lines
5.9 KiB
Python
from collections.abc import Iterable
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Protocol
|
|
|
|
from .adapters import (
|
|
CERTIFICATES,
|
|
DNS_RECORDS,
|
|
PROXY_HOSTS,
|
|
Capability,
|
|
CapabilityOperation,
|
|
Endpoint,
|
|
EndpointPlan,
|
|
NormalizedObject,
|
|
PlanAction,
|
|
PlanOperation,
|
|
)
|
|
from .persistence import SQLiteStateStore
|
|
|
|
|
|
_OBJECT_TYPE_ORDER = {
|
|
CERTIFICATES: 0,
|
|
PROXY_HOSTS: 1,
|
|
DNS_RECORDS: 2,
|
|
}
|
|
|
|
|
|
class ExecutionAdapter(Protocol):
|
|
endpoint: Endpoint
|
|
capabilities: frozenset[Capability]
|
|
|
|
def apply(self, action: PlanAction) -> str | None: ...
|
|
|
|
def verify(self, object: NormalizedObject) -> bool: ...
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExecutedAction:
|
|
endpoint_id: str
|
|
operation: PlanOperation
|
|
object_type: str
|
|
logical_key: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExecutionResult:
|
|
applied: tuple[ExecutedAction, ...]
|
|
skipped: tuple[PlanAction, ...]
|
|
|
|
|
|
def _action_sort_key(
|
|
action: PlanAction,
|
|
) -> tuple[int, str, str]:
|
|
desired = action.desired
|
|
if desired is None:
|
|
return (999, action.endpoint_id, "")
|
|
|
|
return (
|
|
_OBJECT_TYPE_ORDER.get(desired.object_type, 100),
|
|
action.endpoint_id,
|
|
desired.logical_key,
|
|
)
|
|
|
|
|
|
def _required_capability(
|
|
adapter: ExecutionAdapter,
|
|
object_type: str,
|
|
operation: CapabilityOperation,
|
|
) -> None:
|
|
capability = Capability(object_type, operation)
|
|
if capability not in adapter.capabilities:
|
|
raise RuntimeError(
|
|
f"{adapter.endpoint.identity} lacks "
|
|
f"{operation.value} capability for {object_type}"
|
|
)
|
|
|
|
|
|
def _preflight(
|
|
adapters: Iterable[ExecutionAdapter],
|
|
plans: Iterable[EndpointPlan],
|
|
) -> tuple[
|
|
dict[str, ExecutionAdapter],
|
|
list[PlanAction],
|
|
list[PlanAction],
|
|
]:
|
|
adapters_by_endpoint: dict[str, ExecutionAdapter] = {}
|
|
for adapter in adapters:
|
|
endpoint_id = adapter.endpoint.identity
|
|
if endpoint_id in adapters_by_endpoint:
|
|
raise RuntimeError(
|
|
f"Duplicate execution adapter: {endpoint_id}"
|
|
)
|
|
adapters_by_endpoint[endpoint_id] = adapter
|
|
|
|
executable: list[PlanAction] = []
|
|
skipped: list[PlanAction] = []
|
|
|
|
for plan in plans:
|
|
adapter = adapters_by_endpoint.get(plan.endpoint_id)
|
|
if adapter is None:
|
|
raise RuntimeError(
|
|
f"No adapter for endpoint plan {plan.endpoint_id}"
|
|
)
|
|
|
|
for action in plan.actions:
|
|
if action.endpoint_id != plan.endpoint_id:
|
|
raise RuntimeError(
|
|
"Plan action endpoint does not match its endpoint plan"
|
|
)
|
|
|
|
if action.operation in {
|
|
PlanOperation.NOOP,
|
|
PlanOperation.SKIP,
|
|
}:
|
|
skipped.append(action)
|
|
continue
|
|
|
|
if action.operation not in {
|
|
PlanOperation.CREATE,
|
|
PlanOperation.UPDATE,
|
|
}:
|
|
raise RuntimeError(
|
|
"Unsupported executable plan operation: "
|
|
f"{action.operation.value}"
|
|
)
|
|
|
|
if action.desired is None:
|
|
raise RuntimeError(
|
|
"Executable plan action requires desired state"
|
|
)
|
|
|
|
object_type = action.desired.object_type
|
|
_required_capability(
|
|
adapter,
|
|
object_type,
|
|
CapabilityOperation.APPLY,
|
|
)
|
|
_required_capability(
|
|
adapter,
|
|
object_type,
|
|
CapabilityOperation.VERIFY,
|
|
)
|
|
executable.append(action)
|
|
|
|
return adapters_by_endpoint, executable, skipped
|
|
|
|
|
|
def execute_endpoint_plans(
|
|
adapters: Iterable[ExecutionAdapter],
|
|
plans: Iterable[EndpointPlan],
|
|
store: SQLiteStateStore,
|
|
*,
|
|
executed_at: datetime | None = None,
|
|
) -> ExecutionResult:
|
|
timestamp = executed_at or datetime.now(timezone.utc)
|
|
if timestamp.tzinfo is None:
|
|
raise RuntimeError(
|
|
"Execution timestamp must be timezone-aware"
|
|
)
|
|
timestamp = timestamp.astimezone(timezone.utc)
|
|
|
|
adapters_by_endpoint, executable, skipped = _preflight(
|
|
adapters,
|
|
plans,
|
|
)
|
|
applied: list[ExecutedAction] = []
|
|
|
|
for action in sorted(executable, key=_action_sort_key):
|
|
adapter = adapters_by_endpoint[action.endpoint_id]
|
|
desired = action.desired
|
|
|
|
adapter.apply(action)
|
|
store.record_applied(
|
|
action.endpoint_id,
|
|
desired,
|
|
timestamp,
|
|
)
|
|
|
|
try:
|
|
verified = adapter.verify(desired)
|
|
except Exception as exc:
|
|
error = str(exc)
|
|
store.record_verification_failure(
|
|
action.endpoint_id,
|
|
desired,
|
|
error,
|
|
timestamp,
|
|
)
|
|
raise RuntimeError(
|
|
f"{action.endpoint_id} "
|
|
f"{desired.object_type} "
|
|
f"{desired.logical_key}: verification failed"
|
|
) from exc
|
|
|
|
if not verified:
|
|
error = "adapter returned false"
|
|
store.record_verification_failure(
|
|
action.endpoint_id,
|
|
desired,
|
|
error,
|
|
timestamp,
|
|
)
|
|
raise RuntimeError(
|
|
f"{action.endpoint_id} "
|
|
f"{desired.object_type} "
|
|
f"{desired.logical_key}: verification failed"
|
|
)
|
|
|
|
store.record_verification_success(
|
|
action.endpoint_id,
|
|
desired,
|
|
timestamp,
|
|
)
|
|
applied.append(
|
|
ExecutedAction(
|
|
endpoint_id=action.endpoint_id,
|
|
operation=action.operation,
|
|
object_type=desired.object_type,
|
|
logical_key=desired.logical_key,
|
|
)
|
|
)
|
|
|
|
return ExecutionResult(
|
|
applied=tuple(applied),
|
|
skipped=tuple(skipped),
|
|
)
|