Resolve endpoint-local object dependencies

This commit is contained in:
Lyncoln
2026-09-19 21:34:23 -04:00
parent dc6505d165
commit 8caac79a1e
8 changed files with 1174 additions and 42 deletions
+98
View File
@@ -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
View File
@@ -1,35 +1,28 @@
from collections.abc import Iterable
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from typing import Protocol
from .adapters import (
CERTIFICATES,
DNS_RECORDS,
PROXY_HOSTS,
ApplyResult,
Capability,
CapabilityOperation,
Endpoint,
EndpointPlan,
NormalizedObject,
ObjectReference,
PlanAction,
PlanOperation,
)
from .dependencies import order_plan_actions
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 apply(self, action: PlanAction) -> ApplyResult | None: ...
def verify(self, object: NormalizedObject) -> bool: ...
@@ -48,19 +41,238 @@ class ExecutionResult:
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,
) -> tuple[int, str, str]:
local_references: _LocalReferenceIndex,
) -> PlanAction:
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,
raise RuntimeError(
"Executable plan action requires desired state"
)
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(
adapter: ExecutionAdapter,
@@ -164,25 +376,46 @@ def execute_endpoint_plans(
plans,
)
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]
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(
action.endpoint_id,
desired,
applied_object,
timestamp,
)
try:
verified = adapter.verify(desired)
verified = adapter.verify(applied_object)
except Exception as exc:
error = str(exc)
store.record_verification_failure(
action.endpoint_id,
desired,
applied_object,
error,
timestamp,
)
@@ -196,7 +429,7 @@ def execute_endpoint_plans(
error = "adapter returned false"
store.record_verification_failure(
action.endpoint_id,
desired,
applied_object,
error,
timestamp,
)
@@ -208,9 +441,16 @@ def execute_endpoint_plans(
store.record_verification_success(
action.endpoint_id,
desired,
applied_object,
timestamp,
)
_remember_local_object(
local_references[action.endpoint_id],
applied_object,
authoritative=True,
)
applied.append(
ExecutedAction(
endpoint_id=action.endpoint_id,
+2 -1
View File
@@ -1,6 +1,7 @@
from typing import Protocol
from .adapters import (
ApplyResult,
Capability,
DiscoveredObject,
Endpoint,
@@ -17,7 +18,7 @@ class AdapterPlugin(Protocol):
def connect(self) -> None: ...
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: ...
+37 -5
View File
@@ -1,5 +1,6 @@
from ...adapters import (
DNS_RECORDS,
ApplyResult,
Capability,
CapabilityOperation,
DiscoveredObject,
@@ -119,7 +120,7 @@ class Plugin:
wildcard=False,
)
def apply(self, action: PlanAction) -> str | None:
def apply(self, action: PlanAction) -> ApplyResult | None:
if action.endpoint_id != self.endpoint.identity:
raise RuntimeError(
"Plan action belongs to a different endpoint"
@@ -139,15 +140,46 @@ class Plugin:
record = self._desired_record(action.desired)
if action.operation == PlanOperation.CREATE:
return self.provider.add_record(record)
if action.operation == PlanOperation.UPDATE:
return self.provider.update_record(record)
detail = self.provider.add_record(record)
elif action.operation == PlanOperation.UPDATE:
detail = self.provider.update_record(record)
else:
raise RuntimeError(
f"Verizon adapter does not support "
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:
return self.provider.verify_record(
self._desired_record(object)
+2 -1
View File
@@ -13,6 +13,7 @@ from .adapters import (
PlanAction,
PlanOperation,
)
from .dependencies import order_plan_actions
ObservationGroupKey = tuple[str, str]
@@ -255,7 +256,7 @@ def build_endpoint_plans(
plans.append(
EndpointPlan(
endpoint_id=endpoint_id,
actions=tuple(actions),
actions=order_plan_actions(actions),
)
)
+625 -2
View File
@@ -4,6 +4,7 @@ from datetime import datetime, timezone
from dns_sync.adapters import (
CERTIFICATES,
ApplyResult,
DNS_RECORDS,
PROXY_HOSTS,
Capability,
@@ -11,6 +12,8 @@ from dns_sync.adapters import (
Endpoint,
EndpointPlan,
NormalizedObject,
ObjectReference,
Observation,
PlanAction,
PlanOperation,
)
@@ -24,6 +27,8 @@ NOW = datetime(2026, 9, 19, 23, 15, tzinfo=timezone.utc)
def object_for(
object_type: str,
logical_key: str,
*,
references=(),
) -> NormalizedObject:
if object_type == DNS_RECORDS:
payload = {
@@ -50,6 +55,7 @@ def object_for(
object_type=object_type,
logical_key=logical_key,
payload=payload,
references=references,
)
@@ -58,11 +64,17 @@ def action(
operation: PlanOperation,
object_type: str = DNS_RECORDS,
logical_key: str = "app.example.com",
*,
references=(),
) -> PlanAction:
return PlanAction(
endpoint_id=endpoint_id,
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
apply_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(
default_factory=list
)
@@ -110,7 +129,27 @@ class FakeAdapter:
)
if self.apply_error is not None:
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):
self.events.append(
@@ -287,6 +326,13 @@ class ExecutionTests(unittest.TestCase):
PlanOperation.CREATE,
PROXY_HOSTS,
"app.example.com",
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
),
action(
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):
endpoint = Endpoint("verizon", "192.168.1.1:443")
adapter = FakeAdapter(
+116
View File
@@ -5,10 +5,12 @@ from datetime import datetime, timezone
from dns_sync.adapters import (
CERTIFICATES,
DNS_RECORDS,
PROXY_HOSTS,
Capability,
CapabilityOperation,
Endpoint,
NormalizedObject,
ObjectReference,
Observation,
PlanOperation,
)
@@ -173,6 +175,120 @@ class EndpointPlanningTests(unittest.TestCase):
self.assertNotIn("app.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):
desired = [
dns_object("app.example.com", "192.168.1.101")
+25 -4
View File
@@ -147,8 +147,15 @@ class VerizonAdapterTests(unittest.TestCase):
def test_create_and_update_delegate_without_backend_ip(self):
provider = Mock()
provider.add_record.return_value = "slot 41"
provider.update_record.return_value = "slot 42"
provider.add_record.return_value = "created"
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)
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(update_result, "slot 42")
self.assertEqual(
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(
provider.add_record.call_args.args[0].ip,
"192.168.1.101",