Resolve endpoint-local object dependencies
This commit is contained in:
+625
-2
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user