Wire NPM desired state into orchestration

This commit is contained in:
Lyncoln
2026-09-20 01:26:55 -04:00
parent 3022b76876
commit 4b094820b2
3 changed files with 391 additions and 10 deletions
+44 -3
View File
@@ -4,6 +4,7 @@ from datetime import datetime, timezone
from typing import Protocol from typing import Protocol
from .adapters import ( from .adapters import (
CERTIFICATES,
DNS_RECORDS, DNS_RECORDS,
PROXY_HOSTS, PROXY_HOSTS,
Capability, Capability,
@@ -188,11 +189,28 @@ def discover_reconcile_plan(
store, store,
set(adapters_by_endpoint), set(adapters_by_endpoint),
) )
npm_observations = [
observation
for observation in observations
if observation.object.object_type
in {
CERTIFICATES,
PROXY_HOSTS,
}
]
npm_desired = (
reconcile_observations(npm_observations)
if npm_observations
else []
)
proxy_observations = [ proxy_observations = [
observation observation
for observation in observations for observation in observations
if observation.object.object_type == PROXY_HOSTS if observation.object.object_type == PROXY_HOSTS
] ]
projected_dns = project_proxy_hosts_to_dns( projected_dns = project_proxy_hosts_to_dns(
proxy_observations, proxy_observations,
target_ip, target_ip,
@@ -204,12 +222,35 @@ def discover_reconcile_plan(
domain=domain, domain=domain,
) )
desired = reconcile_observations(projected_dns) dns_desired = (
store.replace_desired_state( reconcile_observations(projected_dns)
if projected_dns
else []
)
# Keep the reconciliation domains separate until each has
# produced portable desired state. In particular, router DNS
# observations must never participate in the proxy->DNS union.
desired = [
*npm_desired,
*dns_desired,
]
for object_type in (
CERTIFICATES,
PROXY_HOSTS,
DNS_RECORDS, DNS_RECORDS,
desired, ):
store.replace_desired_state(
object_type,
[
object
for object in desired
if object.object_type == object_type
],
timestamp, timestamp,
) )
plans = build_endpoint_plans( plans = build_endpoint_plans(
adapter_list, adapter_list,
desired, desired,
+27 -5
View File
@@ -147,9 +147,19 @@ class ReadOnlyOrchestrationTests(unittest.TestCase):
DNS_RECORDS DNS_RECORDS
) )
self.assertEqual(len(result.desired), 1) self.assertEqual(len(
[
item
for item in result.desired
if item.object_type == DNS_RECORDS
]
), 1)
self.assertEqual( self.assertEqual(
result.desired[0].payload["ip"], next(
item
for item in result.desired
if item.object_type == DNS_RECORDS
).payload["ip"],
"192.168.1.101", "192.168.1.101",
) )
self.assertEqual(len(result.plans), 1) self.assertEqual(len(result.plans), 1)
@@ -220,7 +230,11 @@ class ReadOnlyOrchestrationTests(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
tuple(item.logical_key for item in result.desired), tuple(
item.logical_key
for item in result.desired
if item.object_type == DNS_RECORDS
),
("cached.example.com",), ("cached.example.com",),
) )
self.assertEqual( self.assertEqual(
@@ -304,7 +318,11 @@ class ReadOnlyOrchestrationTests(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
tuple(item.logical_key for item in result.desired), tuple(
item.logical_key
for item in result.desired
if item.object_type == DNS_RECORDS
),
("app.example.com",), ("app.example.com",),
) )
@@ -344,7 +362,11 @@ class ReadOnlyOrchestrationTests(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
tuple(item.logical_key for item in result.desired), tuple(
item.logical_key
for item in result.desired
if item.object_type == DNS_RECORDS
),
("active.example.com",), ("active.example.com",),
) )
+318
View File
@@ -0,0 +1,318 @@
import unittest
from dataclasses import dataclass
from datetime import datetime, timezone
from dns_sync.adapters import (
CERTIFICATES,
DNS_RECORDS,
PROXY_HOSTS,
Capability,
CapabilityOperation,
DiscoveredObject,
Endpoint,
ObjectReference,
)
from dns_sync.orchestrator import discover_reconcile_plan
from dns_sync.persistence import SQLiteStateStore
NOW = datetime(
2026,
9,
20,
9,
30,
tzinfo=timezone.utc,
)
@dataclass
class FakeNpmAdapter:
endpoint: Endpoint
@property
def capabilities(self):
return frozenset(
{
Capability(
CERTIFICATES,
CapabilityOperation.DISCOVER,
),
Capability(
CERTIFICATES,
CapabilityOperation.VERIFY,
),
Capability(
PROXY_HOSTS,
CapabilityOperation.DISCOVER,
),
Capability(
PROXY_HOSTS,
CapabilityOperation.VERIFY,
),
}
)
def connect(self):
return None
def discover(self, object_type):
if object_type == CERTIFICATES:
return [
DiscoveredObject(
object_type=CERTIFICATES,
local_key="67",
payload={
"domain_names": [
"app.example.com",
],
"provider": "letsencrypt",
"nice_name": "app",
"valid_from": (
"2026-09-01T00:00:00Z"
),
"expires_on": (
"2026-12-01T00:00:00Z"
),
"cluster_managed": True,
"acme_renew": True,
"dns_challenge": False,
},
),
]
if object_type == PROXY_HOSTS:
return [
DiscoveredObject(
object_type=PROXY_HOSTS,
local_key="12",
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="67",
),
),
),
]
raise RuntimeError(
f"unsupported discovery type: {object_type}"
)
def apply(self, action):
raise AssertionError(
"NPM APPLY must remain unavailable"
)
def verify(self, object):
return True
class NpmDesiredStateOrchestrationTests(
unittest.TestCase
):
def test_orchestrator_builds_and_persists_all_three_desired_types(
self,
):
adapter = FakeNpmAdapter(
Endpoint(
"npm",
"192.168.1.3:81",
)
)
with SQLiteStateStore(":memory:") as store:
run = discover_reconcile_plan(
[adapter],
store,
target_ip="192.168.1.101",
run_at=NOW,
)
desired_by_type = {
object.object_type: object
for object in run.desired
}
self.assertEqual(
set(desired_by_type),
{
CERTIFICATES,
PROXY_HOSTS,
DNS_RECORDS,
},
)
certificate = desired_by_type[
CERTIFICATES
]
proxy = desired_by_type[PROXY_HOSTS]
dns = desired_by_type[DNS_RECORDS]
self.assertIsNone(
certificate.local_key
)
self.assertIsNone(
proxy.local_key
)
self.assertIsNone(
dns.local_key
)
self.assertEqual(
proxy.references,
(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
self.assertEqual(
dns.payload["hostname"],
"app.example.com",
)
self.assertEqual(
dns.payload["ip"],
"192.168.1.101",
)
for object_type in (
CERTIFICATES,
PROXY_HOSTS,
DNS_RECORDS,
):
persisted = (
store.load_desired_state(
object_type
)
)
self.assertEqual(
len(persisted),
1,
)
self.assertEqual(
persisted[0].object,
desired_by_type[
object_type
],
)
# The NPM adapter intentionally does not advertise
# APPLY, so this read-only orchestration must not
# produce an NPM endpoint plan.
self.assertEqual(
run.plans,
(),
)
def test_empty_npm_snapshot_clears_stale_npm_desired_state(
self,
):
adapter = FakeNpmAdapter(
Endpoint(
"npm",
"192.168.1.3:81",
)
)
with SQLiteStateStore(":memory:") as store:
first = discover_reconcile_plan(
[adapter],
store,
target_ip="192.168.1.101",
run_at=NOW,
)
self.assertTrue(first.desired)
self.assertEqual(
len(
store.load_desired_state(
CERTIFICATES
)
),
1,
)
self.assertEqual(
len(
store.load_desired_state(
PROXY_HOSTS
)
),
1,
)
class EmptyNpmAdapter(
FakeNpmAdapter
):
def discover(
self,
object_type,
):
if object_type in {
CERTIFICATES,
PROXY_HOSTS,
}:
return []
return super().discover(
object_type
)
empty = EmptyNpmAdapter(
adapter.endpoint
)
second = discover_reconcile_plan(
[empty],
store,
target_ip="192.168.1.101",
run_at=NOW.replace(minute=31),
)
self.assertEqual(
second.desired,
(),
)
self.assertEqual(
store.load_desired_state(
CERTIFICATES
),
[],
)
self.assertEqual(
store.load_desired_state(
PROXY_HOSTS
),
[],
)
self.assertEqual(
store.load_desired_state(
DNS_RECORDS
),
[],
)
if __name__ == "__main__":
unittest.main()