#!/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))