From bcc24595a99d0bade68ba98e14f16486f64da053 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 15 Jul 2026 00:12:48 +0000 Subject: [PATCH] add validate-dshackle-1to1.py: structural 1:1 proxy invariant gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refuses to activate a dshackle config routing >1 upstream for the same chain (any kind/client) — we cannot attribute traffic to multiple nodes behind one proxy (no per-upstream request-count metric; conn-seconds biases it), and the attribution model + placement planner both assume 1:1. Wire into reload_dshackle.sh BEFORE the reload so a drifting config can never go live. Host-local by design (the repo doesn't track host->dshackle assignment; 1:1 is only checkable against the assembled dshackle config at /root/rpc/main_configs). See /root/proxy-1to1-invariant-plan.md. Co-Authored-By: Claude --- validate-dshackle-1to1.py | 102 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 validate-dshackle-1to1.py diff --git a/validate-dshackle-1to1.py b/validate-dshackle-1to1.py new file mode 100644 index 00000000..e1bf0d13 --- /dev/null +++ b/validate-dshackle-1to1.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""validate-dshackle-1to1.py — STRUCTURAL enforcement of the 1:1 proxy invariant. + +1 dshackle = 1 node per chain: a dshackle MUST NOT route two upstreams for the same chain +(any kind or client). We cannot attribute traffic to multiple nodes behind one proxy (no +per-upstream request-count metric; conn-seconds biases it), so the config must be 1:1 — and +the attribution model (splits_node_revenue) + the placement planner both assume it. +See /root/proxy-1to1-invariant-plan.md and the /management skill ("1 dshackle = 1 node/chain"). + +This is the STRUCTURAL gate (vs the planner's runtime gate): it refuses to ACTIVATE a +dshackle config that violates 1:1, so a drifting config can never go live. Wire it into +reload_dshackle.sh BEFORE the dshackle reload (reject -> no reload -> old config stays): + + python3 /opt/.../rpc/validate-dshackle-1to1.py /root/rpc/main_configs || { + echo "REFUSING reload: 1:1 invariant violated (see above)"; exit 1; } + +Why host-local, not in update.sh: the vibe-node repo generates NODE composes (each declares +ONE chain via x-upstreams) but does not track which nodes attach to which dshackle — that +assignment is made at deploy/config-assembly time. So 1:1 can only be checked against the +ASSEMBLED dshackle config (cluster.upstreams), which lives here on the gateway host. + +Usage: python3 validate-dshackle-1to1.py [config_dir_or_file] (default /root/rpc/main_configs) +Exit 0 = OK; exit 1 = violation (do not reload). +""" +import sys, os, glob, collections + +try: + import yaml +except ImportError: + print("validate-1to1: PyYAML required (pip install pyyaml)", file=sys.stderr) + sys.exit(2) + +DEFAULT = '/root/rpc/main_configs' + + +def _upstream_chains(doc): + """Yield each upstream's `chain` in a dshackle config doc. + + Handles the shapes dshackle configs use: + - cluster.upstreams: [{id, chain, methods, ...}, ...] (the standard dshackle cluster) + - x-upstreams: [{id, chain, ...}, ...] (compose extension; node-side def) + An upstream without a `chain` field (bare id reference) is skipped here — if your config + references upstreams by id only (chain resolved elsewhere), extend this to map id->chain + from the x-upstreams defs in the same doc. + """ + if not isinstance(doc, dict): + return + cl = doc.get('cluster') or {} + if isinstance(cl, dict): + for key in ('upstreams', 'include'): + v = cl.get(key) + if isinstance(v, list): + for u in v: + if isinstance(u, dict) and u.get('chain'): + yield u['chain'] + xs = doc.get('x-upstreams') + if isinstance(xs, list): + for u in xs: + if isinstance(u, dict) and u.get('chain'): + yield u['chain'] + + +def main(argv): + path = argv[1] if len(argv) > 1 else DEFAULT + if os.path.isfile(path): + files = [path] + else: + files = sorted(glob.glob(os.path.join(path, '*.yaml')) + glob.glob(os.path.join(path, '*.yml'))) + if not files: + print(f"validate-1to1: no dshackle configs at {path} (nothing to check)") + return 0 + + violations = [] # (config_file, chain, count) + checked = 0 + for f in files: + try: + docs = list(yaml.safe_load_all(open(f))) + except Exception as e: + print(f"validate-1to1: WARN could not parse {f}: {e}", file=sys.stderr) + continue + for doc in docs: + by_chain = collections.Counter(_upstream_chains(doc)) + if not by_chain: + continue + checked += 1 + for ch, n in by_chain.items(): + if n > 1: + violations.append((f, ch, n)) + if violations: + print("❌ 1:1 invariant VIOLATIONS — a dshackle routing >1 node for the same chain:") + for f, ch, n in violations: + print(f" {os.path.basename(f)}: chain '{ch}' routed by {n} upstreams " + f"— keep ONE node/proxy; for diversity add a dshackle (horizontal), don't stack") + print("REFUSING to activate this config. Resolve by moving the extra node(s) behind a " + "different dshackle in the same region.") + return 1 + print(f"✅ 1:1 invariant OK — {checked} dshackle config(s), each chain ≤1 node per proxy.") + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv))