Persist generic object references

This commit is contained in:
Lyncoln
2026-09-19 20:22:18 -04:00
parent d5caee8b8b
commit dc6505d165
3 changed files with 295 additions and 9 deletions
+132 -7
View File
@@ -6,7 +6,11 @@ from datetime import datetime, timezone
from os import PathLike from os import PathLike
from typing import Any from typing import Any
from .adapters import NormalizedObject, Observation from .adapters import (
NormalizedObject,
ObjectReference,
Observation,
)
_FORBIDDEN_PERSISTED_KEYS = frozenset( _FORBIDDEN_PERSISTED_KEYS = frozenset(
@@ -116,6 +120,71 @@ def _deserialize_payload(value: str) -> dict[str, Any]:
return payload return payload
def _serialize_references(
references: Iterable[ObjectReference],
) -> str:
return json.dumps(
[
{
"relation": reference.relation,
"object_type": reference.object_type,
"local_key": reference.local_key,
"logical_key": reference.logical_key,
}
for reference in references
],
sort_keys=True,
separators=(",", ":"),
)
def _deserialize_references(
value: str,
) -> tuple[ObjectReference, ...]:
try:
serialized = json.loads(value)
except json.JSONDecodeError as exc:
raise RuntimeError(
"Persisted object references are invalid JSON"
) from exc
if not isinstance(serialized, list):
raise RuntimeError(
"Persisted object references are not a list"
)
expected_fields = {
"relation",
"object_type",
"local_key",
"logical_key",
}
references: list[ObjectReference] = []
for item in serialized:
if (
not isinstance(item, dict)
or set(item) != expected_fields
):
raise RuntimeError(
"Persisted object reference has invalid fields"
)
try:
references.append(
ObjectReference(
relation=item["relation"],
object_type=item["object_type"],
local_key=item["local_key"],
logical_key=item["logical_key"],
)
)
except (TypeError, ValueError) as exc:
raise RuntimeError(
"Persisted object reference is invalid"
) from exc
return tuple(references)
class SQLiteStateStore: class SQLiteStateStore:
def __init__(self, path: str | PathLike[str]): def __init__(self, path: str | PathLike[str]):
self.path = str(path) self.path = str(path)
@@ -185,7 +254,7 @@ class SQLiteStateStore:
) )
version = versions[0]["version"] version = versions[0]["version"]
if version not in (1, 2): if version not in (1, 2, 3):
raise RuntimeError( raise RuntimeError(
"Unsupported SQLite state schema version: " "Unsupported SQLite state schema version: "
f"{version!r}" f"{version!r}"
@@ -232,6 +301,31 @@ class SQLiteStateStore:
SET version = 2; SET version = 2;
""" """
) )
version = 2
if version == 2:
migrations = (
("observations", "references_json"),
("desired_state", "references_json"),
("applied_state", "references_json"),
)
for table, column in migrations:
existing_columns = {
row["name"]
for row in self.connection.execute(
f"PRAGMA table_info({table})"
)
}
if column not in existing_columns:
self.connection.execute(
f"ALTER TABLE {table} "
f"ADD COLUMN {column} TEXT NOT NULL "
"DEFAULT '[]'"
)
self.connection.execute(
"UPDATE schema_meta SET version = 3"
)
def close(self) -> None: def close(self) -> None:
self.connection.close() self.connection.close()
@@ -254,7 +348,9 @@ class SQLiteStateStore:
snapshot_at, snapshot_at,
"Observation snapshot timestamp", "Observation snapshot timestamp",
) )
prepared: list[tuple[str, str, str, str, str, str]] = [] prepared: list[
tuple[str, str, str, str, str, str, str]
] = []
local_keys: set[str] = set() local_keys: set[str] = set()
for observation in rows: for observation in rows:
@@ -284,6 +380,7 @@ class SQLiteStateStore:
normalized.logical_key, normalized.logical_key,
normalized.local_key, normalized.local_key,
_serialize_payload(normalized.payload), _serialize_payload(normalized.payload),
_serialize_references(normalized.references),
_timestamp( _timestamp(
observation.observed_at, observation.observed_at,
"Observation timestamp", "Observation timestamp",
@@ -307,8 +404,9 @@ class SQLiteStateStore:
logical_key, logical_key,
local_key, local_key,
payload_json, payload_json,
references_json,
observed_at observed_at
) VALUES (?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
prepared, prepared,
) )
@@ -378,6 +476,7 @@ class SQLiteStateStore:
logical_key, logical_key,
local_key, local_key,
payload_json, payload_json,
references_json,
observed_at observed_at
FROM observations FROM observations
""" """
@@ -402,6 +501,9 @@ class SQLiteStateStore:
logical_key=row["logical_key"], logical_key=row["logical_key"],
local_key=row["local_key"], local_key=row["local_key"],
payload=_deserialize_payload(row["payload_json"]), payload=_deserialize_payload(row["payload_json"]),
references=_deserialize_references(
row["references_json"]
),
), ),
observed_at=_parse_timestamp(row["observed_at"]), observed_at=_parse_timestamp(row["observed_at"]),
) )
@@ -419,7 +521,9 @@ class SQLiteStateStore:
selected_at, selected_at,
"Desired-state selection timestamp", "Desired-state selection timestamp",
) )
prepared: list[tuple[str, str, str, str]] = [] prepared: list[
tuple[str, str, str, str, str]
] = []
logical_keys: set[str] = set() logical_keys: set[str] = set()
for normalized in rows: for normalized in rows:
@@ -444,6 +548,7 @@ class SQLiteStateStore:
object_type, object_type,
normalized.logical_key, normalized.logical_key,
_serialize_payload(normalized.payload), _serialize_payload(normalized.payload),
_serialize_references(normalized.references),
selected_timestamp, selected_timestamp,
) )
) )
@@ -462,8 +567,9 @@ class SQLiteStateStore:
object_type, object_type,
logical_key, logical_key,
payload_json, payload_json,
references_json,
selected_at selected_at
) VALUES (?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?)
""", """,
prepared, prepared,
) )
@@ -477,6 +583,7 @@ class SQLiteStateStore:
object_type, object_type,
logical_key, logical_key,
payload_json, payload_json,
references_json,
selected_at selected_at
FROM desired_state FROM desired_state
""" """
@@ -493,6 +600,9 @@ class SQLiteStateStore:
object_type=row["object_type"], object_type=row["object_type"],
logical_key=row["logical_key"], logical_key=row["logical_key"],
payload=_deserialize_payload(row["payload_json"]), payload=_deserialize_payload(row["payload_json"]),
references=_deserialize_references(
row["references_json"]
),
), ),
selected_at=_parse_timestamp(row["selected_at"]), selected_at=_parse_timestamp(row["selected_at"]),
) )
@@ -509,6 +619,9 @@ class SQLiteStateStore:
raise RuntimeError("Applied-state endpoint_id cannot be empty") raise RuntimeError("Applied-state endpoint_id cannot be empty")
payload_json = _serialize_payload(object.payload) payload_json = _serialize_payload(object.payload)
references_json = _serialize_references(
object.references
)
applied_timestamp = _timestamp( applied_timestamp = _timestamp(
applied_at, applied_at,
"Application timestamp", "Application timestamp",
@@ -523,11 +636,12 @@ class SQLiteStateStore:
logical_key, logical_key,
local_key, local_key,
payload_json, payload_json,
references_json,
applied_at, applied_at,
verification_attempted_at, verification_attempted_at,
verified_at, verified_at,
verification_error verification_error
) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL) ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
ON CONFLICT( ON CONFLICT(
endpoint_id, endpoint_id,
object_type, object_type,
@@ -535,6 +649,7 @@ class SQLiteStateStore:
) DO UPDATE SET ) DO UPDATE SET
local_key = excluded.local_key, local_key = excluded.local_key,
payload_json = excluded.payload_json, payload_json = excluded.payload_json,
references_json = excluded.references_json,
applied_at = excluded.applied_at, applied_at = excluded.applied_at,
verification_attempted_at = NULL, verification_attempted_at = NULL,
verified_at = NULL, verified_at = NULL,
@@ -546,6 +661,7 @@ class SQLiteStateStore:
object.logical_key, object.logical_key,
object.local_key, object.local_key,
payload_json, payload_json,
references_json,
applied_timestamp, applied_timestamp,
), ),
) )
@@ -564,6 +680,9 @@ class SQLiteStateStore:
"Verification timestamp", "Verification timestamp",
) )
payload_json = _serialize_payload(object.payload) payload_json = _serialize_payload(object.payload)
references_json = _serialize_references(
object.references
)
if succeeded: if succeeded:
verified_at = attempted_timestamp verified_at = attempted_timestamp
@@ -587,6 +706,7 @@ class SQLiteStateStore:
AND object_type = ? AND object_type = ?
AND logical_key = ? AND logical_key = ?
AND payload_json = ? AND payload_json = ?
AND references_json = ?
""", """,
( (
attempted_timestamp, attempted_timestamp,
@@ -596,6 +716,7 @@ class SQLiteStateStore:
object.object_type, object.object_type,
object.logical_key, object.logical_key,
payload_json, payload_json,
references_json,
), ),
) )
if cursor.rowcount != 1: if cursor.rowcount != 1:
@@ -655,6 +776,7 @@ class SQLiteStateStore:
logical_key, logical_key,
local_key, local_key,
payload_json, payload_json,
references_json,
applied_at, applied_at,
verification_attempted_at, verification_attempted_at,
verified_at, verified_at,
@@ -682,6 +804,9 @@ class SQLiteStateStore:
logical_key=row["logical_key"], logical_key=row["logical_key"],
local_key=row["local_key"], local_key=row["local_key"],
payload=_deserialize_payload(row["payload_json"]), payload=_deserialize_payload(row["payload_json"]),
references=_deserialize_references(
row["references_json"]
),
), ),
applied_at=_parse_timestamp(row["applied_at"]), applied_at=_parse_timestamp(row["applied_at"]),
verification_attempted_at=_parse_timestamp( verification_attempted_at=_parse_timestamp(
+21 -2
View File
@@ -203,7 +203,7 @@ class AppliedStatePersistenceTests(unittest.TestCase):
class SchemaMigrationTests(unittest.TestCase): class SchemaMigrationTests(unittest.TestCase):
def test_version_one_database_is_migrated_to_version_two(self): def test_version_one_database_is_migrated_to_version_three(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "state.sqlite3" path = Path(directory) / "state.sqlite3"
connection = sqlite3.connect(path) connection = sqlite3.connect(path)
@@ -230,10 +230,29 @@ class SchemaMigrationTests(unittest.TestCase):
""" """
) )
} }
reference_columns = {
table: {
row[1]
for row in store.connection.execute(
f"PRAGMA table_info({table})"
)
}
for table in (
"observations",
"desired_state",
"applied_state",
)
}
self.assertEqual(version, 2) self.assertEqual(version, 3)
self.assertIn("desired_state", tables) self.assertIn("desired_state", tables)
self.assertIn("applied_state", tables) self.assertIn("applied_state", tables)
self.assertTrue(
all(
"references_json" in columns
for columns in reference_columns.values()
)
)
if __name__ == "__main__": if __name__ == "__main__":
+142
View File
@@ -0,0 +1,142 @@
import unittest
from datetime import datetime, timezone
from dns_sync.adapters import (
CERTIFICATES,
PROXY_HOSTS,
NormalizedObject,
ObjectReference,
Observation,
)
from dns_sync.persistence import SQLiteStateStore
ENDPOINT = "npm://192.168.1.4:81"
NOW = datetime(2026, 9, 20, 0, 0, tzinfo=timezone.utc)
def local_certificate_reference() -> ObjectReference:
return ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="67",
)
def logical_certificate_reference() -> ObjectReference:
return ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
)
def proxy_host(
*,
local_key: str | None,
reference: ObjectReference,
) -> NormalizedObject:
return NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key=local_key,
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "192.168.1.20",
"forward_port": 8080,
"enabled": True,
},
references=(reference,),
)
class ReferencePersistenceTests(unittest.TestCase):
def test_observed_local_reference_round_trips(self):
observed = Observation(
endpoint_id=ENDPOINT,
object=proxy_host(
local_key="12",
reference=local_certificate_reference(),
),
observed_at=NOW,
)
with SQLiteStateStore(":memory:") as store:
store.replace_observations(
ENDPOINT,
PROXY_HOSTS,
[observed],
NOW,
)
loaded = store.load_observations(PROXY_HOSTS)
self.assertEqual(loaded, [observed])
self.assertEqual(
loaded[0].object.references[0].local_key,
"67",
)
def test_desired_logical_reference_round_trips(self):
desired = proxy_host(
local_key=None,
reference=logical_certificate_reference(),
)
with SQLiteStateStore(":memory:") as store:
store.replace_desired_state(
PROXY_HOSTS,
[desired],
NOW,
)
loaded = store.load_desired_state(PROXY_HOSTS)
self.assertEqual(loaded[0].object, desired)
self.assertEqual(
loaded[0].object.references[0].logical_key,
"app.example.com",
)
def test_applied_reference_participates_in_verification(self):
applied = proxy_host(
local_key="22",
reference=logical_certificate_reference(),
)
different_reference = proxy_host(
local_key="22",
reference=ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="other.example.com",
),
)
with SQLiteStateStore(":memory:") as store:
store.record_applied(ENDPOINT, applied, NOW)
with self.assertRaisesRegex(
RuntimeError,
"does not match",
):
store.record_verification_success(
ENDPOINT,
different_reference,
NOW,
)
store.record_verification_success(
ENDPOINT,
applied,
NOW,
)
loaded = store.load_applied_state(
ENDPOINT,
PROXY_HOSTS,
)
self.assertEqual(loaded[0].object, applied)
self.assertTrue(loaded[0].is_verified)
if __name__ == "__main__":
unittest.main()