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__":