diff --git a/dns_sync/npm_client.py b/dns_sync/npm_client.py index 59defcc..894462e 100644 --- a/dns_sync/npm_client.py +++ b/dns_sync/npm_client.py @@ -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, diff --git a/dns_sync/plugins/adapters/npm.py b/dns_sync/plugins/adapters/npm.py index 2c65946..c080612 100644 --- a/dns_sync/plugins/adapters/npm.py +++ b/dns_sync/plugins/adapters/npm.py @@ -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( diff --git a/tests/test_npm_adapter.py b/tests/test_npm_adapter.py index ebb24a2..a85ceb9 100644 --- a/tests/test_npm_adapter.py +++ b/tests/test_npm_adapter.py @@ -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()) diff --git a/tests/test_npm_client.py b/tests/test_npm_client.py index 1301213..8ffef49 100644 --- a/tests/test_npm_client.py +++ b/tests/test_npm_client.py @@ -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(