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