Wire NPM desired state into orchestration
This commit is contained in:
@@ -4,6 +4,7 @@ from datetime import datetime, timezone
|
||||
from typing import Protocol
|
||||
|
||||
from .adapters import (
|
||||
CERTIFICATES,
|
||||
DNS_RECORDS,
|
||||
PROXY_HOSTS,
|
||||
Capability,
|
||||
@@ -188,11 +189,28 @@ def discover_reconcile_plan(
|
||||
store,
|
||||
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 = [
|
||||
observation
|
||||
for observation in observations
|
||||
if observation.object.object_type == PROXY_HOSTS
|
||||
]
|
||||
|
||||
projected_dns = project_proxy_hosts_to_dns(
|
||||
proxy_observations,
|
||||
target_ip,
|
||||
@@ -204,12 +222,35 @@ def discover_reconcile_plan(
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
desired = reconcile_observations(projected_dns)
|
||||
store.replace_desired_state(
|
||||
DNS_RECORDS,
|
||||
desired,
|
||||
timestamp,
|
||||
dns_desired = (
|
||||
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,
|
||||
):
|
||||
store.replace_desired_state(
|
||||
object_type,
|
||||
[
|
||||
object
|
||||
for object in desired
|
||||
if object.object_type == object_type
|
||||
],
|
||||
timestamp,
|
||||
)
|
||||
|
||||
plans = build_endpoint_plans(
|
||||
adapter_list,
|
||||
desired,
|
||||
|
||||
@@ -147,9 +147,19 @@ class ReadOnlyOrchestrationTests(unittest.TestCase):
|
||||
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(
|
||||
result.desired[0].payload["ip"],
|
||||
next(
|
||||
item
|
||||
for item in result.desired
|
||||
if item.object_type == DNS_RECORDS
|
||||
).payload["ip"],
|
||||
"192.168.1.101",
|
||||
)
|
||||
self.assertEqual(len(result.plans), 1)
|
||||
@@ -220,7 +230,11 @@ class ReadOnlyOrchestrationTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
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",),
|
||||
)
|
||||
self.assertEqual(
|
||||
@@ -304,7 +318,11 @@ class ReadOnlyOrchestrationTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
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",),
|
||||
)
|
||||
|
||||
@@ -344,7 +362,11 @@ class ReadOnlyOrchestrationTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
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",),
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user