Files
ethereum-rpc-docker/validate-dshackle-1to1.py
Claude Agent bcc24595a9 add validate-dshackle-1to1.py: structural 1:1 proxy invariant gate
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 <noreply@anthropic.com>
2026-07-15 00:12:48 +00:00

103 lines
4.3 KiB
Python

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