From 381119a6b5f1d8ca57605a29c515349c9e5dda32 Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Sat, 19 Sep 2026 22:52:14 -0400 Subject: [PATCH] Prepare NPM references and mutation API --- dns_sync/npm_client.py | 153 ++++++++++++++++++++++++ dns_sync/object_normalizer.py | 7 -- dns_sync/plugins/adapters/npm.py | 29 +++++ tests/test_npm_adapter.py | 33 ++++++ tests/test_npm_client.py | 198 ++++++++++++++++++++++++++++++- tests/test_object_normalizer.py | 26 +++- 6 files changed, 436 insertions(+), 10 deletions(-) diff --git a/dns_sync/npm_client.py b/dns_sync/npm_client.py index 54d5cb4..be124ad 100644 --- a/dns_sync/npm_client.py +++ b/dns_sync/npm_client.py @@ -129,6 +129,29 @@ class NpmClient: ) return response.json() + @staticmethod + def _json_object( + response: requests.Response, + label: str, + ) -> dict[str, Any]: + data = response.json() + if not isinstance(data, dict): + raise RuntimeError( + f"NPM {label} response was not an object" + ) + return data + + @staticmethod + def _local_id(value: str | int, label: str) -> str: + if isinstance(value, bool): + raise RuntimeError(f"NPM {label} id is invalid") + + local_id = str(value).strip() + if not local_id or not local_id.isdigit(): + raise RuntimeError(f"NPM {label} id is invalid") + + return local_id + def proxy_hosts(self) -> list[dict[str, Any]]: data = self.get_json("/nginx/proxy-hosts") @@ -148,3 +171,133 @@ class NpmClient: ) return data + + + def proxy_host( + self, + local_id: str | int, + ) -> dict[str, Any]: + host_id = self._local_id(local_id, "proxy host") + data = self.get_json( + f"/nginx/proxy-hosts/{host_id}" + ) + if not isinstance(data, dict): + raise RuntimeError( + "NPM proxy-host response was not an object" + ) + return data + + def create_proxy_host( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + response = self.request( + "POST", + "/nginx/proxy-hosts", + expected_status=201, + json=payload, + ) + return self._json_object( + response, + "proxy-host create", + ) + + def update_proxy_host( + self, + local_id: str | int, + payload: dict[str, Any], + ) -> dict[str, Any]: + host_id = self._local_id(local_id, "proxy host") + response = self.request( + "PUT", + f"/nginx/proxy-hosts/{host_id}", + expected_status=200, + json=payload, + ) + return self._json_object( + response, + "proxy-host update", + ) + + def certificate( + self, + local_id: str | int, + ) -> dict[str, Any]: + certificate_id = self._local_id( + local_id, + "certificate", + ) + data = self.get_json( + f"/nginx/certificates/{certificate_id}" + ) + if not isinstance(data, dict): + raise RuntimeError( + "NPM certificate response was not an object" + ) + return data + + def create_certificate( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + response = self.request( + "POST", + "/nginx/certificates", + expected_status=201, + json=payload, + ) + return self._json_object( + response, + "certificate create", + ) + + def upload_certificate( + self, + local_id: str | int, + *, + certificate: bytes, + certificate_key: bytes, + intermediate_certificate: bytes | None = None, + ) -> dict[str, Any]: + certificate_id = self._local_id( + local_id, + "certificate", + ) + + if not certificate: + raise RuntimeError( + "NPM certificate upload requires certificate data" + ) + if not certificate_key: + raise RuntimeError( + "NPM certificate upload requires certificate key data" + ) + + files: dict[str, tuple[str, bytes]] = { + "certificate": ( + "certificate.pem", + certificate, + ), + "certificate_key": ( + "certificate_key.pem", + certificate_key, + ), + } + + if intermediate_certificate: + files["intermediate_certificate"] = ( + "intermediate_certificate.pem", + intermediate_certificate, + ) + + response = self.request( + "POST", + f"/nginx/certificates/{certificate_id}/upload", + expected_status=200, + files=files, + ) + + return self._json_object( + response, + "certificate upload", + ) diff --git a/dns_sync/object_normalizer.py b/dns_sync/object_normalizer.py index 0bc2e5d..c986a25 100644 --- a/dns_sync/object_normalizer.py +++ b/dns_sync/object_normalizer.py @@ -121,12 +121,6 @@ def _normalize_proxy_host( if not forward_host: raise RuntimeError(f"{label} has no forward_host") - certificate_id = payload.get("certificate_id") - if certificate_id in (None, "", 0, "0"): - certificate_local_key = None - else: - certificate_local_key = str(certificate_id) - normalized_payload: dict[str, Any] = { "domain_names": list(domains), "forward_scheme": forward_scheme, @@ -135,7 +129,6 @@ def _normalize_proxy_host( payload.get("forward_port"), label, ), - "certificate_local_key": certificate_local_key, } for field in _PROXY_BOOLEAN_FIELDS: normalized_payload[field] = _normalized_bool( diff --git a/dns_sync/plugins/adapters/npm.py b/dns_sync/plugins/adapters/npm.py index bd91ba4..740a48f 100644 --- a/dns_sync/plugins/adapters/npm.py +++ b/dns_sync/plugins/adapters/npm.py @@ -6,6 +6,7 @@ from ...adapters import ( Capability, CapabilityOperation, DiscoveredObject, + ObjectReference, ) from ...npm_client import NpmClient @@ -81,11 +82,39 @@ class Plugin: field: row.get(field) for field in _PROXY_HOST_FIELDS } + certificate_id = row.get("certificate_id") + + if certificate_id in (None, "", 0, "0"): + references = () + else: + if isinstance(certificate_id, bool): + raise RuntimeError( + "NPM proxy host has invalid certificate_id" + ) + + certificate_local_key = str( + certificate_id + ).strip() + + if not certificate_local_key: + raise RuntimeError( + "NPM proxy host has invalid certificate_id" + ) + + references = ( + ObjectReference( + relation="certificate", + object_type=CERTIFICATES, + local_key=certificate_local_key, + ), + ) + discovered.append( DiscoveredObject( object_type=PROXY_HOSTS, local_key=self._local_key(row, "proxy host"), payload=payload, + references=references, ) ) diff --git a/tests/test_npm_adapter.py b/tests/test_npm_adapter.py index 46fe5b1..fde337b 100644 --- a/tests/test_npm_adapter.py +++ b/tests/test_npm_adapter.py @@ -7,6 +7,7 @@ from dns_sync.adapters import ( PROXY_HOSTS, Capability, CapabilityOperation, + ObjectReference, ) from dns_sync.config import AppConfig from dns_sync.registry import load_adapter @@ -110,6 +111,38 @@ class NpmAdapterTests(unittest.TestCase): discovered[0].payload, ) + self.assertEqual( + discovered[0].references, + ( + ObjectReference( + relation="certificate", + object_type=CERTIFICATES, + local_key="67", + ), + ), + ) + + def test_proxy_host_without_certificate_has_no_reference(self): + client = Mock() + client.proxy_hosts.return_value = [ + { + "id": 13, + "domain_names": ["plain.example.com"], + "forward_scheme": "http", + "forward_host": "192.168.1.20", + "forward_port": 8080, + "certificate_id": 0, + "enabled": 1, + } + ] + adapter = self.adapter(client) + + discovered = adapter.discover(PROXY_HOSTS) + + self.assertEqual(len(discovered), 1) + self.assertEqual(discovered[0].references, ()) + + def test_certificate_discovery_excludes_secret_material(self): client = Mock() client.certificates.return_value = [ diff --git a/tests/test_npm_client.py b/tests/test_npm_client.py index 785896a..47520a2 100644 --- a/tests/test_npm_client.py +++ b/tests/test_npm_client.py @@ -1,6 +1,6 @@ import os import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch from dns_sync.adapters import Endpoint from dns_sync.config import AppConfig @@ -71,5 +71,201 @@ class NpmClientEndpointTests(unittest.TestCase): NpmClient(config, endpoint) + + +class NpmClientMutationTests(unittest.TestCase): + def client(self): + with patch.dict( + os.environ, + { + "NPM_ENDPOINTS": "192.168.1.3", + "DNS_TARGET_IP": "192.168.1.101", + "NPM_IDENTITY": "test@example.com", + "NPM_SECRET": "not-a-real-secret", + }, + clear=True, + ): + config = AppConfig.from_env() + return NpmClient( + config, + config.npm_endpoints[0], + ) + + @staticmethod + def response(data): + response = Mock() + response.json.return_value = data + return response + + def test_gets_one_proxy_host_by_endpoint_local_id(self): + client = self.client() + client.get_json = Mock( + return_value={ + "id": 12, + "domain_names": ["app.example.com"], + } + ) + + result = client.proxy_host("12") + + client.get_json.assert_called_once_with( + "/nginx/proxy-hosts/12" + ) + self.assertEqual(result["id"], 12) + + def test_creates_proxy_host_with_json_payload(self): + client = self.client() + payload = { + "domain_names": ["app.example.com"], + "forward_scheme": "http", + "forward_host": "192.168.1.20", + "forward_port": 8080, + "certificate_id": 67, + } + client.request = Mock( + return_value=self.response({"id": 12}) + ) + + result = client.create_proxy_host(payload) + + client.request.assert_called_once_with( + "POST", + "/nginx/proxy-hosts", + expected_status=201, + json=payload, + ) + self.assertEqual(result["id"], 12) + + def test_updates_proxy_host_with_json_payload(self): + client = self.client() + payload = { + "forward_host": "192.168.1.30", + } + client.request = Mock( + return_value=self.response({"id": 12}) + ) + + result = client.update_proxy_host( + "12", + payload, + ) + + client.request.assert_called_once_with( + "PUT", + "/nginx/proxy-hosts/12", + expected_status=200, + json=payload, + ) + self.assertEqual(result["id"], 12) + + def test_gets_one_certificate_by_endpoint_local_id(self): + client = self.client() + client.get_json = Mock( + return_value={ + "id": 67, + "domain_names": ["app.example.com"], + } + ) + + result = client.certificate("67") + + client.get_json.assert_called_once_with( + "/nginx/certificates/67" + ) + self.assertEqual(result["id"], 67) + + def test_creates_certificate_metadata_with_json_payload(self): + client = self.client() + payload = { + "provider": "other", + "nice_name": "cluster copy", + "domain_names": ["app.example.com"], + "meta": { + "cluster_managed": True, + }, + } + client.request = Mock( + return_value=self.response({"id": 84}) + ) + + result = client.create_certificate(payload) + + client.request.assert_called_once_with( + "POST", + "/nginx/certificates", + expected_status=201, + json=payload, + ) + self.assertEqual(result["id"], 84) + + def test_uploads_certificate_material_as_multipart(self): + client = self.client() + client.request = Mock( + return_value=self.response({"id": 84}) + ) + + result = client.upload_certificate( + "84", + certificate=b"CERTIFICATE", + certificate_key=b"PRIVATE-KEY", + intermediate_certificate=b"INTERMEDIATE", + ) + + client.request.assert_called_once_with( + "POST", + "/nginx/certificates/84/upload", + expected_status=200, + files={ + "certificate": ( + "certificate.pem", + b"CERTIFICATE", + ), + "certificate_key": ( + "certificate_key.pem", + b"PRIVATE-KEY", + ), + "intermediate_certificate": ( + "intermediate_certificate.pem", + b"INTERMEDIATE", + ), + }, + ) + self.assertEqual(result["id"], 84) + + def test_certificate_upload_requires_both_required_parts(self): + client = self.client() + + with self.assertRaisesRegex( + RuntimeError, + "requires certificate data", + ): + client.upload_certificate( + "84", + certificate=b"", + certificate_key=b"PRIVATE-KEY", + ) + + with self.assertRaisesRegex( + RuntimeError, + "requires certificate key data", + ): + client.upload_certificate( + "84", + certificate=b"CERTIFICATE", + certificate_key=b"", + ) + + def test_rejects_invalid_endpoint_local_ids(self): + client = self.client() + + for local_id in ("", "abc", True, -1): + with self.subTest(local_id=local_id): + with self.assertRaisesRegex( + RuntimeError, + "id is invalid", + ): + client.certificate(local_id) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_object_normalizer.py b/tests/test_object_normalizer.py index 29cca4f..614755b 100644 --- a/tests/test_object_normalizer.py +++ b/tests/test_object_normalizer.py @@ -6,6 +6,7 @@ from dns_sync.adapters import ( DNS_RECORDS, PROXY_HOSTS, DiscoveredObject, + ObjectReference, ) from dns_sync.object_normalizer import ( normalize_discovered_object, @@ -83,6 +84,13 @@ class ProxyHostNormalizationTests(unittest.TestCase): "ssl_forced": 1, "enabled": True, }, + references=( + ObjectReference( + relation="certificate", + object_type=CERTIFICATES, + local_key="67", + ), + ), ) normalized = normalize_discovered_object(discovered) @@ -97,9 +105,23 @@ class ProxyHostNormalizationTests(unittest.TestCase): ) self.assertEqual(normalized.payload["forward_scheme"], "http") self.assertEqual(normalized.payload["forward_port"], 8080) + self.assertNotIn( + "certificate_local_key", + normalized.payload, + ) + self.assertNotIn( + "certificate_id", + normalized.payload, + ) self.assertEqual( - normalized.payload["certificate_local_key"], - "67", + normalized.references, + ( + ObjectReference( + relation="certificate", + object_type=CERTIFICATES, + local_key="67", + ), + ), ) self.assertTrue(normalized.payload["ssl_forced"]) self.assertTrue(normalized.payload["enabled"])