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 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,
|
||||||
|
|||||||
@@ -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",),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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