Recover interrupted certificate replacement
This commit is contained in:
+184
-18
@@ -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
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user