Prepare NPM references and mutation API

This commit is contained in:
Lyncoln
2026-09-19 22:52:14 -04:00
parent 8caac79a1e
commit 381119a6b5
6 changed files with 436 additions and 10 deletions
+153
View File
@@ -129,6 +129,29 @@ class NpmClient:
) )
return response.json() 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]]: def proxy_hosts(self) -> list[dict[str, Any]]:
data = self.get_json("/nginx/proxy-hosts") data = self.get_json("/nginx/proxy-hosts")
@@ -148,3 +171,133 @@ class NpmClient:
) )
return data 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",
)
-7
View File
@@ -121,12 +121,6 @@ def _normalize_proxy_host(
if not forward_host: if not forward_host:
raise RuntimeError(f"{label} has no 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] = { normalized_payload: dict[str, Any] = {
"domain_names": list(domains), "domain_names": list(domains),
"forward_scheme": forward_scheme, "forward_scheme": forward_scheme,
@@ -135,7 +129,6 @@ def _normalize_proxy_host(
payload.get("forward_port"), payload.get("forward_port"),
label, label,
), ),
"certificate_local_key": certificate_local_key,
} }
for field in _PROXY_BOOLEAN_FIELDS: for field in _PROXY_BOOLEAN_FIELDS:
normalized_payload[field] = _normalized_bool( normalized_payload[field] = _normalized_bool(
+29
View File
@@ -6,6 +6,7 @@ from ...adapters import (
Capability, Capability,
CapabilityOperation, CapabilityOperation,
DiscoveredObject, DiscoveredObject,
ObjectReference,
) )
from ...npm_client import NpmClient from ...npm_client import NpmClient
@@ -81,11 +82,39 @@ class Plugin:
field: row.get(field) field: row.get(field)
for field in _PROXY_HOST_FIELDS 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( discovered.append(
DiscoveredObject( DiscoveredObject(
object_type=PROXY_HOSTS, object_type=PROXY_HOSTS,
local_key=self._local_key(row, "proxy host"), local_key=self._local_key(row, "proxy host"),
payload=payload, payload=payload,
references=references,
) )
) )
+33
View File
@@ -7,6 +7,7 @@ from dns_sync.adapters import (
PROXY_HOSTS, PROXY_HOSTS,
Capability, Capability,
CapabilityOperation, CapabilityOperation,
ObjectReference,
) )
from dns_sync.config import AppConfig from dns_sync.config import AppConfig
from dns_sync.registry import load_adapter from dns_sync.registry import load_adapter
@@ -110,6 +111,38 @@ class NpmAdapterTests(unittest.TestCase):
discovered[0].payload, 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): def test_certificate_discovery_excludes_secret_material(self):
client = Mock() client = Mock()
client.certificates.return_value = [ client.certificates.return_value = [
+197 -1
View File
@@ -1,6 +1,6 @@
import os import os
import unittest import unittest
from unittest.mock import patch from unittest.mock import Mock, patch
from dns_sync.adapters import Endpoint from dns_sync.adapters import Endpoint
from dns_sync.config import AppConfig from dns_sync.config import AppConfig
@@ -71,5 +71,201 @@ class NpmClientEndpointTests(unittest.TestCase):
NpmClient(config, endpoint) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+24 -2
View File
@@ -6,6 +6,7 @@ from dns_sync.adapters import (
DNS_RECORDS, DNS_RECORDS,
PROXY_HOSTS, PROXY_HOSTS,
DiscoveredObject, DiscoveredObject,
ObjectReference,
) )
from dns_sync.object_normalizer import ( from dns_sync.object_normalizer import (
normalize_discovered_object, normalize_discovered_object,
@@ -83,6 +84,13 @@ class ProxyHostNormalizationTests(unittest.TestCase):
"ssl_forced": 1, "ssl_forced": 1,
"enabled": True, "enabled": True,
}, },
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="67",
),
),
) )
normalized = normalize_discovered_object(discovered) 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_scheme"], "http")
self.assertEqual(normalized.payload["forward_port"], 8080) self.assertEqual(normalized.payload["forward_port"], 8080)
self.assertNotIn(
"certificate_local_key",
normalized.payload,
)
self.assertNotIn(
"certificate_id",
normalized.payload,
)
self.assertEqual( self.assertEqual(
normalized.payload["certificate_local_key"], normalized.references,
"67", (
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="67",
),
),
) )
self.assertTrue(normalized.payload["ssl_forced"]) self.assertTrue(normalized.payload["ssl_forced"])
self.assertTrue(normalized.payload["enabled"]) self.assertTrue(normalized.payload["enabled"])