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>
This commit is contained in:
102
validate-dshackle-1to1.py
Normal file
102
validate-dshackle-1to1.py
Normal file
@@ -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))
|
||||
Reference in New Issue
Block a user