Resolve endpoint-local object dependencies
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from .adapters import PlanAction
|
||||||
|
|
||||||
|
|
||||||
|
def _action_identity(
|
||||||
|
action: PlanAction,
|
||||||
|
) -> tuple[str, str, str]:
|
||||||
|
desired = action.desired
|
||||||
|
if desired is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Dependency-ordered plan action requires desired state"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
action.endpoint_id,
|
||||||
|
desired.object_type,
|
||||||
|
desired.logical_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def order_plan_actions(
|
||||||
|
actions: Iterable[PlanAction],
|
||||||
|
) -> tuple[PlanAction, ...]:
|
||||||
|
action_list = list(actions)
|
||||||
|
by_identity: dict[
|
||||||
|
tuple[str, str, str],
|
||||||
|
PlanAction,
|
||||||
|
] = {}
|
||||||
|
positions: dict[
|
||||||
|
tuple[str, str, str],
|
||||||
|
int,
|
||||||
|
] = {}
|
||||||
|
|
||||||
|
for position, action in enumerate(action_list):
|
||||||
|
identity = _action_identity(action)
|
||||||
|
if identity in by_identity:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Duplicate plan action identity: "
|
||||||
|
f"{identity[0]} {identity[1]} {identity[2]!r}"
|
||||||
|
)
|
||||||
|
by_identity[identity] = action
|
||||||
|
positions[identity] = position
|
||||||
|
|
||||||
|
dependencies: dict[
|
||||||
|
tuple[str, str, str],
|
||||||
|
set[tuple[str, str, str]],
|
||||||
|
] = {
|
||||||
|
identity: set()
|
||||||
|
for identity in by_identity
|
||||||
|
}
|
||||||
|
|
||||||
|
for identity, action in by_identity.items():
|
||||||
|
desired = action.desired
|
||||||
|
for reference in desired.references:
|
||||||
|
if reference.logical_key is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dependency = (
|
||||||
|
action.endpoint_id,
|
||||||
|
reference.object_type,
|
||||||
|
reference.logical_key,
|
||||||
|
)
|
||||||
|
if dependency in by_identity:
|
||||||
|
dependencies[identity].add(dependency)
|
||||||
|
|
||||||
|
remaining = set(by_identity)
|
||||||
|
ordered: list[PlanAction] = []
|
||||||
|
|
||||||
|
while remaining:
|
||||||
|
ready = [
|
||||||
|
identity
|
||||||
|
for identity in remaining
|
||||||
|
if not dependencies[identity].intersection(remaining)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not ready:
|
||||||
|
cycle = ", ".join(
|
||||||
|
f"{identity[1]} {identity[2]!r}"
|
||||||
|
for identity in sorted(
|
||||||
|
remaining,
|
||||||
|
key=lambda item: positions[item],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
"Plan action dependency cycle: "
|
||||||
|
f"{cycle}"
|
||||||
|
)
|
||||||
|
|
||||||
|
ready.sort(
|
||||||
|
key=lambda identity: positions[identity]
|
||||||
|
)
|
||||||
|
|
||||||
|
for identity in ready:
|
||||||
|
ordered.append(by_identity[identity])
|
||||||
|
remaining.remove(identity)
|
||||||
|
|
||||||
|
return tuple(ordered)
|
||||||
+268
-28
@@ -1,35 +1,28 @@
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from .adapters import (
|
from .adapters import (
|
||||||
CERTIFICATES,
|
ApplyResult,
|
||||||
DNS_RECORDS,
|
|
||||||
PROXY_HOSTS,
|
|
||||||
Capability,
|
Capability,
|
||||||
CapabilityOperation,
|
CapabilityOperation,
|
||||||
Endpoint,
|
Endpoint,
|
||||||
EndpointPlan,
|
EndpointPlan,
|
||||||
NormalizedObject,
|
NormalizedObject,
|
||||||
|
ObjectReference,
|
||||||
PlanAction,
|
PlanAction,
|
||||||
PlanOperation,
|
PlanOperation,
|
||||||
)
|
)
|
||||||
|
from .dependencies import order_plan_actions
|
||||||
from .persistence import SQLiteStateStore
|
from .persistence import SQLiteStateStore
|
||||||
|
|
||||||
|
|
||||||
_OBJECT_TYPE_ORDER = {
|
|
||||||
CERTIFICATES: 0,
|
|
||||||
PROXY_HOSTS: 1,
|
|
||||||
DNS_RECORDS: 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ExecutionAdapter(Protocol):
|
class ExecutionAdapter(Protocol):
|
||||||
endpoint: Endpoint
|
endpoint: Endpoint
|
||||||
capabilities: frozenset[Capability]
|
capabilities: frozenset[Capability]
|
||||||
|
|
||||||
def apply(self, action: PlanAction) -> str | None: ...
|
def apply(self, action: PlanAction) -> ApplyResult | None: ...
|
||||||
|
|
||||||
def verify(self, object: NormalizedObject) -> bool: ...
|
def verify(self, object: NormalizedObject) -> bool: ...
|
||||||
|
|
||||||
@@ -48,19 +41,238 @@ class ExecutionResult:
|
|||||||
skipped: tuple[PlanAction, ...]
|
skipped: tuple[PlanAction, ...]
|
||||||
|
|
||||||
|
|
||||||
def _action_sort_key(
|
@dataclass
|
||||||
|
class _LocalReferenceIndex:
|
||||||
|
by_logical: dict[
|
||||||
|
tuple[str, str],
|
||||||
|
set[str],
|
||||||
|
]
|
||||||
|
by_local: set[tuple[str, str]]
|
||||||
|
|
||||||
|
|
||||||
|
def _remember_local_object(
|
||||||
|
index: _LocalReferenceIndex,
|
||||||
|
object: NormalizedObject,
|
||||||
|
*,
|
||||||
|
authoritative: bool = False,
|
||||||
|
) -> None:
|
||||||
|
local_key = object.local_key
|
||||||
|
if local_key is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
identity = (
|
||||||
|
object.object_type,
|
||||||
|
object.logical_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
if authoritative:
|
||||||
|
previous = index.by_logical.get(identity, set())
|
||||||
|
for previous_local_key in previous:
|
||||||
|
index.by_local.discard(
|
||||||
|
(
|
||||||
|
object.object_type,
|
||||||
|
previous_local_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
index.by_logical[identity] = {local_key}
|
||||||
|
else:
|
||||||
|
index.by_logical.setdefault(
|
||||||
|
identity,
|
||||||
|
set(),
|
||||||
|
).add(local_key)
|
||||||
|
|
||||||
|
index.by_local.add(
|
||||||
|
(
|
||||||
|
object.object_type,
|
||||||
|
local_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _local_reference_index(
|
||||||
|
store: SQLiteStateStore,
|
||||||
|
endpoint_id: str,
|
||||||
|
) -> _LocalReferenceIndex:
|
||||||
|
index = _LocalReferenceIndex(
|
||||||
|
by_logical={},
|
||||||
|
by_local=set(),
|
||||||
|
)
|
||||||
|
observed_identities: set[
|
||||||
|
tuple[str, str]
|
||||||
|
] = set()
|
||||||
|
|
||||||
|
for observation in store.load_observations():
|
||||||
|
if observation.endpoint_id != endpoint_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
object = observation.object
|
||||||
|
if object.local_key is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
identity = (
|
||||||
|
object.object_type,
|
||||||
|
object.logical_key,
|
||||||
|
)
|
||||||
|
observed_identities.add(identity)
|
||||||
|
_remember_local_object(index, object)
|
||||||
|
|
||||||
|
for applied in store.load_applied_state(
|
||||||
|
endpoint_id=endpoint_id,
|
||||||
|
):
|
||||||
|
if not applied.is_verified:
|
||||||
|
continue
|
||||||
|
|
||||||
|
object = applied.object
|
||||||
|
if object.local_key is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
identity = (
|
||||||
|
object.object_type,
|
||||||
|
object.logical_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Endpoint observations are authoritative when present.
|
||||||
|
# Verified applied state is only a fallback when this
|
||||||
|
# logical identity is absent from the observation snapshot.
|
||||||
|
if identity in observed_identities:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_remember_local_object(index, object)
|
||||||
|
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_reference(
|
||||||
|
endpoint_id: str,
|
||||||
|
reference: ObjectReference,
|
||||||
|
local_references: _LocalReferenceIndex,
|
||||||
|
) -> ObjectReference:
|
||||||
|
if reference.local_key is not None:
|
||||||
|
local_identity = (
|
||||||
|
reference.object_type,
|
||||||
|
reference.local_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
if local_identity not in local_references.by_local:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Unresolved endpoint-local reference for "
|
||||||
|
f"{endpoint_id}: "
|
||||||
|
f"{reference.relation} -> "
|
||||||
|
f"{reference.object_type} "
|
||||||
|
f"local key {reference.local_key!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return reference
|
||||||
|
|
||||||
|
identity = (
|
||||||
|
reference.object_type,
|
||||||
|
reference.logical_key,
|
||||||
|
)
|
||||||
|
candidates = local_references.by_logical.get(
|
||||||
|
identity,
|
||||||
|
set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Unresolved endpoint-local reference for "
|
||||||
|
f"{endpoint_id}: "
|
||||||
|
f"{reference.relation} -> "
|
||||||
|
f"{reference.object_type} "
|
||||||
|
f"{reference.logical_key!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(candidates) > 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Ambiguous endpoint-local reference for "
|
||||||
|
f"{endpoint_id}: "
|
||||||
|
f"{reference.relation} -> "
|
||||||
|
f"{reference.object_type} "
|
||||||
|
f"{reference.logical_key!r}: "
|
||||||
|
f"{', '.join(sorted(candidates))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
local_key = next(iter(candidates))
|
||||||
|
|
||||||
|
return ObjectReference(
|
||||||
|
relation=reference.relation,
|
||||||
|
object_type=reference.object_type,
|
||||||
|
local_key=local_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_action_references(
|
||||||
action: PlanAction,
|
action: PlanAction,
|
||||||
) -> tuple[int, str, str]:
|
local_references: _LocalReferenceIndex,
|
||||||
|
) -> PlanAction:
|
||||||
desired = action.desired
|
desired = action.desired
|
||||||
if desired is None:
|
if desired is None:
|
||||||
return (999, action.endpoint_id, "")
|
raise RuntimeError(
|
||||||
|
"Executable plan action requires desired state"
|
||||||
return (
|
|
||||||
_OBJECT_TYPE_ORDER.get(desired.object_type, 100),
|
|
||||||
action.endpoint_id,
|
|
||||||
desired.logical_key,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not desired.references:
|
||||||
|
return action
|
||||||
|
|
||||||
|
references = tuple(
|
||||||
|
_resolved_reference(
|
||||||
|
action.endpoint_id,
|
||||||
|
reference,
|
||||||
|
local_references,
|
||||||
|
)
|
||||||
|
for reference in desired.references
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_desired = replace(
|
||||||
|
desired,
|
||||||
|
references=references,
|
||||||
|
)
|
||||||
|
|
||||||
|
return replace(
|
||||||
|
action,
|
||||||
|
desired=resolved_desired,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_apply_result(
|
||||||
|
action: PlanAction,
|
||||||
|
result: ApplyResult | None,
|
||||||
|
) -> ApplyResult:
|
||||||
|
desired = action.desired
|
||||||
|
if desired is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Executable plan action requires desired state"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(result, ApplyResult):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{action.endpoint_id} "
|
||||||
|
f"{desired.object_type} "
|
||||||
|
f"{desired.logical_key}: "
|
||||||
|
"adapter apply did not return ApplyResult"
|
||||||
|
)
|
||||||
|
|
||||||
|
applied = result.object
|
||||||
|
|
||||||
|
if applied.object_type != desired.object_type:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Applied object type does not match desired object type"
|
||||||
|
)
|
||||||
|
|
||||||
|
if applied.logical_key != desired.logical_key:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Applied object logical key does not match desired "
|
||||||
|
"logical key"
|
||||||
|
)
|
||||||
|
|
||||||
|
if applied.references != desired.references:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Applied object references do not match resolved "
|
||||||
|
"desired references"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _required_capability(
|
def _required_capability(
|
||||||
adapter: ExecutionAdapter,
|
adapter: ExecutionAdapter,
|
||||||
@@ -164,25 +376,46 @@ def execute_endpoint_plans(
|
|||||||
plans,
|
plans,
|
||||||
)
|
)
|
||||||
applied: list[ExecutedAction] = []
|
applied: list[ExecutedAction] = []
|
||||||
|
local_references: dict[
|
||||||
|
str,
|
||||||
|
_LocalReferenceIndex,
|
||||||
|
] = {}
|
||||||
|
|
||||||
for action in sorted(executable, key=_action_sort_key):
|
for endpoint_id in adapters_by_endpoint:
|
||||||
|
local_references[endpoint_id] = (
|
||||||
|
_local_reference_index(
|
||||||
|
store,
|
||||||
|
endpoint_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for action in order_plan_actions(executable):
|
||||||
adapter = adapters_by_endpoint[action.endpoint_id]
|
adapter = adapters_by_endpoint[action.endpoint_id]
|
||||||
desired = action.desired
|
resolved_action = _resolve_action_references(
|
||||||
|
action,
|
||||||
|
local_references[action.endpoint_id],
|
||||||
|
)
|
||||||
|
desired = resolved_action.desired
|
||||||
|
|
||||||
|
result = _validated_apply_result(
|
||||||
|
resolved_action,
|
||||||
|
adapter.apply(resolved_action),
|
||||||
|
)
|
||||||
|
applied_object = result.object
|
||||||
|
|
||||||
adapter.apply(action)
|
|
||||||
store.record_applied(
|
store.record_applied(
|
||||||
action.endpoint_id,
|
action.endpoint_id,
|
||||||
desired,
|
applied_object,
|
||||||
timestamp,
|
timestamp,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
verified = adapter.verify(desired)
|
verified = adapter.verify(applied_object)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error = str(exc)
|
error = str(exc)
|
||||||
store.record_verification_failure(
|
store.record_verification_failure(
|
||||||
action.endpoint_id,
|
action.endpoint_id,
|
||||||
desired,
|
applied_object,
|
||||||
error,
|
error,
|
||||||
timestamp,
|
timestamp,
|
||||||
)
|
)
|
||||||
@@ -196,7 +429,7 @@ def execute_endpoint_plans(
|
|||||||
error = "adapter returned false"
|
error = "adapter returned false"
|
||||||
store.record_verification_failure(
|
store.record_verification_failure(
|
||||||
action.endpoint_id,
|
action.endpoint_id,
|
||||||
desired,
|
applied_object,
|
||||||
error,
|
error,
|
||||||
timestamp,
|
timestamp,
|
||||||
)
|
)
|
||||||
@@ -208,9 +441,16 @@ def execute_endpoint_plans(
|
|||||||
|
|
||||||
store.record_verification_success(
|
store.record_verification_success(
|
||||||
action.endpoint_id,
|
action.endpoint_id,
|
||||||
desired,
|
applied_object,
|
||||||
timestamp,
|
timestamp,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_remember_local_object(
|
||||||
|
local_references[action.endpoint_id],
|
||||||
|
applied_object,
|
||||||
|
authoritative=True,
|
||||||
|
)
|
||||||
|
|
||||||
applied.append(
|
applied.append(
|
||||||
ExecutedAction(
|
ExecutedAction(
|
||||||
endpoint_id=action.endpoint_id,
|
endpoint_id=action.endpoint_id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from .adapters import (
|
from .adapters import (
|
||||||
|
ApplyResult,
|
||||||
Capability,
|
Capability,
|
||||||
DiscoveredObject,
|
DiscoveredObject,
|
||||||
Endpoint,
|
Endpoint,
|
||||||
@@ -17,7 +18,7 @@ class AdapterPlugin(Protocol):
|
|||||||
|
|
||||||
def connect(self) -> None: ...
|
def connect(self) -> None: ...
|
||||||
def discover(self, object_type: str) -> list[DiscoveredObject]: ...
|
def discover(self, object_type: str) -> list[DiscoveredObject]: ...
|
||||||
def apply(self, action: PlanAction) -> str | None: ...
|
def apply(self, action: PlanAction) -> ApplyResult | None: ...
|
||||||
def verify(self, object: NormalizedObject) -> bool: ...
|
def verify(self, object: NormalizedObject) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from ...adapters import (
|
from ...adapters import (
|
||||||
DNS_RECORDS,
|
DNS_RECORDS,
|
||||||
|
ApplyResult,
|
||||||
Capability,
|
Capability,
|
||||||
CapabilityOperation,
|
CapabilityOperation,
|
||||||
DiscoveredObject,
|
DiscoveredObject,
|
||||||
@@ -119,7 +120,7 @@ class Plugin:
|
|||||||
wildcard=False,
|
wildcard=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def apply(self, action: PlanAction) -> str | None:
|
def apply(self, action: PlanAction) -> ApplyResult | None:
|
||||||
if action.endpoint_id != self.endpoint.identity:
|
if action.endpoint_id != self.endpoint.identity:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Plan action belongs to a different endpoint"
|
"Plan action belongs to a different endpoint"
|
||||||
@@ -139,15 +140,46 @@ class Plugin:
|
|||||||
record = self._desired_record(action.desired)
|
record = self._desired_record(action.desired)
|
||||||
|
|
||||||
if action.operation == PlanOperation.CREATE:
|
if action.operation == PlanOperation.CREATE:
|
||||||
return self.provider.add_record(record)
|
detail = self.provider.add_record(record)
|
||||||
if action.operation == PlanOperation.UPDATE:
|
elif action.operation == PlanOperation.UPDATE:
|
||||||
return self.provider.update_record(record)
|
detail = self.provider.update_record(record)
|
||||||
|
else:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Verizon adapter does not support "
|
f"Verizon adapter does not support "
|
||||||
f"{action.operation.value} actions"
|
f"{action.operation.value} actions"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
records = self.provider.list_records()
|
||||||
|
applied_record = records.get(record.hostname.lower())
|
||||||
|
if applied_record is None:
|
||||||
|
applied_record = records.get(record.hostname)
|
||||||
|
|
||||||
|
if applied_record is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Verizon mutation succeeded but the applied DNS "
|
||||||
|
f"record {record.hostname!r} could not be rediscovered"
|
||||||
|
)
|
||||||
|
|
||||||
|
slot = applied_record.metadata.get("slot")
|
||||||
|
if isinstance(slot, bool) or not isinstance(slot, int):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Verizon DNS record {record.hostname!r} "
|
||||||
|
"has no valid endpoint-local slot after apply"
|
||||||
|
)
|
||||||
|
|
||||||
|
applied = NormalizedObject(
|
||||||
|
object_type=action.desired.object_type,
|
||||||
|
logical_key=action.desired.logical_key,
|
||||||
|
local_key=str(slot),
|
||||||
|
payload=dict(action.desired.payload),
|
||||||
|
references=action.desired.references,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ApplyResult(
|
||||||
|
object=applied,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
|
||||||
def verify(self, object: NormalizedObject) -> bool:
|
def verify(self, object: NormalizedObject) -> bool:
|
||||||
return self.provider.verify_record(
|
return self.provider.verify_record(
|
||||||
self._desired_record(object)
|
self._desired_record(object)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from .adapters import (
|
|||||||
PlanAction,
|
PlanAction,
|
||||||
PlanOperation,
|
PlanOperation,
|
||||||
)
|
)
|
||||||
|
from .dependencies import order_plan_actions
|
||||||
|
|
||||||
|
|
||||||
ObservationGroupKey = tuple[str, str]
|
ObservationGroupKey = tuple[str, str]
|
||||||
@@ -255,7 +256,7 @@ def build_endpoint_plans(
|
|||||||
plans.append(
|
plans.append(
|
||||||
EndpointPlan(
|
EndpointPlan(
|
||||||
endpoint_id=endpoint_id,
|
endpoint_id=endpoint_id,
|
||||||
actions=tuple(actions),
|
actions=order_plan_actions(actions),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+625
-2
@@ -4,6 +4,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from dns_sync.adapters import (
|
from dns_sync.adapters import (
|
||||||
CERTIFICATES,
|
CERTIFICATES,
|
||||||
|
ApplyResult,
|
||||||
DNS_RECORDS,
|
DNS_RECORDS,
|
||||||
PROXY_HOSTS,
|
PROXY_HOSTS,
|
||||||
Capability,
|
Capability,
|
||||||
@@ -11,6 +12,8 @@ from dns_sync.adapters import (
|
|||||||
Endpoint,
|
Endpoint,
|
||||||
EndpointPlan,
|
EndpointPlan,
|
||||||
NormalizedObject,
|
NormalizedObject,
|
||||||
|
ObjectReference,
|
||||||
|
Observation,
|
||||||
PlanAction,
|
PlanAction,
|
||||||
PlanOperation,
|
PlanOperation,
|
||||||
)
|
)
|
||||||
@@ -24,6 +27,8 @@ NOW = datetime(2026, 9, 19, 23, 15, tzinfo=timezone.utc)
|
|||||||
def object_for(
|
def object_for(
|
||||||
object_type: str,
|
object_type: str,
|
||||||
logical_key: str,
|
logical_key: str,
|
||||||
|
*,
|
||||||
|
references=(),
|
||||||
) -> NormalizedObject:
|
) -> NormalizedObject:
|
||||||
if object_type == DNS_RECORDS:
|
if object_type == DNS_RECORDS:
|
||||||
payload = {
|
payload = {
|
||||||
@@ -50,6 +55,7 @@ def object_for(
|
|||||||
object_type=object_type,
|
object_type=object_type,
|
||||||
logical_key=logical_key,
|
logical_key=logical_key,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
|
references=references,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -58,11 +64,17 @@ def action(
|
|||||||
operation: PlanOperation,
|
operation: PlanOperation,
|
||||||
object_type: str = DNS_RECORDS,
|
object_type: str = DNS_RECORDS,
|
||||||
logical_key: str = "app.example.com",
|
logical_key: str = "app.example.com",
|
||||||
|
*,
|
||||||
|
references=(),
|
||||||
) -> PlanAction:
|
) -> PlanAction:
|
||||||
return PlanAction(
|
return PlanAction(
|
||||||
endpoint_id=endpoint_id,
|
endpoint_id=endpoint_id,
|
||||||
operation=operation,
|
operation=operation,
|
||||||
desired=object_for(object_type, logical_key),
|
desired=object_for(
|
||||||
|
object_type,
|
||||||
|
logical_key,
|
||||||
|
references=references,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -95,6 +107,13 @@ class FakeAdapter:
|
|||||||
verify_result: bool = True
|
verify_result: bool = True
|
||||||
apply_error: Exception | None = None
|
apply_error: Exception | None = None
|
||||||
verify_error: Exception | None = None
|
verify_error: Exception | None = None
|
||||||
|
apply_local_keys: dict[
|
||||||
|
tuple[str, str],
|
||||||
|
str,
|
||||||
|
] = field(default_factory=dict)
|
||||||
|
applied_actions: list[PlanAction] = field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
events: list[tuple[str, str, str]] = field(
|
events: list[tuple[str, str, str]] = field(
|
||||||
default_factory=list
|
default_factory=list
|
||||||
)
|
)
|
||||||
@@ -110,7 +129,27 @@ class FakeAdapter:
|
|||||||
)
|
)
|
||||||
if self.apply_error is not None:
|
if self.apply_error is not None:
|
||||||
raise self.apply_error
|
raise self.apply_error
|
||||||
return "test detail"
|
|
||||||
|
self.applied_actions.append(plan_action)
|
||||||
|
|
||||||
|
local_key = self.apply_local_keys.get(
|
||||||
|
(
|
||||||
|
desired.object_type,
|
||||||
|
desired.logical_key,
|
||||||
|
),
|
||||||
|
desired.local_key or "test-local",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ApplyResult(
|
||||||
|
object=NormalizedObject(
|
||||||
|
object_type=desired.object_type,
|
||||||
|
logical_key=desired.logical_key,
|
||||||
|
local_key=local_key,
|
||||||
|
payload=dict(desired.payload),
|
||||||
|
references=desired.references,
|
||||||
|
),
|
||||||
|
detail="test detail",
|
||||||
|
)
|
||||||
|
|
||||||
def verify(self, object):
|
def verify(self, object):
|
||||||
self.events.append(
|
self.events.append(
|
||||||
@@ -287,6 +326,13 @@ class ExecutionTests(unittest.TestCase):
|
|||||||
PlanOperation.CREATE,
|
PlanOperation.CREATE,
|
||||||
PROXY_HOSTS,
|
PROXY_HOSTS,
|
||||||
"app.example.com",
|
"app.example.com",
|
||||||
|
references=(
|
||||||
|
ObjectReference(
|
||||||
|
relation="certificate",
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
action(
|
action(
|
||||||
endpoint.identity,
|
endpoint.identity,
|
||||||
@@ -315,6 +361,583 @@ class ExecutionTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_existing_reference_resolves_from_observation(self):
|
||||||
|
endpoint = Endpoint("npm", "192.168.1.4:81")
|
||||||
|
adapter = FakeAdapter(
|
||||||
|
endpoint,
|
||||||
|
capabilities(PROXY_HOSTS),
|
||||||
|
)
|
||||||
|
reference = ObjectReference(
|
||||||
|
relation="certificate",
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
)
|
||||||
|
proxy_action = action(
|
||||||
|
endpoint.identity,
|
||||||
|
PlanOperation.CREATE,
|
||||||
|
PROXY_HOSTS,
|
||||||
|
"app.example.com",
|
||||||
|
references=(reference,),
|
||||||
|
)
|
||||||
|
|
||||||
|
observed_certificate = NormalizedObject(
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
local_key="67",
|
||||||
|
payload={
|
||||||
|
"domain_names": ["app.example.com"],
|
||||||
|
"expires_on": "2026-12-15T00:00:00+00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with SQLiteStateStore(":memory:") as store:
|
||||||
|
store.replace_observations(
|
||||||
|
endpoint.identity,
|
||||||
|
CERTIFICATES,
|
||||||
|
[
|
||||||
|
Observation(
|
||||||
|
endpoint.identity,
|
||||||
|
observed_certificate,
|
||||||
|
NOW,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_endpoint_plans(
|
||||||
|
[adapter],
|
||||||
|
[
|
||||||
|
EndpointPlan(
|
||||||
|
endpoint.identity,
|
||||||
|
(proxy_action,),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
store,
|
||||||
|
executed_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
applied_reference = (
|
||||||
|
adapter.applied_actions[0]
|
||||||
|
.desired
|
||||||
|
.references[0]
|
||||||
|
)
|
||||||
|
self.assertEqual(applied_reference.local_key, "67")
|
||||||
|
self.assertIsNone(applied_reference.logical_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_created_reference_uses_apply_result_local_key(self):
|
||||||
|
endpoint = Endpoint("npm", "192.168.1.4:81")
|
||||||
|
adapter = FakeAdapter(
|
||||||
|
endpoint,
|
||||||
|
capabilities(CERTIFICATES, PROXY_HOSTS),
|
||||||
|
apply_local_keys={
|
||||||
|
(
|
||||||
|
CERTIFICATES,
|
||||||
|
"app.example.com",
|
||||||
|
): "68",
|
||||||
|
(
|
||||||
|
PROXY_HOSTS,
|
||||||
|
"app.example.com",
|
||||||
|
): "12",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
certificate_action = action(
|
||||||
|
endpoint.identity,
|
||||||
|
PlanOperation.CREATE,
|
||||||
|
CERTIFICATES,
|
||||||
|
"app.example.com",
|
||||||
|
)
|
||||||
|
proxy_action = action(
|
||||||
|
endpoint.identity,
|
||||||
|
PlanOperation.CREATE,
|
||||||
|
PROXY_HOSTS,
|
||||||
|
"app.example.com",
|
||||||
|
references=(
|
||||||
|
ObjectReference(
|
||||||
|
relation="certificate",
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with SQLiteStateStore(":memory:") as store:
|
||||||
|
execute_endpoint_plans(
|
||||||
|
[adapter],
|
||||||
|
[
|
||||||
|
EndpointPlan(
|
||||||
|
endpoint.identity,
|
||||||
|
(
|
||||||
|
proxy_action,
|
||||||
|
certificate_action,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
store,
|
||||||
|
executed_at=NOW,
|
||||||
|
)
|
||||||
|
persisted = store.load_applied_state(
|
||||||
|
endpoint.identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(
|
||||||
|
item.desired.object_type
|
||||||
|
for item in adapter.applied_actions
|
||||||
|
),
|
||||||
|
(CERTIFICATES, PROXY_HOSTS),
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved = (
|
||||||
|
adapter.applied_actions[1]
|
||||||
|
.desired
|
||||||
|
.references[0]
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved.local_key, "68")
|
||||||
|
self.assertIsNone(resolved.logical_key)
|
||||||
|
|
||||||
|
persisted_by_type = {
|
||||||
|
row.object.object_type: row.object
|
||||||
|
for row in persisted
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
persisted_by_type[CERTIFICATES].local_key,
|
||||||
|
"68",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
persisted_by_type[PROXY_HOSTS]
|
||||||
|
.references[0]
|
||||||
|
.local_key,
|
||||||
|
"68",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unresolved_reference_fails_before_dependent_apply(self):
|
||||||
|
endpoint = Endpoint("npm", "192.168.1.4:81")
|
||||||
|
adapter = FakeAdapter(
|
||||||
|
endpoint,
|
||||||
|
capabilities(PROXY_HOSTS),
|
||||||
|
)
|
||||||
|
proxy_action = action(
|
||||||
|
endpoint.identity,
|
||||||
|
PlanOperation.CREATE,
|
||||||
|
PROXY_HOSTS,
|
||||||
|
"app.example.com",
|
||||||
|
references=(
|
||||||
|
ObjectReference(
|
||||||
|
relation="certificate",
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="missing.example.com",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with SQLiteStateStore(":memory:") as store:
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RuntimeError,
|
||||||
|
"Unresolved endpoint-local reference",
|
||||||
|
):
|
||||||
|
execute_endpoint_plans(
|
||||||
|
[adapter],
|
||||||
|
[
|
||||||
|
EndpointPlan(
|
||||||
|
endpoint.identity,
|
||||||
|
(proxy_action,),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
store,
|
||||||
|
executed_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
store.load_applied_state(),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(adapter.events, [])
|
||||||
|
self.assertEqual(adapter.applied_actions, [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_reference_falls_back_to_verified_applied_state(self):
|
||||||
|
endpoint = Endpoint("npm", "192.168.1.4:81")
|
||||||
|
adapter = FakeAdapter(
|
||||||
|
endpoint,
|
||||||
|
capabilities(PROXY_HOSTS),
|
||||||
|
)
|
||||||
|
|
||||||
|
certificate = NormalizedObject(
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
local_key="71",
|
||||||
|
payload={
|
||||||
|
"domain_names": ["app.example.com"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy_action = action(
|
||||||
|
endpoint.identity,
|
||||||
|
PlanOperation.CREATE,
|
||||||
|
PROXY_HOSTS,
|
||||||
|
"app.example.com",
|
||||||
|
references=(
|
||||||
|
ObjectReference(
|
||||||
|
relation="certificate",
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with SQLiteStateStore(":memory:") as store:
|
||||||
|
store.record_applied(
|
||||||
|
endpoint.identity,
|
||||||
|
certificate,
|
||||||
|
NOW,
|
||||||
|
)
|
||||||
|
store.record_verification_success(
|
||||||
|
endpoint.identity,
|
||||||
|
certificate,
|
||||||
|
NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_endpoint_plans(
|
||||||
|
[adapter],
|
||||||
|
[
|
||||||
|
EndpointPlan(
|
||||||
|
endpoint.identity,
|
||||||
|
(proxy_action,),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
store,
|
||||||
|
executed_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved = (
|
||||||
|
adapter.applied_actions[0]
|
||||||
|
.desired
|
||||||
|
.references[0]
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved.local_key, "71")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ambiguous_logical_reference_fails_closed(self):
|
||||||
|
endpoint = Endpoint("npm", "192.168.1.4:81")
|
||||||
|
adapter = FakeAdapter(
|
||||||
|
endpoint,
|
||||||
|
capabilities(PROXY_HOSTS),
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy_action = action(
|
||||||
|
endpoint.identity,
|
||||||
|
PlanOperation.CREATE,
|
||||||
|
PROXY_HOSTS,
|
||||||
|
"app.example.com",
|
||||||
|
references=(
|
||||||
|
ObjectReference(
|
||||||
|
relation="certificate",
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
first = NormalizedObject(
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
local_key="63",
|
||||||
|
payload={
|
||||||
|
"domain_names": ["app.example.com"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
second = NormalizedObject(
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
local_key="67",
|
||||||
|
payload={
|
||||||
|
"domain_names": ["app.example.com"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with SQLiteStateStore(":memory:") as store:
|
||||||
|
store.replace_observations(
|
||||||
|
endpoint.identity,
|
||||||
|
CERTIFICATES,
|
||||||
|
[
|
||||||
|
Observation(
|
||||||
|
endpoint.identity,
|
||||||
|
first,
|
||||||
|
NOW,
|
||||||
|
),
|
||||||
|
Observation(
|
||||||
|
endpoint.identity,
|
||||||
|
second,
|
||||||
|
NOW,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RuntimeError,
|
||||||
|
"Ambiguous endpoint-local reference",
|
||||||
|
):
|
||||||
|
execute_endpoint_plans(
|
||||||
|
[adapter],
|
||||||
|
[
|
||||||
|
EndpointPlan(
|
||||||
|
endpoint.identity,
|
||||||
|
(proxy_action,),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
store,
|
||||||
|
executed_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(adapter.events, [])
|
||||||
|
self.assertEqual(adapter.applied_actions, [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_duplicate_identity_does_not_block_apply(self):
|
||||||
|
endpoint = Endpoint("npm", "192.168.1.4:81")
|
||||||
|
adapter = FakeAdapter(
|
||||||
|
endpoint,
|
||||||
|
capabilities(PROXY_HOSTS),
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy_action = action(
|
||||||
|
endpoint.identity,
|
||||||
|
PlanOperation.CREATE,
|
||||||
|
PROXY_HOSTS,
|
||||||
|
"unrelated.example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
first = NormalizedObject(
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="duplicate.example.com",
|
||||||
|
local_key="63",
|
||||||
|
payload={
|
||||||
|
"domain_names": ["duplicate.example.com"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
second = NormalizedObject(
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="duplicate.example.com",
|
||||||
|
local_key="67",
|
||||||
|
payload={
|
||||||
|
"domain_names": ["duplicate.example.com"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with SQLiteStateStore(":memory:") as store:
|
||||||
|
store.replace_observations(
|
||||||
|
endpoint.identity,
|
||||||
|
CERTIFICATES,
|
||||||
|
[
|
||||||
|
Observation(
|
||||||
|
endpoint.identity,
|
||||||
|
first,
|
||||||
|
NOW,
|
||||||
|
),
|
||||||
|
Observation(
|
||||||
|
endpoint.identity,
|
||||||
|
second,
|
||||||
|
NOW,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = execute_endpoint_plans(
|
||||||
|
[adapter],
|
||||||
|
[
|
||||||
|
EndpointPlan(
|
||||||
|
endpoint.identity,
|
||||||
|
(proxy_action,),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
store,
|
||||||
|
executed_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(result.applied), 1)
|
||||||
|
self.assertEqual(len(adapter.applied_actions), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_local_reference_is_not_trusted(self):
|
||||||
|
endpoint = Endpoint("npm", "192.168.1.4:81")
|
||||||
|
adapter = FakeAdapter(
|
||||||
|
endpoint,
|
||||||
|
capabilities(PROXY_HOSTS),
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy_action = action(
|
||||||
|
endpoint.identity,
|
||||||
|
PlanOperation.CREATE,
|
||||||
|
PROXY_HOSTS,
|
||||||
|
"app.example.com",
|
||||||
|
references=(
|
||||||
|
ObjectReference(
|
||||||
|
relation="certificate",
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
local_key="999",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with SQLiteStateStore(":memory:") as store:
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RuntimeError,
|
||||||
|
"local key '999'",
|
||||||
|
):
|
||||||
|
execute_endpoint_plans(
|
||||||
|
[adapter],
|
||||||
|
[
|
||||||
|
EndpointPlan(
|
||||||
|
endpoint.identity,
|
||||||
|
(proxy_action,),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
store,
|
||||||
|
executed_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(adapter.events, [])
|
||||||
|
self.assertEqual(adapter.applied_actions, [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_local_reference_is_preserved(self):
|
||||||
|
endpoint = Endpoint("npm", "192.168.1.4:81")
|
||||||
|
adapter = FakeAdapter(
|
||||||
|
endpoint,
|
||||||
|
capabilities(PROXY_HOSTS),
|
||||||
|
)
|
||||||
|
|
||||||
|
certificate = NormalizedObject(
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
local_key="67",
|
||||||
|
payload={
|
||||||
|
"domain_names": ["app.example.com"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
local_reference = ObjectReference(
|
||||||
|
relation="certificate",
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
local_key="67",
|
||||||
|
)
|
||||||
|
proxy_action = action(
|
||||||
|
endpoint.identity,
|
||||||
|
PlanOperation.CREATE,
|
||||||
|
PROXY_HOSTS,
|
||||||
|
"app.example.com",
|
||||||
|
references=(local_reference,),
|
||||||
|
)
|
||||||
|
|
||||||
|
with SQLiteStateStore(":memory:") as store:
|
||||||
|
store.replace_observations(
|
||||||
|
endpoint.identity,
|
||||||
|
CERTIFICATES,
|
||||||
|
[
|
||||||
|
Observation(
|
||||||
|
endpoint.identity,
|
||||||
|
certificate,
|
||||||
|
NOW,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_endpoint_plans(
|
||||||
|
[adapter],
|
||||||
|
[
|
||||||
|
EndpointPlan(
|
||||||
|
endpoint.identity,
|
||||||
|
(proxy_action,),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
store,
|
||||||
|
executed_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
adapter.applied_actions[0].desired.references,
|
||||||
|
(local_reference,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependency_cycle_fails_before_any_mutation(self):
|
||||||
|
endpoint = Endpoint("generic", "node.example:443")
|
||||||
|
object_a = "object_a"
|
||||||
|
object_b = "object_b"
|
||||||
|
|
||||||
|
adapter = FakeAdapter(
|
||||||
|
endpoint,
|
||||||
|
capabilities(object_a, object_b),
|
||||||
|
)
|
||||||
|
|
||||||
|
action_a = PlanAction(
|
||||||
|
endpoint_id=endpoint.identity,
|
||||||
|
operation=PlanOperation.CREATE,
|
||||||
|
desired=NormalizedObject(
|
||||||
|
object_type=object_a,
|
||||||
|
logical_key="a",
|
||||||
|
payload={},
|
||||||
|
references=(
|
||||||
|
ObjectReference(
|
||||||
|
relation="b",
|
||||||
|
object_type=object_b,
|
||||||
|
logical_key="b",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
action_b = PlanAction(
|
||||||
|
endpoint_id=endpoint.identity,
|
||||||
|
operation=PlanOperation.CREATE,
|
||||||
|
desired=NormalizedObject(
|
||||||
|
object_type=object_b,
|
||||||
|
logical_key="b",
|
||||||
|
payload={},
|
||||||
|
references=(
|
||||||
|
ObjectReference(
|
||||||
|
relation="a",
|
||||||
|
object_type=object_a,
|
||||||
|
logical_key="a",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with SQLiteStateStore(":memory:") as store:
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RuntimeError,
|
||||||
|
"dependency cycle",
|
||||||
|
):
|
||||||
|
execute_endpoint_plans(
|
||||||
|
[adapter],
|
||||||
|
[
|
||||||
|
EndpointPlan(
|
||||||
|
endpoint.identity,
|
||||||
|
(
|
||||||
|
action_a,
|
||||||
|
action_b,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
store,
|
||||||
|
executed_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
store.load_applied_state(),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(adapter.events, [])
|
||||||
|
self.assertEqual(adapter.applied_actions, [])
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_plan_fails_preflight_without_mutation(self):
|
def test_invalid_plan_fails_preflight_without_mutation(self):
|
||||||
endpoint = Endpoint("verizon", "192.168.1.1:443")
|
endpoint = Endpoint("verizon", "192.168.1.1:443")
|
||||||
adapter = FakeAdapter(
|
adapter = FakeAdapter(
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ from datetime import datetime, timezone
|
|||||||
from dns_sync.adapters import (
|
from dns_sync.adapters import (
|
||||||
CERTIFICATES,
|
CERTIFICATES,
|
||||||
DNS_RECORDS,
|
DNS_RECORDS,
|
||||||
|
PROXY_HOSTS,
|
||||||
Capability,
|
Capability,
|
||||||
CapabilityOperation,
|
CapabilityOperation,
|
||||||
Endpoint,
|
Endpoint,
|
||||||
NormalizedObject,
|
NormalizedObject,
|
||||||
|
ObjectReference,
|
||||||
Observation,
|
Observation,
|
||||||
PlanOperation,
|
PlanOperation,
|
||||||
)
|
)
|
||||||
@@ -173,6 +175,120 @@ class EndpointPlanningTests(unittest.TestCase):
|
|||||||
self.assertNotIn("app.example.com", planned_keys)
|
self.assertNotIn("app.example.com", planned_keys)
|
||||||
self.assertNotIn("unmanaged.example.com", planned_keys)
|
self.assertNotIn("unmanaged.example.com", planned_keys)
|
||||||
|
|
||||||
|
def test_plan_orders_referenced_object_before_dependent(self):
|
||||||
|
endpoint = Endpoint("generic", "node.example:443")
|
||||||
|
generic_adapter = FakeAdapter(
|
||||||
|
endpoint=endpoint,
|
||||||
|
capabilities=frozenset(
|
||||||
|
{
|
||||||
|
Capability(
|
||||||
|
CERTIFICATES,
|
||||||
|
CapabilityOperation.APPLY,
|
||||||
|
),
|
||||||
|
Capability(
|
||||||
|
PROXY_HOSTS,
|
||||||
|
CapabilityOperation.APPLY,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
certificate = NormalizedObject(
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
payload={
|
||||||
|
"domain_names": ["app.example.com"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
proxy_host = NormalizedObject(
|
||||||
|
object_type=PROXY_HOSTS,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
payload={
|
||||||
|
"domain_names": ["app.example.com"],
|
||||||
|
"forward_scheme": "http",
|
||||||
|
"forward_host": "192.168.1.50",
|
||||||
|
"forward_port": 8080,
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
references=(
|
||||||
|
ObjectReference(
|
||||||
|
relation="certificate",
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
plans = build_endpoint_plans(
|
||||||
|
[generic_adapter],
|
||||||
|
[
|
||||||
|
proxy_host,
|
||||||
|
certificate,
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(plans), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(
|
||||||
|
action.desired.object_type
|
||||||
|
for action in plans[0].actions
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CERTIFICATES,
|
||||||
|
PROXY_HOSTS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_dependency_cycle_fails_before_plan_is_returned(self):
|
||||||
|
endpoint = Endpoint("generic", "node.example:443")
|
||||||
|
object_a = "object_a"
|
||||||
|
object_b = "object_b"
|
||||||
|
|
||||||
|
generic_adapter = adapter(
|
||||||
|
"generic",
|
||||||
|
"node.example:443",
|
||||||
|
object_a,
|
||||||
|
object_b,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = NormalizedObject(
|
||||||
|
object_type=object_a,
|
||||||
|
logical_key="a",
|
||||||
|
payload={},
|
||||||
|
references=(
|
||||||
|
ObjectReference(
|
||||||
|
relation="b",
|
||||||
|
object_type=object_b,
|
||||||
|
logical_key="b",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
second = NormalizedObject(
|
||||||
|
object_type=object_b,
|
||||||
|
logical_key="b",
|
||||||
|
payload={},
|
||||||
|
references=(
|
||||||
|
ObjectReference(
|
||||||
|
relation="a",
|
||||||
|
object_type=object_a,
|
||||||
|
logical_key="a",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RuntimeError,
|
||||||
|
"dependency cycle",
|
||||||
|
):
|
||||||
|
build_endpoint_plans(
|
||||||
|
[generic_adapter],
|
||||||
|
[first, second],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_duplicate_endpoint_objects_fail_without_payload_output(self):
|
def test_duplicate_endpoint_objects_fail_without_payload_output(self):
|
||||||
desired = [
|
desired = [
|
||||||
dns_object("app.example.com", "192.168.1.101")
|
dns_object("app.example.com", "192.168.1.101")
|
||||||
|
|||||||
@@ -147,8 +147,15 @@ class VerizonAdapterTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_create_and_update_delegate_without_backend_ip(self):
|
def test_create_and_update_delegate_without_backend_ip(self):
|
||||||
provider = Mock()
|
provider = Mock()
|
||||||
provider.add_record.return_value = "slot 41"
|
provider.add_record.return_value = "created"
|
||||||
provider.update_record.return_value = "slot 42"
|
provider.update_record.return_value = "updated"
|
||||||
|
provider.list_records.return_value = {
|
||||||
|
"app.example.com": ProviderRecord(
|
||||||
|
hostname="app.example.com",
|
||||||
|
ip="192.168.1.101",
|
||||||
|
metadata={"slot": 41},
|
||||||
|
),
|
||||||
|
}
|
||||||
adapter = self.adapter(provider)
|
adapter = self.adapter(provider)
|
||||||
desired = self.dns_object(ip="192.168.1.101")
|
desired = self.dns_object(ip="192.168.1.101")
|
||||||
|
|
||||||
@@ -167,8 +174,22 @@ class VerizonAdapterTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(create_result, "slot 41")
|
self.assertEqual(
|
||||||
self.assertEqual(update_result, "slot 42")
|
create_result.object.local_key,
|
||||||
|
"41",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
create_result.detail,
|
||||||
|
"created",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
update_result.object.local_key,
|
||||||
|
"41",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
update_result.detail,
|
||||||
|
"updated",
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
provider.add_record.call_args.args[0].ip,
|
provider.add_record.call_args.args[0].ip,
|
||||||
"192.168.1.101",
|
"192.168.1.101",
|
||||||
|
|||||||
Reference in New Issue
Block a user