From 98465af22657bb123ddffbde1398945ea03dc021 Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 21 Apr 2026 01:20:42 -0400 Subject: [PATCH] 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. --- decnet/topology/hashing.py | 65 +++++++++++++++++++++++++++ tests/topology/test_hashing.py | 80 ++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 decnet/topology/hashing.py create mode 100644 tests/topology/test_hashing.py diff --git a/decnet/topology/hashing.py b/decnet/topology/hashing.py new file mode 100644 index 00000000..4979d7ab --- /dev/null +++ b/decnet/topology/hashing.py @@ -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"] diff --git a/tests/topology/test_hashing.py b/tests/topology/test_hashing.py new file mode 100644 index 00000000..1c890f8b --- /dev/null +++ b/tests/topology/test_hashing.py @@ -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