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):
CREATE = "create"
UPDATE = "update"
REPLACE = "replace"
DELETE = "delete"
NOOP = "noop"
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)
class ApplyResult:
object: NormalizedObject
detail: str | None = None
retirements: tuple[RetireRequest, ...] = ()
def __post_init__(self) -> None:
if self.object.local_key is None:
raise ValueError(
"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:
detail = self.detail.strip()
object.__setattr__(
+53
View File
@@ -13,6 +13,7 @@ from .adapters import (
ObjectReference,
PlanAction,
PlanOperation,
RetireRequest,
)
from .dependencies import order_plan_actions
from .persistence import SQLiteStateStore
@@ -26,6 +27,8 @@ class ExecutionAdapter(Protocol):
def verify(self, object: NormalizedObject) -> bool: ...
def retire(self, request: RetireRequest) -> None: ...
@dataclass(frozen=True)
class ExecutedAction:
@@ -271,6 +274,30 @@ def _validated_apply_result(
"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
@@ -330,6 +357,7 @@ def _preflight(
if action.operation not in {
PlanOperation.CREATE,
PlanOperation.UPDATE,
PlanOperation.REPLACE,
}:
raise RuntimeError(
"Unsupported executable plan operation: "
@@ -376,6 +404,10 @@ def execute_endpoint_plans(
plans,
)
applied: list[ExecutedAction] = []
pending_retirements: list[
tuple[str, RetireRequest]
] = []
local_references: dict[
str,
_LocalReferenceIndex,
@@ -451,6 +483,14 @@ def execute_endpoint_plans(
authoritative=True,
)
for retirement in result.retirements:
pending_retirements.append(
(
action.endpoint_id,
retirement,
)
)
applied.append(
ExecutedAction(
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(
applied=tuple(applied),
skipped=tuple(skipped),
+66
View File
@@ -11,6 +11,7 @@ from ...adapters import (
ObjectReference,
PlanAction,
PlanOperation,
RetireRequest,
)
from ...certificates.material import CertificateMaterial
from ...npm_client import NpmClient
@@ -602,6 +603,12 @@ class Plugin:
):
return self._create_certificate(desired)
if (
desired.object_type == CERTIFICATES
and action.operation == PlanOperation.REPLACE
):
return self._replace_certificate(action)
if (
desired.object_type == PROXY_HOSTS
and action.operation == PlanOperation.CREATE
@@ -619,6 +626,65 @@ class Plugin:
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(
self,
desired: NormalizedObject,
+81 -12
View File
@@ -38,6 +38,7 @@ class PlanningAdapter(Protocol):
class ReconciliationPolicy(Protocol):
object_type: str
mutation_operation: PlanOperation
def reconcile(
self,
@@ -181,6 +182,7 @@ def group_observations(
class DnsUnionPolicy:
object_type = DNS_RECORDS
mutation_operation = PlanOperation.UPDATE
def reconcile(
self,
@@ -229,6 +231,7 @@ class DnsUnionPolicy:
class CertificateNewestPolicy:
object_type = CERTIFICATES
mutation_operation = PlanOperation.REPLACE
_PORTABLE_FIELDS = (
"domain_names",
@@ -247,20 +250,38 @@ class CertificateNewestPolicy:
)
@staticmethod
def _expiry_key(
object: NormalizedObject,
def _date_key(
value: object,
field: str,
) -> tuple[int, str]:
expires_on = object.payload.get("expires_on")
if expires_on is None:
if value is None:
return (0, "")
if not isinstance(expires_on, str):
if not isinstance(value, str):
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(
self,
@@ -273,14 +294,14 @@ class CertificateNewestPolicy:
)
newest_key = max(
self._expiry_key(observation.object)
self._newest_key(observation.object)
for observation in observations
)
newest = [
observation
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)
@@ -296,7 +317,7 @@ class CertificateNewestPolicy:
raise ReconciliationConflict(
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))
)
@@ -324,6 +345,7 @@ class CertificateNewestPolicy:
class ProxyHostConsensusPolicy:
object_type = PROXY_HOSTS
mutation_operation = PlanOperation.UPDATE
def reconcile(
self,
@@ -540,7 +562,11 @@ def build_endpoint_plans(
operation = (
PlanOperation.NOOP
if equivalent
else PlanOperation.UPDATE
else (
policy.mutation_operation
if policy is not None
else PlanOperation.UPDATE
)
)
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(
EndpointPlan(
endpoint_id=endpoint_id,