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
+414 -2
View File
@@ -9,6 +9,8 @@ from dns_sync.adapters import (
CapabilityOperation,
NormalizedObject,
ObjectReference,
PlanAction,
PlanOperation,
)
from dns_sync.certificates.material import CertificateMaterial
from dns_sync.config import AppConfig
@@ -762,14 +764,424 @@ class NpmAdapterTests(unittest.TestCase):
self.assertEqual(resolved, material)
def test_certificate_create_uploads_verifies_and_returns_target_id(self):
target_client = Mock()
source_client = Mock()
source_client.certificates.return_value = [
{
"id": 10,
"domain_names": ["app.example.com"],
"provider": "letsencrypt",
"nice_name": "source",
"valid_from": "2026-09-01T00:00:00Z",
"expires_on": "2026-12-01T00:00:00Z",
"meta": {},
}
]
source_client.certificate_material.return_value = (
CertificateMaterial(
certificate=b"CERT",
certificate_key=b"KEY",
intermediate_certificate=b"CHAIN",
)
)
target_client.create_certificate.return_value = {
"id": 84,
}
target_client.certificate.return_value = {
"id": 84,
"domain_names": ["app.example.com"],
"provider": "other",
"nice_name": "source",
"valid_from": "2026-09-01T00:00:00Z",
"expires_on": "2026-12-01T00:00:00Z",
"meta": {
"cluster_managed": True,
"acme_renew": False,
"dns_challenge": False,
},
}
adapter, client_factory = (
self.target_adapter_with_sources(
target_client,
{
"npm://192.168.1.3:81": source_client,
},
)
)
desired = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"provider": "letsencrypt",
"nice_name": "source",
"valid_from": "2026-09-01T00:00:00+00:00",
"expires_on": "2026-12-01T00:00:00+00:00",
"cluster_managed": True,
"acme_renew": True,
"dns_challenge": False,
},
)
action = PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.CREATE,
desired=desired,
)
with patch(
"dns_sync.plugins.adapters.npm.NpmClient",
side_effect=client_factory,
):
result = adapter.apply(action)
self.assertEqual(
result.object.local_key,
"84",
)
self.assertEqual(
result.object.logical_key,
"app.example.com",
)
target_client.create_certificate.assert_called_once_with(
{
"provider": "other",
"domain_names": ["app.example.com"],
"nice_name": "source",
"meta": {
"cluster_managed": True,
"acme_renew": False,
"dns_challenge": False,
},
}
)
target_client.upload_certificate.assert_called_once_with(
"84",
certificate=b"CERT",
certificate_key=b"KEY",
intermediate_certificate=b"CHAIN",
)
target_client.certificate.assert_called_once_with("84")
target_client.delete_certificate.assert_not_called()
def test_certificate_create_rolls_back_when_upload_fails(self):
target_client = Mock()
source_client = Mock()
source_client.certificates.return_value = [
{
"id": 10,
"domain_names": ["app.example.com"],
"provider": "other",
"valid_from": "2026-09-01T00:00:00Z",
"expires_on": "2026-12-01T00:00:00Z",
"meta": {},
}
]
source_client.certificate_material.return_value = (
CertificateMaterial(
certificate=b"CERT",
certificate_key=b"KEY",
)
)
target_client.create_certificate.return_value = {
"id": 84,
}
target_client.upload_certificate.side_effect = RuntimeError(
"upload failed"
)
adapter, client_factory = (
self.target_adapter_with_sources(
target_client,
{
"npm://192.168.1.3:81": source_client,
},
)
)
desired = 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",
},
)
action = PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.CREATE,
desired=desired,
)
with patch(
"dns_sync.plugins.adapters.npm.NpmClient",
side_effect=client_factory,
):
with self.assertRaisesRegex(
RuntimeError,
"upload failed",
):
adapter.apply(action)
target_client.delete_certificate.assert_called_once_with(
"84"
)
target_client.certificate.assert_not_called()
def test_certificate_create_rolls_back_when_created_state_is_wrong(self):
target_client = Mock()
source_client = Mock()
source_client.certificates.return_value = [
{
"id": 10,
"domain_names": ["app.example.com"],
"provider": "other",
"valid_from": "2026-09-01T00:00:00Z",
"expires_on": "2026-12-01T00:00:00Z",
"meta": {},
}
]
source_client.certificate_material.return_value = (
CertificateMaterial(
certificate=b"CERT",
certificate_key=b"KEY",
)
)
target_client.create_certificate.return_value = {
"id": 84,
}
target_client.certificate.return_value = {
"id": 84,
"domain_names": ["app.example.com"],
"provider": "other",
"valid_from": "2026-09-01T00:00:00Z",
# Wrong target certificate state.
"expires_on": "2027-01-01T00:00:00Z",
"meta": {},
}
adapter, client_factory = (
self.target_adapter_with_sources(
target_client,
{
"npm://192.168.1.3:81": source_client,
},
)
)
desired = 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",
},
)
action = PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.CREATE,
desired=desired,
)
with patch(
"dns_sync.plugins.adapters.npm.NpmClient",
side_effect=client_factory,
):
with self.assertRaisesRegex(
RuntimeError,
"does not match desired",
):
adapter.apply(action)
target_client.delete_certificate.assert_called_once_with(
"84"
)
def test_certificate_create_reports_rollback_failure_without_secrets(self):
target_client = Mock()
source_client = Mock()
source_client.certificates.return_value = [
{
"id": 10,
"domain_names": ["app.example.com"],
"provider": "other",
"valid_from": "2026-09-01T00:00:00Z",
"expires_on": "2026-12-01T00:00:00Z",
"meta": {},
}
]
source_client.certificate_material.return_value = (
CertificateMaterial(
certificate=b"DO-NOT-PRINT-CERT",
certificate_key=b"DO-NOT-PRINT-KEY",
)
)
target_client.create_certificate.return_value = {
"id": 84,
}
target_client.upload_certificate.side_effect = RuntimeError(
"upload failed"
)
target_client.delete_certificate.side_effect = RuntimeError(
"delete failed"
)
adapter, client_factory = (
self.target_adapter_with_sources(
target_client,
{
"npm://192.168.1.3:81": source_client,
},
)
)
desired = 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",
},
)
action = PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.CREATE,
desired=desired,
)
with patch(
"dns_sync.plugins.adapters.npm.NpmClient",
side_effect=client_factory,
):
with self.assertRaises(RuntimeError) as caught:
adapter.apply(action)
rendered = str(caught.exception)
self.assertIn(
"rollback also failed",
rendered,
)
self.assertNotIn(
"DO-NOT-PRINT-CERT",
rendered,
)
self.assertNotIn(
"DO-NOT-PRINT-KEY",
rendered,
)
def test_certificate_create_rejects_invalid_created_id(self):
target_client = Mock()
source_client = Mock()
source_client.certificates.return_value = [
{
"id": 10,
"domain_names": ["app.example.com"],
"provider": "other",
"valid_from": "2026-09-01T00:00:00Z",
"expires_on": "2026-12-01T00:00:00Z",
"meta": {},
}
]
source_client.certificate_material.return_value = (
CertificateMaterial(
certificate=b"CERT",
certificate_key=b"KEY",
)
)
target_client.create_certificate.return_value = {
"id": None,
}
adapter, client_factory = (
self.target_adapter_with_sources(
target_client,
{
"npm://192.168.1.3:81": source_client,
},
)
)
desired = 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",
},
)
action = PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.CREATE,
desired=desired,
)
with patch(
"dns_sync.plugins.adapters.npm.NpmClient",
side_effect=client_factory,
):
with self.assertRaisesRegex(
RuntimeError,
"invalid id",
):
adapter.apply(action)
target_client.upload_certificate.assert_not_called()
target_client.delete_certificate.assert_not_called()
def test_unsupported_operations_fail_explicitly(self):
adapter = self.adapter(Mock())
with self.assertRaisesRegex(RuntimeError, "cannot discover"):
adapter.discover("unsupported_objects")
with self.assertRaisesRegex(RuntimeError, "does not support apply"):
adapter.apply(Mock())
unsupported_action = PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.CREATE,
desired=NormalizedObject(
object_type="unsupported_objects",
logical_key="unsupported",
payload={},
),
)
with self.assertRaisesRegex(
RuntimeError,
"does not support",
):
adapter.apply(unsupported_action)
unsupported = NormalizedObject(
object_type="unsupported_objects",