Recover interrupted certificate replacement

This commit is contained in:
Lyncoln
2026-09-20 01:54:30 -04:00
parent 2668651a98
commit 387332b5db
5 changed files with 658 additions and 21 deletions
+184 -18
View File
@@ -16,6 +16,7 @@ from .adapters import (
Observation,
PlanAction,
PlanOperation,
RetireRequest,
)
from .dependencies import order_plan_actions
@@ -342,6 +343,81 @@ class CertificateNewestPolicy:
== self._portable_state(desired)
)
@staticmethod
def _equivalent_selection_key(
object: NormalizedObject,
) -> tuple[int, str]:
# Preserve a native ACME-renewable certificate over an
# imported replica when both contain the same portable
# certificate state.
provider = object.payload.get("provider")
return (
1 if provider == "letsencrypt" else 0,
object.local_key or "",
)
def select_current(
self,
observations: Sequence[Observation],
desired: NormalizedObject,
) -> tuple[
NormalizedObject,
tuple[RetireRequest, ...],
]:
if not observations:
raise PlanningConflict(
"Certificate current-state selection requires "
"at least one observation"
)
candidates = [
observation.object
for observation in observations
]
if any(
candidate.local_key is None
for candidate in candidates
):
raise PlanningConflict(
"Observed certificate requires endpoint-local identity"
)
matching = [
candidate
for candidate in candidates
if self.equivalent(
candidate,
desired,
)
]
if matching:
selected = max(
matching,
key=self._equivalent_selection_key,
)
else:
# No local copy is already desired. Replace the newest
# local certificate and retire every other stale copy
# after the replacement and its dependents verify.
selected = max(
candidates,
key=lambda candidate: (
self._newest_key(candidate),
candidate.local_key or "",
),
)
retirements = tuple(
RetireRequest(object=candidate)
for candidate in candidates
if candidate.local_key != selected.local_key
)
return selected, retirements
class ProxyHostConsensusPolicy:
object_type = PROXY_HOSTS
@@ -481,13 +557,31 @@ def build_endpoint_plans(
else policies
)
raw_observations = tuple(observations)
current_by_identity: dict[
tuple[str, str, str],
list[Observation],
] = defaultdict(list)
raw_current_by_identity: dict[
tuple[str, str, str],
list[Observation],
] = defaultdict(list)
for observation in raw_observations:
raw_current_by_identity[
(
observation.endpoint_id,
observation.object.object_type,
observation.object.logical_key,
)
].append(observation)
logicalized_observations = (
logicalize_observation_references(observations)
logicalize_observation_references(
raw_observations
)
)
for observation in logicalized_observations:
@@ -521,6 +615,10 @@ def build_endpoint_plans(
continue
actions: list[PlanAction] = []
planned_retirements: list[
RetireRequest
] = []
for identity, desired in sorted(
desired_by_identity.items()
):
@@ -532,22 +630,42 @@ def build_endpoint_plans(
(endpoint_id, object_type, logical_key),
[],
)
policy = selected_policies.get(object_type)
if len(current_rows) > 1:
raise PlanningConflict(
f"Multiple endpoint objects for {endpoint_id} "
f"{object_type} {logical_key!r}"
selector = (
getattr(
policy,
"select_current",
None,
)
if policy is not None
else None
)
current = (
current_rows[0].object
if current_rows
else None
)
if selector is None:
raise PlanningConflict(
f"Multiple endpoint objects for {endpoint_id} "
f"{object_type} {logical_key!r}"
)
current, retirements = selector(
tuple(current_rows),
desired,
)
planned_retirements.extend(
retirements
)
else:
current = (
current_rows[0].object
if current_rows
else None
)
if current is None:
operation = PlanOperation.CREATE
else:
policy = selected_policies.get(object_type)
if policy is None:
equivalent = (
current.payload == desired.payload
@@ -590,15 +708,22 @@ def build_endpoint_plans(
)
}
if replaced:
retiring_local = {
(
retirement.object.object_type,
retirement.object.local_key,
)
for retirement in planned_retirements
}
if replaced or retiring_local:
promoted: list[PlanAction] = []
for action in actions:
desired = action.desired
if (
action.operation == PlanOperation.NOOP
and desired is not None
logical_dependency_replaced = (
desired is not None
and any(
(
reference.object_type,
@@ -608,13 +733,51 @@ def build_endpoint_plans(
for reference in desired.references
if reference.logical_key is not None
)
)
raw_rows = (
raw_current_by_identity.get(
(
endpoint_id,
desired.object_type,
desired.logical_key,
),
[],
)
if desired is not None
else []
)
local_dependency_retiring = any(
(
reference.object_type,
reference.local_key,
)
in retiring_local
for row in raw_rows
for reference in row.object.references
if reference.local_key is not None
)
if (
action.operation == PlanOperation.NOOP
and (
logical_dependency_replaced
or local_dependency_retiring
)
):
reason = (
"referenced object is being replaced"
if logical_dependency_replaced
else (
"referenced local object is being retired"
)
)
action = replace(
action,
operation=PlanOperation.UPDATE,
reason=(
"referenced object is being replaced"
),
reason=reason,
)
promoted.append(action)
@@ -625,6 +788,9 @@ def build_endpoint_plans(
EndpointPlan(
endpoint_id=endpoint_id,
actions=order_plan_actions(actions),
retirements=tuple(
planned_retirements
),
)
)