feat(topology): canonical_hash for applied-state comparison

Tiny pure helper both master and agent will use to answer "is the
applied state the one we expect?".  SHA-256 of canonical JSON with
volatile keys (timestamps, status, version, canvas x/y/w/h) stripped
so the hash only captures deployment-relevant state.

Step 2 of the agent <-> topology integration.
This commit is contained in:
2026-04-21 01:20:42 -04:00
parent 5a0cf5d7c8
commit 98465af226
2 changed files with 145 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
"""Canonical hash of a hydrated topology dict.
Both master and agent need to agree on "is the applied state the one
the master intends?". We answer that by hashing the hydrated topology
blob on both sides and comparing the hex digests. The function has to
be **pure** and **deterministic**: same logical state → same hash, no
matter the dict-key order, no matter the timezone of a ``created_at``.
Normalisation rules (applied to a deep copy — input is never mutated):
- Drop fields that change on every read but don't change behaviour:
``created_at``, ``status_changed_at``, ``updated_at``, ``last_seen``,
``status``, ``version``, ``last_error``.
- Drop purely-cosmetic canvas positions (``x``, ``y``, ``w``, ``h``)
everywhere — they're client-side layout, not deployment state.
- Leave everything else alone; sort-keys=True + ``separators``
collapse whitespace and fix ordering.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
# Fields that vary over time or come from layout and must NOT feed the
# applied-state hash. Dropped at every nesting level.
_VOLATILE_KEYS = frozenset(
{
"created_at",
"status_changed_at",
"updated_at",
"last_seen",
"status",
"version",
"last_error",
"x",
"y",
"w",
"h",
}
)
def _strip(value: Any) -> Any:
"""Return a deep copy of *value* with volatile keys removed."""
if isinstance(value, dict):
return {k: _strip(v) for k, v in value.items() if k not in _VOLATILE_KEYS}
if isinstance(value, list):
return [_strip(v) for v in value]
return value
def canonical_hash(hydrated: dict) -> str:
"""Return the SHA-256 hex digest of *hydrated*'s canonical form."""
normalised = _strip(hydrated)
blob = json.dumps(
normalised,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(blob).hexdigest()
__all__ = ["canonical_hash"]

View File

@@ -0,0 +1,80 @@
"""Tests for :mod:`decnet.topology.hashing`."""
from __future__ import annotations
import copy
from decnet.topology.hashing import canonical_hash
def _sample() -> dict:
return {
"topology": {
"id": "t1",
"name": "n",
"mode": "agent",
"target_host_uuid": "h1",
"status": "deploying",
"version": 3,
"created_at": "2026-04-21T00:00:00+00:00",
},
"lans": [
{"id": "l1", "name": "dmz", "subnet": "10.0.0.0/24", "is_dmz": True,
"x": 40, "y": 40},
],
"deckies": [
{
"uuid": "d1",
"name": "gw",
"services": ["ssh"],
"decky_config": {"archetype": "deaddeck", "forwards_l3": True},
"state": "pending",
"x": 10,
"y": 20,
}
],
"edges": [
{"id": "e1", "decky_uuid": "d1", "lan_id": "l1",
"is_bridge": True, "forwards_l3": True},
],
}
def test_hash_is_stable() -> None:
assert canonical_hash(_sample()) == canonical_hash(_sample())
def test_key_order_does_not_matter() -> None:
a = _sample()
b = {
"edges": a["edges"],
"deckies": a["deckies"],
"lans": a["lans"],
"topology": a["topology"],
}
assert canonical_hash(a) == canonical_hash(b)
def test_volatile_fields_ignored() -> None:
a = _sample()
b = copy.deepcopy(a)
b["topology"]["status"] = "active"
b["topology"]["version"] = 99
b["topology"]["status_changed_at"] = "2099-01-01T00:00:00+00:00"
b["deckies"][0]["last_error"] = "transient"
b["deckies"][0]["x"] = 9999
b["lans"][0]["y"] = 12345
assert canonical_hash(a) == canonical_hash(b)
def test_behavioural_change_flips_hash() -> None:
a = _sample()
b = copy.deepcopy(a)
b["deckies"][0]["services"] = ["ssh", "http"]
assert canonical_hash(a) != canonical_hash(b)
def test_input_is_not_mutated() -> None:
a = _sample()
snapshot = copy.deepcopy(a)
_ = canonical_hash(a)
assert a == snapshot