Add rollback-safe NPM certificate create

This commit is contained in:
Lyncoln
2026-09-20 00:56:25 -04:00
parent 44489db3ff
commit 6ec4f2932e
4 changed files with 580 additions and 4 deletions
+15
View File
@@ -372,6 +372,21 @@ class NpmClient:
f"Unsupported NPM certificate provider: {provider!r}"
)
def delete_certificate(
self,
local_id: str | int,
) -> None:
certificate_id = self._local_id(
local_id,
"certificate",
)
self.request(
"DELETE",
f"/nginx/certificates/{certificate_id}",
expected_status=200,
)
def create_certificate(
self,
payload: dict[str, Any],
+138 -2
View File
@@ -3,11 +3,14 @@ from typing import Any
from ...adapters import (
CERTIFICATES,
PROXY_HOSTS,
ApplyResult,
Capability,
CapabilityOperation,
DiscoveredObject,
NormalizedObject,
ObjectReference,
PlanAction,
PlanOperation,
)
from ...certificates.material import CertificateMaterial
from ...npm_client import NpmClient
@@ -312,9 +315,142 @@ class Plugin:
return baseline
def apply(self, action):
def apply(
self,
action: PlanAction,
) -> ApplyResult | None:
desired = action.desired
if desired is None:
raise RuntimeError(
"NPM apply requires desired state"
)
if action.endpoint_id != self.endpoint.identity:
raise RuntimeError(
"NPM apply action endpoint does not match adapter"
)
if (
desired.object_type == CERTIFICATES
and action.operation == PlanOperation.CREATE
):
return self._create_certificate(desired)
raise RuntimeError(
f"{self.endpoint.identity} does not support apply yet"
f"{self.endpoint.identity} does not support "
f"{action.operation} for {desired.object_type}"
)
def _create_certificate(
self,
desired: NormalizedObject,
) -> ApplyResult:
material = self._certificate_material_source(
desired
)
domains = desired.payload.get("domain_names")
if (
not isinstance(domains, list)
or not domains
or not all(
isinstance(value, str) and value
for value in domains
)
):
raise RuntimeError(
"NPM certificate create requires domain_names"
)
nice_name = desired.payload.get("nice_name")
if not isinstance(nice_name, str):
nice_name = ""
nice_name = nice_name.strip()
if not nice_name:
nice_name = (
"dns-sync: "
+ ", ".join(domains)
)
created = self.client.create_certificate(
{
# This node is receiving existing certificate
# material. It must not independently perform ACME
# issuance.
"provider": "other",
"domain_names": list(domains),
"nice_name": nice_name,
"meta": {
"cluster_managed": True,
"acme_renew": False,
"dns_challenge": False,
},
}
)
created_id = created.get("id")
if (
isinstance(created_id, bool)
or not isinstance(created_id, int)
or created_id < 1
):
raise RuntimeError(
"NPM certificate create returned invalid id"
)
local_key = str(created_id)
try:
self.client.upload_certificate(
local_key,
certificate=material.certificate,
certificate_key=material.certificate_key,
intermediate_certificate=(
material.intermediate_certificate
),
)
row = self.client.certificate(local_key)
actual = normalize_discovered_object(
self._certificate_from_row(row)
)
if actual.local_key != local_key:
raise RuntimeError(
"Created NPM certificate returned "
"unexpected local id"
)
if not self._portable_certificate_matches(
actual,
desired,
):
raise RuntimeError(
"Created NPM certificate does not match "
"desired portable certificate state"
)
except Exception:
try:
self.client.delete_certificate(local_key)
except Exception as rollback_error:
raise RuntimeError(
"NPM certificate creation failed and "
"rollback also failed"
) from rollback_error
raise
return ApplyResult(
object=actual,
detail="created certificate",
)
def verify(self, object: NormalizedObject) -> bool: