Add NPM proxy host apply paths

This commit is contained in:
Lyncoln
2026-09-20 01:00:42 -04:00
parent 6ec4f2932e
commit c4cdc92865
4 changed files with 748 additions and 1 deletions
+15
View File
@@ -223,6 +223,21 @@ class NpmClient:
"proxy-host update",
)
def delete_proxy_host(
self,
local_id: str | int,
) -> None:
proxy_host_id = self._local_id(
local_id,
"proxy host",
)
self.request(
"DELETE",
f"/nginx/proxy-hosts/{proxy_host_id}",
expected_status=200,
)
def certificate(
self,
local_id: str | int,
+282 -1
View File
@@ -315,6 +315,271 @@ class Plugin:
return baseline
@staticmethod
def _proxy_host_matches(
actual: NormalizedObject,
desired: NormalizedObject,
*,
local_key: str | None = None,
) -> bool:
if actual.object_type != PROXY_HOSTS:
return False
if desired.object_type != PROXY_HOSTS:
return False
if local_key is not None and actual.local_key != local_key:
return False
if actual.logical_key != desired.logical_key:
return False
if actual.payload != desired.payload:
return False
if actual.references != desired.references:
return False
return True
@staticmethod
def _proxy_host_certificate_id(
desired: NormalizedObject,
) -> int:
certificate_references = []
for reference in desired.references:
if (
reference.relation != "certificate"
or reference.object_type != CERTIFICATES
):
raise RuntimeError(
"NPM proxy host has unsupported object reference"
)
certificate_references.append(reference)
if not certificate_references:
return 0
if len(certificate_references) != 1:
raise RuntimeError(
"NPM proxy host requires at most one "
"certificate reference"
)
reference = certificate_references[0]
if reference.local_key is None:
raise RuntimeError(
"NPM proxy host certificate reference "
"was not resolved to an endpoint-local key"
)
try:
certificate_id = int(reference.local_key)
except (TypeError, ValueError) as exc:
raise RuntimeError(
"NPM proxy host certificate reference "
"has invalid endpoint-local key"
) from exc
if certificate_id < 1:
raise RuntimeError(
"NPM proxy host certificate reference "
"has invalid endpoint-local key"
)
return certificate_id
def _proxy_host_api_payload(
self,
desired: NormalizedObject,
) -> dict[str, Any]:
if desired.object_type != PROXY_HOSTS:
raise RuntimeError(
"Proxy-host API payload requires proxy-host state"
)
payload = desired.payload
domains = 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 proxy host requires domain_names"
)
forward_scheme = payload.get("forward_scheme")
if (
not isinstance(forward_scheme, str)
or not forward_scheme
):
raise RuntimeError(
"NPM proxy host requires forward_scheme"
)
forward_host = payload.get("forward_host")
if (
not isinstance(forward_host, str)
or not forward_host
):
raise RuntimeError(
"NPM proxy host requires forward_host"
)
forward_port = payload.get("forward_port")
if (
isinstance(forward_port, bool)
or not isinstance(forward_port, int)
or forward_port < 1
or forward_port > 65535
):
raise RuntimeError(
"NPM proxy host requires valid forward_port"
)
api_payload: dict[str, Any] = {
"domain_names": list(domains),
"forward_scheme": forward_scheme,
"forward_host": forward_host,
"forward_port": forward_port,
"certificate_id": (
self._proxy_host_certificate_id(desired)
),
}
for field in _PROXY_HOST_FIELDS:
if field in (
"domain_names",
"forward_scheme",
"forward_host",
"forward_port",
"certificate_id",
):
continue
if field in payload:
api_payload[field] = payload[field]
return api_payload
def _create_proxy_host(
self,
desired: NormalizedObject,
) -> ApplyResult:
payload = self._proxy_host_api_payload(
desired
)
created = self.client.create_proxy_host(
payload
)
local_key = self._local_key(
created,
"proxy host",
)
try:
row = self.client.proxy_host(local_key)
actual = normalize_discovered_object(
self._proxy_host_from_row(row)
)
if not self._proxy_host_matches(
actual,
desired,
local_key=local_key,
):
raise RuntimeError(
"Created NPM proxy host does not match "
"desired state"
)
except Exception:
try:
self.client.delete_proxy_host(
local_key
)
except Exception as rollback_error:
raise RuntimeError(
"NPM proxy-host creation failed and "
"rollback also failed"
) from rollback_error
raise
return ApplyResult(
object=actual,
detail="created proxy host",
)
def _update_proxy_host(
self,
action: PlanAction,
) -> ApplyResult:
desired = action.desired
current = action.current
if desired is None:
raise RuntimeError(
"NPM proxy-host update requires desired state"
)
if (
current is None
or current.object_type != PROXY_HOSTS
or current.local_key is None
):
raise RuntimeError(
"NPM proxy-host update requires "
"current endpoint-local state"
)
if current.logical_key != desired.logical_key:
raise RuntimeError(
"NPM proxy-host update identity changed"
)
local_key = current.local_key
payload = self._proxy_host_api_payload(
desired
)
self.client.update_proxy_host(
local_key,
payload,
)
row = self.client.proxy_host(local_key)
actual = normalize_discovered_object(
self._proxy_host_from_row(row)
)
if not self._proxy_host_matches(
actual,
desired,
local_key=local_key,
):
raise RuntimeError(
"Updated NPM proxy host does not match "
"desired state"
)
return ApplyResult(
object=actual,
detail="updated proxy host",
)
def apply(
self,
action: PlanAction,
@@ -337,6 +602,18 @@ class Plugin:
):
return self._create_certificate(desired)
if (
desired.object_type == PROXY_HOSTS
and action.operation == PlanOperation.CREATE
):
return self._create_proxy_host(desired)
if (
desired.object_type == PROXY_HOSTS
and action.operation == PlanOperation.UPDATE
):
return self._update_proxy_host(action)
raise RuntimeError(
f"{self.endpoint.identity} does not support "
f"{action.operation} for {desired.object_type}"
@@ -465,7 +742,11 @@ class Plugin:
self._proxy_host_from_row(row)
)
return actual == object
return self._proxy_host_matches(
actual,
object,
local_key=object.local_key,
)
if object.object_type == CERTIFICATES:
row = self.client.certificate(
+438
View File
@@ -1161,6 +1161,444 @@ class NpmAdapterTests(unittest.TestCase):
target_client.upload_certificate.assert_not_called()
target_client.delete_certificate.assert_not_called()
def test_proxy_host_create_uses_resolved_target_certificate_id(self):
client = Mock()
client.create_proxy_host.return_value = {
"id": 120,
}
client.proxy_host.return_value = {
"id": 120,
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
"certificate_id": 84,
"ssl_forced": True,
"caching_enabled": False,
"block_exploits": True,
"allow_websocket_upgrade": True,
"http2_support": True,
"hsts_enabled": True,
"hsts_subdomains": False,
"enabled": True,
}
adapter = self.adapter(client)
desired = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
"ssl_forced": True,
"caching_enabled": False,
"block_exploits": True,
"allow_websocket_upgrade": True,
"http2_support": True,
"hsts_enabled": True,
"hsts_subdomains": False,
"enabled": True,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="84",
),
),
)
result = adapter.apply(
PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.CREATE,
desired=desired,
)
)
client.create_proxy_host.assert_called_once_with(
{
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
"certificate_id": 84,
"ssl_forced": True,
"caching_enabled": False,
"block_exploits": True,
"allow_websocket_upgrade": True,
"http2_support": True,
"hsts_enabled": True,
"hsts_subdomains": False,
"enabled": True,
}
)
self.assertEqual(
result.object.local_key,
"120",
)
self.assertEqual(
result.object.references,
desired.references,
)
client.delete_proxy_host.assert_not_called()
def test_proxy_host_create_without_certificate_uses_zero(self):
client = Mock()
client.create_proxy_host.return_value = {
"id": 120,
}
client.proxy_host.return_value = {
"id": 120,
"domain_names": ["plain.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
"certificate_id": 0,
"enabled": True,
}
adapter = self.adapter(client)
desired = normalize_discovered_object(
adapter._proxy_host_from_row(
client.proxy_host.return_value
)
)
desired = NormalizedObject(
object_type=desired.object_type,
logical_key=desired.logical_key,
payload=desired.payload,
references=desired.references,
)
result = adapter.apply(
PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.CREATE,
desired=desired,
)
)
payload = client.create_proxy_host.call_args.kwargs
if not payload:
payload = client.create_proxy_host.call_args.args[0]
self.assertEqual(
payload["certificate_id"],
0,
)
self.assertEqual(
result.object.references,
(),
)
def test_proxy_host_create_rolls_back_when_refetch_mismatches(self):
client = Mock()
client.create_proxy_host.return_value = {
"id": 120,
}
client.proxy_host.return_value = {
"id": 120,
"domain_names": ["wrong.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
"certificate_id": 0,
"enabled": True,
}
adapter = self.adapter(client)
desired = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
"ssl_forced": False,
"caching_enabled": False,
"block_exploits": False,
"allow_websocket_upgrade": False,
"http2_support": False,
"hsts_enabled": False,
"hsts_subdomains": False,
"enabled": True,
},
)
with self.assertRaisesRegex(
RuntimeError,
"does not match desired",
):
adapter.apply(
PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.CREATE,
desired=desired,
)
)
client.delete_proxy_host.assert_called_once_with(
"120"
)
def test_proxy_host_create_reports_rollback_failure(self):
client = Mock()
client.create_proxy_host.return_value = {
"id": 120,
}
client.proxy_host.side_effect = RuntimeError(
"refetch failed"
)
client.delete_proxy_host.side_effect = RuntimeError(
"delete failed"
)
adapter = self.adapter(client)
desired = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
},
)
with self.assertRaisesRegex(
RuntimeError,
"rollback also failed",
):
adapter.apply(
PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.CREATE,
desired=desired,
)
)
def test_proxy_host_update_uses_current_local_id_and_resolved_reference(self):
client = Mock()
client.proxy_host.return_value = {
"id": 12,
"domain_names": ["app.example.com"],
"forward_scheme": "https",
"forward_host": "new-backend.internal",
"forward_port": 8443,
"certificate_id": 84,
"ssl_forced": True,
"enabled": True,
}
adapter = self.adapter(client)
current = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "old-backend.internal",
"forward_port": 8080,
"ssl_forced": False,
"caching_enabled": False,
"block_exploits": False,
"allow_websocket_upgrade": False,
"http2_support": False,
"hsts_enabled": False,
"hsts_subdomains": False,
"enabled": True,
},
)
actual = normalize_discovered_object(
adapter._proxy_host_from_row(
client.proxy_host.return_value
)
)
desired = NormalizedObject(
object_type=actual.object_type,
logical_key=actual.logical_key,
payload=actual.payload,
references=actual.references,
)
result = adapter.apply(
PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.UPDATE,
desired=desired,
current=current,
)
)
client.update_proxy_host.assert_called_once()
args = client.update_proxy_host.call_args.args
self.assertEqual(args[0], "12")
self.assertEqual(
args[1]["certificate_id"],
84,
)
self.assertEqual(
result.object.local_key,
"12",
)
self.assertEqual(
result.object.references,
desired.references,
)
client.delete_proxy_host.assert_not_called()
def test_proxy_host_update_fails_when_target_does_not_converge(self):
client = Mock()
client.proxy_host.return_value = {
"id": 12,
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "still-old.internal",
"forward_port": 8080,
"certificate_id": 0,
"enabled": True,
}
adapter = self.adapter(client)
current = normalize_discovered_object(
adapter._proxy_host_from_row(
client.proxy_host.return_value
)
)
desired = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "new.internal",
"forward_port": 8080,
"ssl_forced": False,
"caching_enabled": False,
"block_exploits": False,
"allow_websocket_upgrade": False,
"http2_support": False,
"hsts_enabled": False,
"hsts_subdomains": False,
"enabled": True,
},
)
with self.assertRaisesRegex(
RuntimeError,
"does not match desired",
):
adapter.apply(
PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.UPDATE,
desired=desired,
current=current,
)
)
client.delete_proxy_host.assert_not_called()
def test_proxy_host_apply_rejects_unresolved_certificate_reference(self):
client = Mock()
adapter = self.adapter(client)
desired = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
with self.assertRaisesRegex(
RuntimeError,
"not resolved",
):
adapter.apply(
PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.CREATE,
desired=desired,
)
)
client.create_proxy_host.assert_not_called()
client.update_proxy_host.assert_not_called()
def test_proxy_host_apply_rejects_unsupported_reference(self):
client = Mock()
adapter = self.adapter(client)
desired = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
},
references=(
ObjectReference(
relation="other",
object_type=CERTIFICATES,
local_key="84",
),
),
)
with self.assertRaisesRegex(
RuntimeError,
"unsupported object reference",
):
adapter.apply(
PlanAction(
endpoint_id=adapter.endpoint.identity,
operation=PlanOperation.CREATE,
desired=desired,
)
)
client.create_proxy_host.assert_not_called()
def test_unsupported_operations_fail_explicitly(self):
adapter = self.adapter(Mock())
+13
View File
@@ -186,6 +186,19 @@ class NpmClientMutationTests(unittest.TestCase):
)
self.assertEqual(result["id"], 12)
def test_deletes_proxy_host_by_endpoint_local_id(self):
client = self.client()
client.request = Mock()
result = client.delete_proxy_host("12")
self.assertIsNone(result)
client.request.assert_called_once_with(
"DELETE",
"/nginx/proxy-hosts/12",
expected_status=200,
)
def test_gets_one_certificate_by_endpoint_local_id(self):
client = self.client()
client.get_json = Mock(