Add certificate replacement lifecycle

This commit is contained in:
Lyncoln
2026-09-20 01:44:56 -04:00
parent 4b094820b2
commit 2668651a98
7 changed files with 582 additions and 21 deletions
+54
View File
@@ -19,6 +19,7 @@ class CapabilityOperation(StrEnum):
class PlanOperation(StrEnum): class PlanOperation(StrEnum):
CREATE = "create" CREATE = "create"
UPDATE = "update" UPDATE = "update"
REPLACE = "replace"
DELETE = "delete" DELETE = "delete"
NOOP = "noop" NOOP = "noop"
SKIP = "skip" SKIP = "skip"
@@ -157,16 +158,69 @@ class NormalizedObject:
) )
@dataclass(frozen=True)
class RetireRequest:
object: NormalizedObject
def __post_init__(self) -> None:
if self.object.local_key is None:
raise ValueError(
"Retired object requires an endpoint-local key"
)
@dataclass(frozen=True) @dataclass(frozen=True)
class ApplyResult: class ApplyResult:
object: NormalizedObject object: NormalizedObject
detail: str | None = None detail: str | None = None
retirements: tuple[RetireRequest, ...] = ()
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.object.local_key is None: if self.object.local_key is None:
raise ValueError( raise ValueError(
"Applied object requires an endpoint-local key" "Applied object requires an endpoint-local key"
) )
retirements = tuple(self.retirements)
if any(
not isinstance(retirement, RetireRequest)
for retirement in retirements
):
raise ValueError(
"ApplyResult retirements must be RetireRequest instances"
)
seen: set[tuple[str, str]] = set()
for retirement in retirements:
retired = retirement.object
identity = (
retired.object_type,
retired.local_key,
)
if identity in seen:
raise ValueError(
"ApplyResult retirements cannot contain duplicates"
)
seen.add(identity)
if (
retired.object_type == self.object.object_type
and retired.local_key == self.object.local_key
):
raise ValueError(
"ApplyResult cannot retire the newly applied object"
)
object.__setattr__(
self,
"retirements",
retirements,
)
if self.detail is not None: if self.detail is not None:
detail = self.detail.strip() detail = self.detail.strip()
object.__setattr__( object.__setattr__(
+53
View File
@@ -13,6 +13,7 @@ from .adapters import (
ObjectReference, ObjectReference,
PlanAction, PlanAction,
PlanOperation, PlanOperation,
RetireRequest,
) )
from .dependencies import order_plan_actions from .dependencies import order_plan_actions
from .persistence import SQLiteStateStore from .persistence import SQLiteStateStore
@@ -26,6 +27,8 @@ class ExecutionAdapter(Protocol):
def verify(self, object: NormalizedObject) -> bool: ... def verify(self, object: NormalizedObject) -> bool: ...
def retire(self, request: RetireRequest) -> None: ...
@dataclass(frozen=True) @dataclass(frozen=True)
class ExecutedAction: class ExecutedAction:
@@ -271,6 +274,30 @@ def _validated_apply_result(
"desired references" "desired references"
) )
if (
action.operation != PlanOperation.REPLACE
and result.retirements
):
raise RuntimeError(
"Only REPLACE actions may request deferred retirement"
)
for retirement in result.retirements:
retired = retirement.object
if retired.local_key is None:
raise RuntimeError(
"Deferred retirement requires endpoint-local identity"
)
if (
retired.object_type == applied.object_type
and retired.local_key == applied.local_key
):
raise RuntimeError(
"Deferred retirement cannot target applied object"
)
return result return result
@@ -330,6 +357,7 @@ def _preflight(
if action.operation not in { if action.operation not in {
PlanOperation.CREATE, PlanOperation.CREATE,
PlanOperation.UPDATE, PlanOperation.UPDATE,
PlanOperation.REPLACE,
}: }:
raise RuntimeError( raise RuntimeError(
"Unsupported executable plan operation: " "Unsupported executable plan operation: "
@@ -376,6 +404,10 @@ def execute_endpoint_plans(
plans, plans,
) )
applied: list[ExecutedAction] = [] applied: list[ExecutedAction] = []
pending_retirements: list[
tuple[str, RetireRequest]
] = []
local_references: dict[ local_references: dict[
str, str,
_LocalReferenceIndex, _LocalReferenceIndex,
@@ -451,6 +483,14 @@ def execute_endpoint_plans(
authoritative=True, authoritative=True,
) )
for retirement in result.retirements:
pending_retirements.append(
(
action.endpoint_id,
retirement,
)
)
applied.append( applied.append(
ExecutedAction( ExecutedAction(
endpoint_id=action.endpoint_id, endpoint_id=action.endpoint_id,
@@ -460,6 +500,19 @@ def execute_endpoint_plans(
) )
) )
for endpoint_id, retirement in pending_retirements:
adapter = adapters_by_endpoint[endpoint_id]
retire = getattr(adapter, "retire", None)
if retire is None:
raise RuntimeError(
f"{endpoint_id} adapter does not support "
"deferred retirement"
)
retire(retirement)
return ExecutionResult( return ExecutionResult(
applied=tuple(applied), applied=tuple(applied),
skipped=tuple(skipped), skipped=tuple(skipped),
+66
View File
@@ -11,6 +11,7 @@ from ...adapters import (
ObjectReference, ObjectReference,
PlanAction, PlanAction,
PlanOperation, PlanOperation,
RetireRequest,
) )
from ...certificates.material import CertificateMaterial from ...certificates.material import CertificateMaterial
from ...npm_client import NpmClient from ...npm_client import NpmClient
@@ -602,6 +603,12 @@ class Plugin:
): ):
return self._create_certificate(desired) return self._create_certificate(desired)
if (
desired.object_type == CERTIFICATES
and action.operation == PlanOperation.REPLACE
):
return self._replace_certificate(action)
if ( if (
desired.object_type == PROXY_HOSTS desired.object_type == PROXY_HOSTS
and action.operation == PlanOperation.CREATE and action.operation == PlanOperation.CREATE
@@ -619,6 +626,65 @@ class Plugin:
f"{action.operation} for {desired.object_type}" f"{action.operation} for {desired.object_type}"
) )
def _replace_certificate(
self,
action: PlanAction,
) -> ApplyResult:
desired = action.desired
current = action.current
if desired is None or desired.object_type != CERTIFICATES:
raise RuntimeError(
"NPM certificate replace requires desired certificate"
)
if current is None or current.object_type != CERTIFICATES:
raise RuntimeError(
"NPM certificate replace requires current certificate"
)
if current.local_key is None:
raise RuntimeError(
"NPM certificate replace requires current local id"
)
if current.logical_key != desired.logical_key:
raise RuntimeError(
"NPM certificate replace identity mismatch"
)
result = self._create_certificate(desired)
return ApplyResult(
object=result.object,
detail="replaced certificate",
retirements=(
RetireRequest(
object=current,
),
),
)
def retire(
self,
request: RetireRequest,
) -> None:
retired = request.object
if retired.object_type != CERTIFICATES:
raise RuntimeError(
"NPM deferred retirement only supports certificates"
)
if retired.local_key is None:
raise RuntimeError(
"NPM certificate retirement requires local id"
)
self.client.delete_certificate(
retired.local_key
)
def _create_certificate( def _create_certificate(
self, self,
desired: NormalizedObject, desired: NormalizedObject,
+81 -12
View File
@@ -38,6 +38,7 @@ class PlanningAdapter(Protocol):
class ReconciliationPolicy(Protocol): class ReconciliationPolicy(Protocol):
object_type: str object_type: str
mutation_operation: PlanOperation
def reconcile( def reconcile(
self, self,
@@ -181,6 +182,7 @@ def group_observations(
class DnsUnionPolicy: class DnsUnionPolicy:
object_type = DNS_RECORDS object_type = DNS_RECORDS
mutation_operation = PlanOperation.UPDATE
def reconcile( def reconcile(
self, self,
@@ -229,6 +231,7 @@ class DnsUnionPolicy:
class CertificateNewestPolicy: class CertificateNewestPolicy:
object_type = CERTIFICATES object_type = CERTIFICATES
mutation_operation = PlanOperation.REPLACE
_PORTABLE_FIELDS = ( _PORTABLE_FIELDS = (
"domain_names", "domain_names",
@@ -247,20 +250,38 @@ class CertificateNewestPolicy:
) )
@staticmethod @staticmethod
def _expiry_key( def _date_key(
object: NormalizedObject, value: object,
field: str,
) -> tuple[int, str]: ) -> tuple[int, str]:
expires_on = object.payload.get("expires_on") if value is None:
if expires_on is None:
return (0, "") return (0, "")
if not isinstance(expires_on, str): if not isinstance(value, str):
raise ReconciliationConflict( raise ReconciliationConflict(
"Certificate expires_on must be normalized text" f"Certificate {field} must be normalized text"
) )
return (1, expires_on) return (1, value)
@classmethod
def _newest_key(
cls,
object: NormalizedObject,
) -> tuple[
tuple[int, str],
tuple[int, str],
]:
return (
cls._date_key(
object.payload.get("valid_from"),
"valid_from",
),
cls._date_key(
object.payload.get("expires_on"),
"expires_on",
),
)
def reconcile( def reconcile(
self, self,
@@ -273,14 +294,14 @@ class CertificateNewestPolicy:
) )
newest_key = max( newest_key = max(
self._expiry_key(observation.object) self._newest_key(observation.object)
for observation in observations for observation in observations
) )
newest = [ newest = [
observation observation
for observation in observations for observation in observations
if self._expiry_key(observation.object) == newest_key if self._newest_key(observation.object) == newest_key
] ]
baseline = self._portable_state(newest[0].object) baseline = self._portable_state(newest[0].object)
@@ -296,7 +317,7 @@ class CertificateNewestPolicy:
raise ReconciliationConflict( raise ReconciliationConflict(
f"Conflicting {CERTIFICATES} observations for " f"Conflicting {CERTIFICATES} observations for "
f"{logical_key!r} at newest expiry from " f"{logical_key!r} at newest issuance from "
+ ", ".join(sorted(conflicting_endpoints)) + ", ".join(sorted(conflicting_endpoints))
) )
@@ -324,6 +345,7 @@ class CertificateNewestPolicy:
class ProxyHostConsensusPolicy: class ProxyHostConsensusPolicy:
object_type = PROXY_HOSTS object_type = PROXY_HOSTS
mutation_operation = PlanOperation.UPDATE
def reconcile( def reconcile(
self, self,
@@ -540,7 +562,11 @@ def build_endpoint_plans(
operation = ( operation = (
PlanOperation.NOOP PlanOperation.NOOP
if equivalent if equivalent
else PlanOperation.UPDATE else (
policy.mutation_operation
if policy is not None
else PlanOperation.UPDATE
)
) )
actions.append( actions.append(
@@ -552,6 +578,49 @@ def build_endpoint_plans(
) )
) )
replaced = {
(
action.desired.object_type,
action.desired.logical_key,
)
for action in actions
if (
action.operation == PlanOperation.REPLACE
and action.desired is not None
)
}
if replaced:
promoted: list[PlanAction] = []
for action in actions:
desired = action.desired
if (
action.operation == PlanOperation.NOOP
and desired is not None
and any(
(
reference.object_type,
reference.logical_key,
)
in replaced
for reference in desired.references
if reference.logical_key is not None
)
):
action = replace(
action,
operation=PlanOperation.UPDATE,
reason=(
"referenced object is being replaced"
),
)
promoted.append(action)
actions = promoted
plans.append( plans.append(
EndpointPlan( EndpointPlan(
endpoint_id=endpoint_id, endpoint_id=endpoint_id,
+198
View File
@@ -16,6 +16,7 @@ from dns_sync.adapters import (
Observation, Observation,
PlanAction, PlanAction,
PlanOperation, PlanOperation,
RetireRequest,
) )
from dns_sync.executor import execute_endpoint_plans from dns_sync.executor import execute_endpoint_plans
from dns_sync.persistence import SQLiteStateStore from dns_sync.persistence import SQLiteStateStore
@@ -117,6 +118,9 @@ class FakeAdapter:
events: list[tuple[str, str, str]] = field( events: list[tuple[str, str, str]] = field(
default_factory=list default_factory=list
) )
retired: list[RetireRequest] = field(
default_factory=list
)
def apply(self, plan_action): def apply(self, plan_action):
desired = plan_action.desired desired = plan_action.desired
@@ -163,6 +167,16 @@ class FakeAdapter:
raise self.verify_error raise self.verify_error
return self.verify_result return self.verify_result
def retire(self, request):
self.events.append(
(
"retire",
request.object.object_type,
request.object.logical_key,
)
)
self.retired.append(request)
class ExecutionTests(unittest.TestCase): class ExecutionTests(unittest.TestCase):
def test_successful_actions_are_applied_verified_and_persisted(self): def test_successful_actions_are_applied_verified_and_persisted(self):
@@ -216,6 +230,190 @@ class ExecutionTests(unittest.TestCase):
("apply", "verify", "apply", "verify"), ("apply", "verify", "apply", "verify"),
) )
def test_replace_rebinds_dependent_before_retiring_old_object(self):
endpoint = Endpoint(
"npm",
"192.168.1.4:81",
)
class ReplacementAdapter(FakeAdapter):
def apply(self, plan_action):
result = super().apply(plan_action)
if (
plan_action.operation
== PlanOperation.REPLACE
):
return ApplyResult(
object=result.object,
detail="replacement",
retirements=(
RetireRequest(
object=plan_action.current,
),
),
)
return result
adapter = ReplacementAdapter(
endpoint,
capabilities(
CERTIFICATES,
PROXY_HOSTS,
),
apply_local_keys={
(
CERTIFICATES,
"app.example.com",
): "84",
(
PROXY_HOSTS,
"app.example.com",
): "12",
},
)
old_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="67",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-08-01T00:00:00+00:00",
"expires_on": "2026-11-01T00:00:00+00:00",
},
)
desired_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-09-01T00:00:00+00:00",
"expires_on": "2026-12-01T00:00:00+00:00",
},
)
old_proxy = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "https",
"forward_host": "backend.internal",
"forward_port": 443,
"enabled": True,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="67",
),
),
)
desired_proxy = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload=dict(old_proxy.payload),
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
plan = EndpointPlan(
endpoint.identity,
(
PlanAction(
endpoint_id=endpoint.identity,
operation=PlanOperation.REPLACE,
desired=desired_certificate,
current=old_certificate,
),
PlanAction(
endpoint_id=endpoint.identity,
operation=PlanOperation.UPDATE,
desired=desired_proxy,
current=old_proxy,
),
),
)
with SQLiteStateStore(":memory:") as store:
store.replace_observations(
endpoint.identity,
CERTIFICATES,
[
Observation(
endpoint.identity,
old_certificate,
NOW,
)
],
NOW,
)
store.replace_observations(
endpoint.identity,
PROXY_HOSTS,
[
Observation(
endpoint.identity,
old_proxy,
NOW,
)
],
NOW,
)
execute_endpoint_plans(
[adapter],
[plan],
store,
executed_at=NOW,
)
self.assertEqual(
tuple(event[0] for event in adapter.events),
(
"apply",
"verify",
"apply",
"verify",
"retire",
),
)
proxy_apply = adapter.applied_actions[1]
self.assertEqual(
proxy_apply.desired.references,
(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="84",
),
),
)
self.assertEqual(
adapter.retired,
[
RetireRequest(
object=old_certificate,
)
],
)
def test_failed_verification_is_persisted_and_raises(self): def test_failed_verification_is_persisted_and_raises(self):
endpoint = Endpoint("verizon", "192.168.1.1:443") endpoint = Endpoint("verizon", "192.168.1.1:443")
adapter = FakeAdapter( adapter = FakeAdapter(
+115
View File
@@ -500,6 +500,121 @@ class EndpointPlanningTests(unittest.TestCase):
desired.references, desired.references,
) )
def test_older_certificate_is_replace_and_dependent_proxy_is_update(self):
endpoint = Endpoint(
"generic",
"node.example:443",
)
generic_adapter = adapter(
"generic",
"node.example:443",
CERTIFICATES,
PROXY_HOSTS,
)
desired_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-09-20T00:00:00+00:00",
"expires_on": "2026-12-20T00:00:00+00:00",
},
)
desired_proxy = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "https",
"forward_host": "backend.internal",
"forward_port": 443,
"enabled": True,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
current_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="67",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-08-20T00:00:00+00:00",
"expires_on": "2026-11-20T00:00:00+00:00",
},
)
current_proxy = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload=dict(desired_proxy.payload),
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="67",
),
),
)
plans = build_endpoint_plans(
[generic_adapter],
[
desired_certificate,
desired_proxy,
],
[
observed(
endpoint.identity,
current_certificate,
),
observed(
endpoint.identity,
current_proxy,
),
],
)
self.assertEqual(len(plans), 1)
actions = plans[0].actions
self.assertEqual(
[
(
action.operation,
action.desired.object_type,
)
for action in actions
],
[
(
PlanOperation.REPLACE,
CERTIFICATES,
),
(
PlanOperation.UPDATE,
PROXY_HOSTS,
),
],
)
self.assertEqual(
actions[1].reason,
"referenced object is being replaced",
)
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")
+15 -9
View File
@@ -326,7 +326,7 @@ class DnsUnionPolicyTests(unittest.TestCase):
) )
self.assertIsNone(desired[0].local_key) self.assertIsNone(desired[0].local_key)
def test_certificate_same_expiry_conflict_fails_closed(self): def test_certificate_policy_prefers_newer_valid_from_over_expiry(self):
observations = [ observations = [
Observation( Observation(
endpoint_id="npm://node-one:81", endpoint_id="npm://node-one:81",
@@ -337,7 +337,7 @@ class DnsUnionPolicyTests(unittest.TestCase):
payload={ payload={
"domain_names": ["app.example.com"], "domain_names": ["app.example.com"],
"valid_from": "2026-08-01T00:00:00+00:00", "valid_from": "2026-08-01T00:00:00+00:00",
"expires_on": "2026-12-01T00:00:00+00:00", "expires_on": "2027-01-01T00:00:00+00:00",
}, },
), ),
observed_at=NOW, observed_at=NOW,
@@ -350,19 +350,25 @@ class DnsUnionPolicyTests(unittest.TestCase):
local_key="44", local_key="44",
payload={ payload={
"domain_names": ["app.example.com"], "domain_names": ["app.example.com"],
"valid_from": "2026-09-01T00:00:00+00:00", "valid_from": "2026-09-15T00:00:00+00:00",
"expires_on": "2026-12-01T00:00:00+00:00", "expires_on": "2026-12-15T00:00:00+00:00",
}, },
), ),
observed_at=NOW, observed_at=NOW,
), ),
] ]
with self.assertRaisesRegex( desired = reconcile_observations(observations)
ReconciliationConflict,
"newest expiry", self.assertEqual(len(desired), 1)
): self.assertEqual(
reconcile_observations(observations) desired[0].payload["valid_from"],
"2026-09-15T00:00:00+00:00",
)
self.assertEqual(
desired[0].payload["expires_on"],
"2026-12-15T00:00:00+00:00",
)
def test_proxy_consensus_ignores_different_endpoint_local_certificate_ids(self): def test_proxy_consensus_ignores_different_endpoint_local_certificate_ids(self):
observations = [] observations = []