Prepare NPM references and mutation API
This commit is contained in:
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user