From bf173d8d986e10ccef484fa9b4957e00e884f5d7 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Thu, 23 Jul 2026 15:43:10 +0000 Subject: [PATCH] Pin the chaos mesh root to n01 so scenario diagrams describe the real tree Every chaos scenario draws n01 at the top of its topology, and until now that held in three of thirteen. The mesh roots itself at the numerically smallest NodeAddr, which is a hash of the node's public key and bears no relation to the node numbering, so which node ended up as root was effectively arbitrary. The consequences were not cosmetic. cost-reeval rooted at n04, its own designated test subject, so that node had no parent and the periodic parent switch the scenario exists to observe could not occur at all. mixed-technology rooted at n09, which put its two documented parent criteria out of reach and made correct cost-based selection look like a defect. Identities are still derived from the mesh name exactly as before and are still deterministic. What changes is which node id holds which one: they are now assigned in NodeAddr order, so n01 holds the smallest and is the root. Verified against a model of the daemon's own derivation that reproduces the previously observed root for every scenario and n01's address byte for byte; all twelve pinned scenarios now root at n01. smoke-10 deliberately opts out via pin_root: false so that root election from an arbitrary key distribution stays exercised somewhere. Its assertion is a convergence floor and is root-agnostic, which is why it is the cheapest home for that. The new key is rejected when non-boolean, and a near-miss spelling is rejected as unknown; both checked. Not yet established: the trees themselves change, so the parent-dependent assertions in bottleneck-parent, cost-avoidance and cost-stability need re-deriving against live runs. Those are held until the in-flight CI finishes, because a chaos run rebuilds the shared fips-test image that run is using. --- testing/chaos/scenarios/smoke-10.yaml | 10 +++++++++ testing/chaos/sim/keys.py | 2 +- testing/chaos/sim/scenario.py | 17 +++++++++++++++- testing/chaos/sim/topology.py | 29 ++++++++++++++++++++++----- testing/lib/derive_keys.py | 19 ++++++++++++++++-- 5 files changed, 68 insertions(+), 9 deletions(-) diff --git a/testing/chaos/scenarios/smoke-10.yaml b/testing/chaos/scenarios/smoke-10.yaml index 9a63958..452e9b5 100644 --- a/testing/chaos/scenarios/smoke-10.yaml +++ b/testing/chaos/scenarios/smoke-10.yaml @@ -11,6 +11,16 @@ topology: ensure_connected: true subnet: "172.20.0.0/24" ip_start: 10 + # The one scenario that deliberately does NOT pin the root. Every other + # scenario assigns identities in NodeAddr order so that n01 is root and its + # diagram describes the tree that actually forms. That is right for them, + # because they test parent selection and were silently testing it under a + # root they did not intend, but applying it everywhere would leave root + # election itself exercised nowhere. This is the generic 10-node convergence + # baseline and its `baseline` assertion is root-agnostic, so it is the + # cheapest place to keep election under test. + # Do not "fix" this to true for consistency with the others. + pin_root: false # Phase 2+ features (disabled for MVP) netem: diff --git a/testing/chaos/sim/keys.py b/testing/chaos/sim/keys.py index dd2dd24..69ee9d1 100644 --- a/testing/chaos/sim/keys.py +++ b/testing/chaos/sim/keys.py @@ -8,4 +8,4 @@ _TESTING_DIR = os.path.join(os.path.dirname(__file__), "..", "..") if _TESTING_DIR not in sys.path: sys.path.insert(0, _TESTING_DIR) -from lib.derive_keys import derive # noqa: E402 +from lib.derive_keys import derive, derive_full # noqa: E402,F401 diff --git a/testing/chaos/sim/scenario.py b/testing/chaos/sim/scenario.py index d7e30c4..8f03f83 100644 --- a/testing/chaos/sim/scenario.py +++ b/testing/chaos/sim/scenario.py @@ -44,6 +44,14 @@ class TopologyConfig: # When set, each edge is randomly assigned a transport based on weights. # Only valid for non-explicit algorithms (explicit uses per-edge syntax). transport_mix: dict[str, float] | None = None + # Assign derived identities in NodeAddr order so n01 holds the smallest and + # is therefore the root every scenario diagram draws. Default on: without it + # the root is effectively arbitrary, which left `cost-reeval` rooted at its + # own designated test subject — so the parent switch it exists to observe + # could not occur — and made `mixed-technology`'s parent criteria + # unreachable. Set false where election from an arbitrary key distribution + # is itself the subject. + pin_root: bool = True @dataclass @@ -372,7 +380,7 @@ _SECTION_KEYS = { "scenario": {"name", "seed", "duration_secs"}, "topology": { "num_nodes", "algorithm", "params", "ensure_connected", "subnet", - "ip_start", "default_transport", "transport_mix", + "ip_start", "default_transport", "transport_mix", "pin_root", }, "netem": {"enabled", "default_policy", "link_policies", "mutation"}, "netem.link_policies[]": {"edges", "policy", "policy_name"}, @@ -494,6 +502,13 @@ def load_scenario(path: str) -> Scenario: s.topology.subnet = tc.get("subnet", "172.20.0.0/24") s.topology.ip_start = int(tc.get("ip_start", 10)) s.topology.default_transport = tc.get("default_transport", "udp") + if "pin_root" in tc: + pin = tc["pin_root"] + if not isinstance(pin, bool): + raise ValueError( + f"topology.pin_root must be a boolean, got {pin!r}" + ) + s.topology.pin_root = pin if "transport_mix" in tc: mix = tc["transport_mix"] if not isinstance(mix, dict) or not mix: diff --git a/testing/chaos/sim/topology.py b/testing/chaos/sim/topology.py index 69ea769..4ac99d3 100644 --- a/testing/chaos/sim/topology.py +++ b/testing/chaos/sim/topology.py @@ -7,7 +7,7 @@ import random from collections import deque from dataclasses import dataclass, field -from .keys import derive +from .keys import derive_full from .naming import name_suffix, veth_token from .scenario import TopologyConfig @@ -203,12 +203,31 @@ def generate_topology( n = config.num_nodes subnet_base = config.subnet.rsplit(".", 1)[0] # "172.20.0" - # Create nodes with IPs and keys + # Create nodes with IPs and keys. + # + # The mesh roots itself at the numerically smallest NodeAddr + # (`src/tree/state.rs:363-390`), which is a hash of the node's public key + # and so bears no relation to the node numbering. Every scenario diagram in + # this tree draws n01 at the top, and before this ordering was applied that + # held in only three of thirteen: `cost-reeval` rooted at n04 — its own + # designated test subject, which therefore had no parent to switch and could + # not exercise what the scenario exists to test — and `mixed-technology` at + # n09, which made its two documented parent criteria unreachable. + # + # So derive the identities from the mesh name as before, then *assign* them + # in NodeAddr order: n01 receives the smallest and is the root, n02 the next, + # and so on. The keys are unchanged and still deterministic; only which node + # id holds which one changes. Scenarios that want an arbitrary root set + # `pin_root: false` and keep exercising election. + node_ids_ordered = [f"n{i + 1:02d}" for i in range(n)] + identities = [derive_full(mesh_name, nid) for nid in node_ids_ordered] + if config.pin_root: + identities.sort(key=lambda t: t[2]) + nodes: dict[str, SimNode] = {} - for i in range(n): - node_id = f"n{i + 1:02d}" + for i, node_id in enumerate(node_ids_ordered): docker_ip = f"{subnet_base}.{config.ip_start + i}" - nsec, npub = derive(mesh_name, node_id) + nsec, npub, _ = identities[i] nodes[node_id] = SimNode( node_id=node_id, docker_ip=docker_ip, diff --git a/testing/lib/derive_keys.py b/testing/lib/derive_keys.py index 37495e5..460095d 100755 --- a/testing/lib/derive_keys.py +++ b/testing/lib/derive_keys.py @@ -93,13 +93,28 @@ def _convertbits(data, frombits, tobits): # --- public API --- def derive(mesh_name, node_name): + nsec_hex, npub, _ = derive_full(mesh_name, node_name) + return nsec_hex, npub + + +def derive_full(mesh_name, node_name): + """As `derive`, plus the NodeAddr the daemon will compute for this key. + + NodeAddr is the first 16 bytes of SHA-256 over the serialized x-only + public key (`src/identity/node_addr.rs:36-43`), and the mesh elects the + numerically smallest one as root (`src/tree/state.rs:363-390`). Callers + that need the tree's root to be predictable derive it from here rather + than assuming it follows the node numbering. + """ nsec_hex = hashlib.sha256(f"{mesh_name}|{node_name}".encode()).hexdigest() k = int(nsec_hex, 16) pub = _scalar_mult(k, (Gx, Gy)) x_hex = format(pub[0], "064x") - data_5bit = _convertbits(list(bytes.fromhex(x_hex)), 8, 5) + x_bytes = bytes.fromhex(x_hex) + data_5bit = _convertbits(list(x_bytes), 8, 5) npub = _bech32_encode("npub", data_5bit) - return nsec_hex, npub + node_addr = hashlib.sha256(x_bytes).hexdigest()[:32] + return nsec_hex, npub, node_addr if __name__ == "__main__":