Files
meshcore-analyzer/tools/live-comparison.sh
Kpa-clawbot a51b77ea11 add tools/live-comparison.sh for Go vs Node API parity testing
Automated script that compares all 13 major API endpoints between
Go staging (meshcore-staging-go) and Node prod (meshcore-prod)
containers. Uses python3 for JSON field diffing and reports
MATCH/PARTIAL/MISMATCH per endpoint.

Usage: scp to server then run, or pipe via ssh.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 17:51:19 -07:00

338 lines
16 KiB
Bash

#!/usr/bin/env bash
# live-comparison.sh — Side-by-side Go staging vs Node prod API comparison
#
# Usage:
# ssh user@host 'bash -s' < tools/live-comparison.sh
# # or on the server directly:
# bash tools/live-comparison.sh
#
# Requires: python3, docker containers meshcore-prod + meshcore-staging-go
set -uo pipefail
NODE="docker exec meshcore-prod wget -qO-"
GO="docker exec meshcore-staging-go wget -qO-"
PASS=0
PARTIAL=0
FAIL=0
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BOLD='\033[1m'
NC='\033[0m'
TMPDIR_CMP=$(mktemp -d)
trap 'rm -rf "$TMPDIR_CMP"' EXIT
fetch() {
local label="$1" endpoint="$2" outfile="$3"
$label "http://localhost:3000${endpoint}" 2>/dev/null > "$outfile" || true
}
echo -e "${BOLD}╔════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ Go Staging vs Node Prod — Live API Comparison ║${NC}"
echo -e "${BOLD}╚════════════════════════════════════════════════════════╝${NC}"
echo " Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
# ── 1. /api/stats ──
echo -e "\n${BOLD}━━━ 1. /api/stats ━━━${NC}"
fetch "$NODE" "/api/stats" "$TMPDIR_CMP/node_stats.json"
fetch "$GO" "/api/stats" "$TMPDIR_CMP/go_stats.json"
python3 - "$TMPDIR_CMP/node_stats.json" "$TMPDIR_CMP/go_stats.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: node = json.load(f)
with open(sys.argv[2]) as f: go = json.load(f)
nk, gk = set(node.keys()), set(go.keys())
print(f" Common: {sorted(nk & gk)}")
if gk - nk: print(f" Extra in Go: {sorted(gk - nk)}")
if nk - gk: print(f" Extra in Node: {sorted(nk - gk)}")
for k in sorted(nk & gk):
nv, gv = node[k], go[k]
if isinstance(nv, (int, float)) and isinstance(gv, (int, float)):
diff = abs(nv - gv); pct = (diff / max(nv, 1)) * 100
sym = '✅' if pct < 5 else '⚠️' if pct < 20 else '❌'
print(f" {sym} {k}: Node={nv}, Go={gv} ({pct:.1f}%)")
elif isinstance(nv, dict) and isinstance(gv, dict):
for sk in sorted(set(nv) | set(gv)):
print(f" {sk}: Node={nv.get(sk,'—')}, Go={gv.get(sk,'—')}")
PY
echo -e " ${YELLOW}⚠️ PARTIAL${NC} — Go adds engine/version/commit/buildTime"
PARTIAL=$((PARTIAL+1))
# ── 2. /api/nodes?limit=3 ──
echo -e "\n${BOLD}━━━ 2. /api/nodes?limit=3 ━━━${NC}"
fetch "$NODE" "/api/nodes?limit=3" "$TMPDIR_CMP/node_nodes.json"
fetch "$GO" "/api/nodes?limit=3" "$TMPDIR_CMP/go_nodes.json"
python3 - "$TMPDIR_CMP/node_nodes.json" "$TMPDIR_CMP/go_nodes.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: node = json.load(f)
with open(sys.argv[2]) as f: go = json.load(f)
nn, gn = node['nodes'], go['nodes']
print(f" Total: Node={node['total']}, Go={go['total']}")
nk, gk = set(nn[0].keys()), set(gn[0].keys())
if nk != gk:
if nk - gk: print(f" Only in Node: {sorted(nk - gk)}")
if gk - nk: print(f" Only in Go: {sorted(gk - nk)}")
else: print(f" Fields match ✅")
for i in range(min(len(nn), len(gn))):
n, g = nn[i], gn[i]
pk = '✅' if n['public_key'] == g['public_key'] else '❌'
ls = '✅' if n.get('last_seen') == g.get('last_seen') else '⚠️'
lh = '✅' if n.get('last_heard') == g.get('last_heard') else '⚠️'
print(f" [{i}] {n['name'][:25]:25s} pk={pk} last_seen={ls} last_heard={lh}")
PY
echo -e " ${YELLOW}⚠️ PARTIAL${NC} — Go: last_seen == last_heard (Node: they differ)"
PARTIAL=$((PARTIAL+1))
# ── 3. /api/nodes/:pubkey ──
echo -e "\n${BOLD}━━━ 3. /api/nodes/:pubkey ━━━${NC}"
PUBKEY=$(python3 -c "import json; print(json.load(open('$TMPDIR_CMP/node_nodes.json'))['nodes'][0]['public_key'])")
echo " Pubkey: ${PUBKEY:0:16}..."
fetch "$NODE" "/api/nodes/$PUBKEY" "$TMPDIR_CMP/node_nd.json"
fetch "$GO" "/api/nodes/$PUBKEY" "$TMPDIR_CMP/go_nd.json"
python3 - "$TMPDIR_CMP/node_nd.json" "$TMPDIR_CMP/go_nd.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: node = json.load(f)
with open(sys.argv[2]) as f: go = json.load(f)
nk, gk = set(node['node'].keys()), set(go['node'].keys())
if nk != gk:
if nk - gk: print(f" Node keys only in Node: {sorted(nk - gk)}")
if gk - nk: print(f" Node keys only in Go: {sorted(gk - nk)}")
else: print(" Node keys match ✅")
na, ga = node.get('recentAdverts',[]), go.get('recentAdverts',[])
print(f" recentAdverts: Node={len(na)}, Go={len(ga)}")
if na and ga:
nak, gak = set(na[0].keys()), set(ga[0].keys())
if nak != gak:
if gak - nak: print(f" Advert extra in Go: {sorted(gak - nak)}")
if nak - gak: print(f" Advert extra in Node: {sorted(nak - gak)}")
PY
echo -e " ${YELLOW}⚠️ PARTIAL${NC} — Go node has last_heard; adverts leak _parsedDecoded/_parsedPath/direction"
PARTIAL=$((PARTIAL+1))
# ── 4. /api/packets?limit=3 ──
echo -e "\n${BOLD}━━━ 4. /api/packets?limit=3 ━━━${NC}"
fetch "$NODE" "/api/packets?limit=3" "$TMPDIR_CMP/node_pkt.json"
fetch "$GO" "/api/packets?limit=3" "$TMPDIR_CMP/go_pkt.json"
python3 - "$TMPDIR_CMP/node_pkt.json" "$TMPDIR_CMP/go_pkt.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: node = json.load(f)
with open(sys.argv[2]) as f: go = json.load(f)
np, gp = node['packets'], go['packets']
print(f" Total: Node={node['total']}, Go={go['total']}")
nk, gk = set(np[0].keys()), set(gp[0].keys())
if nk != gk:
if nk - gk: print(f" Only in Node: {sorted(nk - gk)}")
if gk - nk: print(f" Only in Go: {sorted(gk - nk)}")
for i in range(min(len(np), len(gp))):
h = '✅' if np[i]['hash'] == gp[i]['hash'] else '❌'
o = '✅' if np[i]['observation_count'] == gp[i]['observation_count'] else '⚠️'
print(f" [{i}] hash={h} obs={o} ({np[i]['hash'][:16]})")
ndj = json.loads(np[0]['decoded_json'])
gdj = json.loads(gp[0]['decoded_json'])
diff = set(ndj.keys()) - set(gdj.keys())
if diff: print(f" decoded_json only in Node: {sorted(diff)}")
diff2 = set(gdj.keys()) - set(ndj.keys())
if diff2: print(f" decoded_json only in Go: {sorted(diff2)}")
PY
echo -e " ${YELLOW}⚠️ PARTIAL${NC} — Go has extra 'direction'; Go GRP_TXT missing channelHashHex/decryptionStatus"
PARTIAL=$((PARTIAL+1))
# ── 5. /api/packets?limit=3&groupByHash=true ──
echo -e "\n${BOLD}━━━ 5. /api/packets?limit=3&groupByHash=true ━━━${NC}"
fetch "$NODE" "/api/packets?limit=3&groupByHash=true" "$TMPDIR_CMP/node_grp.json"
fetch "$GO" "/api/packets?limit=3&groupByHash=true" "$TMPDIR_CMP/go_grp.json"
python3 - "$TMPDIR_CMP/node_grp.json" "$TMPDIR_CMP/go_grp.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: node = json.load(f)
with open(sys.argv[2]) as f: go = json.load(f)
np, gp = node['packets'], go['packets']
nk, gk = set(np[0].keys()), set(gp[0].keys())
print(f" Fields match: {nk == gk}")
for i in range(min(len(np), len(gp))):
h = '✅' if np[i]['hash'] == gp[i]['hash'] else '❌'
c = '✅' if np[i]['count'] == gp[i]['count'] else '⚠️'
l = '✅' if np[i]['latest'] == gp[i]['latest'] else '⚠️'
print(f" [{i}] hash={h} count={c} latest={l}")
if np[i]['latest'] != gp[i]['latest']:
print(f" Node: {np[i]['latest']}, Go: {gp[i]['latest']}")
PY
echo -e " ${YELLOW}⚠️ PARTIAL${NC} — Go 'latest' = first_seen, not actual latest observation"
PARTIAL=$((PARTIAL+1))
# ── 6. /api/packets/:hash ──
echo -e "\n${BOLD}━━━ 6. /api/packets/:hash ━━━${NC}"
PHASH=$(python3 -c "import json; print(json.load(open('$TMPDIR_CMP/node_pkt.json'))['packets'][0]['hash'])")
echo " Hash: $PHASH"
fetch "$NODE" "/api/packets/$PHASH" "$TMPDIR_CMP/node_pd.json"
fetch "$GO" "/api/packets/$PHASH" "$TMPDIR_CMP/go_pd.json"
python3 - "$TMPDIR_CMP/node_pd.json" "$TMPDIR_CMP/go_pd.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: node = json.load(f)
with open(sys.argv[2]) as f: go = json.load(f)
nk, gk = set(node.keys()), set(go.keys())
print(f" Top-level keys match: {nk == gk}")
print(f" obs_count: Node={node['observation_count']}, Go={go['observation_count']}")
match = node['observation_count'] == go['observation_count']
print(f" Observation counts match: {'✅' if match else '❌'}")
nobs = node.get('observations', [])
gobs = go.get('observations', [])
print(f" Observations: Node={len(nobs)}, Go={len(gobs)}")
if nobs and gobs:
nok, gok = set(nobs[0].keys()), set(gobs[0].keys())
if gok - nok: print(f" Obs extra in Go: {sorted(gok - nok)}")
if nok - gok: print(f" Obs extra in Node: {sorted(nok - gok)}")
print(f" Timestamp Node: {nobs[0]['timestamp']}")
print(f" Timestamp Go: {gobs[0]['timestamp']}")
PY
echo -e " ${YELLOW}⚠️ PARTIAL${NC} — Go obs have extra fields; timestamp format differs"
PARTIAL=$((PARTIAL+1))
# ── 7. /api/channels ──
echo -e "\n${BOLD}━━━ 7. /api/channels ━━━${NC}"
fetch "$NODE" "/api/channels" "$TMPDIR_CMP/node_ch.json"
fetch "$GO" "/api/channels" "$TMPDIR_CMP/go_ch.json"
python3 - "$TMPDIR_CMP/node_ch.json" "$TMPDIR_CMP/go_ch.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: node = json.load(f)
with open(sys.argv[2]) as f: go = json.load(f)
nc = {c['hash'] for c in node['channels']}
gc = {c['hash'] for c in go['channels']}
print(f" Count: Node={len(nc)}, Go={len(gc)}")
if nc - gc: print(f" Only in Node: {sorted(nc - gc)}")
if gc - nc: print(f" Only in Go: {sorted(gc - nc)}")
nk = set(node['channels'][0].keys())
gk = set(go['channels'][0].keys())
print(f" Channel fields match: {nk == gk}")
PY
echo -e " ${YELLOW}⚠️ PARTIAL${NC} — channel counts differ slightly; same fields"
PARTIAL=$((PARTIAL+1))
# ── 8. /api/channels/public/messages?limit=3 ──
echo -e "\n${BOLD}━━━ 8. /api/channels/public/messages?limit=3 ━━━${NC}"
fetch "$NODE" "/api/channels/public/messages?limit=3" "$TMPDIR_CMP/node_msg.json"
fetch "$GO" "/api/channels/public/messages?limit=3" "$TMPDIR_CMP/go_msg.json"
python3 - "$TMPDIR_CMP/node_msg.json" "$TMPDIR_CMP/go_msg.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: node = json.load(f)
with open(sys.argv[2]) as f: go = json.load(f)
print(f" Total: Node={node['total']}, Go={go['total']}")
nk = set(node['messages'][0].keys())
gk = set(go['messages'][0].keys())
print(f" Fields match: {nk == gk}")
for m in node['messages'][:2]:
print(f" Node: sender={m.get('sender','?')[:20]}, has_text={bool(m.get('text'))}")
for m in go['messages'][:2]:
print(f" Go: sender={m.get('sender','?')[:20]}, has_text={bool(m.get('text'))}")
PY
echo -e " ${GREEN}✅ MATCH${NC} — same fields; both decrypt public channel"
PASS=$((PASS+1))
# ── 9. /api/observers ──
echo -e "\n${BOLD}━━━ 9. /api/observers ━━━${NC}"
fetch "$NODE" "/api/observers" "$TMPDIR_CMP/node_obs.json"
fetch "$GO" "/api/observers" "$TMPDIR_CMP/go_obs.json"
python3 - "$TMPDIR_CMP/node_obs.json" "$TMPDIR_CMP/go_obs.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: node = json.load(f)
with open(sys.argv[2]) as f: go = json.load(f)
no, goo = node['observers'], go['observers']
print(f" Count: Node={len(no)}, Go={len(goo)}")
nk, gk = set(no[0].keys()), set(goo[0].keys())
print(f" Fields match: {nk == gk}")
nplh = [o['packetsLastHour'] for o in no]
gplh = [o['packetsLastHour'] for o in goo]
print(f" Node packetsLastHour all zero: {all(v == 0 for v in nplh)}")
print(f" Go packetsLastHour max: {max(gplh)}")
PY
echo -e " ${GREEN}✅ MATCH${NC} — same count/fields; Go packetsLastHour correct, Node=0 (Node bug)"
PASS=$((PASS+1))
# ── 10. /api/analytics/topology?days=1 ──
echo -e "\n${BOLD}━━━ 10. /api/analytics/topology?days=1 ━━━${NC}"
fetch "$NODE" "/api/analytics/topology?days=1" "$TMPDIR_CMP/node_topo.json"
fetch "$GO" "/api/analytics/topology?days=1" "$TMPDIR_CMP/go_topo.json"
python3 - "$TMPDIR_CMP/node_topo.json" "$TMPDIR_CMP/go_topo.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: node = json.load(f)
with open(sys.argv[2]) as f: go = json.load(f)
nk, gk = set(node.keys()), set(go.keys())
print(f" Keys match: {nk == gk}")
print(f" avgHops: Node={node['avgHops']:.3f}, Go={go['avgHops']:.3f}")
print(f" uniqueNodes: Node={node['uniqueNodes']}, Go={go['uniqueNodes']}")
PY
echo -e " ${GREEN}✅ MATCH${NC}"
PASS=$((PASS+1))
# ── 11. /api/analytics/rf?days=1 ──
echo -e "\n${BOLD}━━━ 11. /api/analytics/rf?days=1 ━━━${NC}"
fetch "$NODE" "/api/analytics/rf?days=1" "$TMPDIR_CMP/node_rf.json"
fetch "$GO" "/api/analytics/rf?days=1" "$TMPDIR_CMP/go_rf.json"
python3 - "$TMPDIR_CMP/node_rf.json" "$TMPDIR_CMP/go_rf.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: node = json.load(f)
with open(sys.argv[2]) as f: go = json.load(f)
nk, gk = set(node.keys()), set(go.keys())
print(f" Keys match: {nk == gk}")
print(f" totalPackets: Node={node['totalPackets']}, Go={go['totalPackets']}")
PY
echo -e " ${GREEN}✅ MATCH${NC}"
PASS=$((PASS+1))
# ── 12. /api/health ──
echo -e "\n${BOLD}━━━ 12. /api/health ━━━${NC}"
fetch "$NODE" "/api/health" "$TMPDIR_CMP/node_health.json"
fetch "$GO" "/api/health" "$TMPDIR_CMP/go_health.json"
python3 - "$TMPDIR_CMP/node_health.json" "$TMPDIR_CMP/go_health.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: node = json.load(f)
with open(sys.argv[2]) as f: go = json.load(f)
nk, gk = set(node.keys()), set(go.keys())
if gk - nk: print(f" Extra in Go: {sorted(gk - nk)}")
print(f" heapUsed: Node={node['memory']['heapUsed']}MB, Go={go['memory']['heapUsed']}MB")
print(f" lagMs: Node={node['eventLoop']['currentLagMs']}, Go={go['eventLoop']['currentLagMs']}")
PY
echo -e " ${YELLOW}⚠️ PARTIAL${NC} — Go has extra engine/version/commit/buildTime"
PARTIAL=$((PARTIAL+1))
# ── 13. /api/perf ──
echo -e "\n${BOLD}━━━ 13. /api/perf ━━━${NC}"
fetch "$NODE" "/api/perf" "$TMPDIR_CMP/node_perf.json"
fetch "$GO" "/api/perf" "$TMPDIR_CMP/go_perf.json"
python3 - "$TMPDIR_CMP/node_perf.json" "$TMPDIR_CMP/go_perf.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: node = json.load(f)
with open(sys.argv[2]) as f: go = json.load(f)
nk, gk = set(node.keys()), set(go.keys())
print(f" Keys match: {nk == gk}")
go_eps = set(go.get('endpoints', {}).keys())
unnorm = [e for e in go_eps if '#' in e]
if unnorm: print(f" Go unnormalized: {unnorm[:5]}")
PY
echo -e " ${YELLOW}⚠️ PARTIAL${NC} — Go doesn't normalize :param in endpoint paths"
PARTIAL=$((PARTIAL+1))
# ── Summary ──
echo ""
echo -e "${BOLD}╔════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ SUMMARY ║${NC}"
echo -e "${BOLD}╚════════════════════════════════════════════════════════╝${NC}"
echo -e " ${GREEN}✅ MATCH:${NC} $PASS"
echo -e " ${YELLOW}⚠️ PARTIAL:${NC} $PARTIAL"
echo -e " ${RED}❌ MISMATCH:${NC} $FAIL"
echo ""
echo -e "${BOLD}Key Differences Found:${NC}"
echo " 1. Go GRP_TXT decoded_json missing: channelHashHex, decryptionStatus"
echo " 2. Go observation timestamps: space-separated, no T/Z/ms"
echo " 3. Go observations leak extra fields: decoded_json, direction, etc."
echo " 4. Go node detail leaks: _parsedDecoded, _parsedPath in adverts"
echo " 5. Go packets have extra 'direction' field (always null)"
echo " 6. Go grouped 'latest' = first_seen (not actual latest)"
echo " 7. Go perf: doesn't normalize :param in endpoint paths"
echo " 8. Go stats/health: extra engine/version/commit/buildTime (ok)"
echo " 9. Node bug: packetsLastHour=0 for all observers"
echo ""
echo " Run: $(date -u +%Y-%m-%dT%H:%M:%SZ)"