Each Larry is independent. Bryan's question "how will Larry on Windows
talk to Larry on Linux for regression file transfer" answered: they don't.
File transfer is YOUR responsibility (scp / gh release / shared mount /
USB), but nc-regression now produces and consumes portable bundles that
make the split a one-command-on-each-side workflow.
Changes:
lib/nc-regression.sh
+ --phase env-a convenience for phases 1+2+3 (env-A side)
+ --phase env-b convenience for phases 4+5+6 (env-B side + diff)
+ --bundle-out PATH after env-A phases, tar inputs+outputs/env-a +
manifest.json + README.md + inbounds.txt
+ --bundle-in PATH at start, untar a bundle into $OUT; pulls scope
from the manifest so the env-B side just needs
--env-b and --route-test-cmd
MANUAL.md
+ New "Cross-environment Larry — how the boxes communicate" section
+ Bundle transport table (scp, gh release, NFS, USB, etc.)
+ Notes that the lesson loop uses the same local-capture / manual-
transport / central-merge model
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
401 lines
18 KiB
Bash
Executable File
401 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# nc-regression.sh — Example 6 orchestrator: end-to-end regression testing
|
|
# between two Cloverleaf environments.
|
|
#
|
|
# Phases:
|
|
# 1. discover → list inbound threads in scope (uses nc-find-inbound)
|
|
# 2. sample → grab N messages per inbound from env-A smatdb → input .msgs files
|
|
# 3. route-A → run route_test on env-A for each inbound → captured outputs/env-a/...
|
|
# 4. route-B → run route_test on env-B with same inputs → captured outputs/env-b/...
|
|
# 5. diff → hl7-diff every paired output file with --ignore MSH.7 → per-pair report
|
|
# 6. summary → one master regression-summary.md compiling everything
|
|
#
|
|
# Phases 3 and 4 require Cloverleaf's route_test command on each box. The
|
|
# command is parameterized via --route-test-cmd with placeholders:
|
|
# {THREAD} → the inbound thread name
|
|
# {INPUT} → absolute path to the .msgs input file
|
|
# {OUTPUT_DIR} → absolute path where output files should land
|
|
# {HCIROOT} → the env's HCIROOT
|
|
# {HCISITE} → the env's HCISITE
|
|
# Default: not set — you must pass it once for your shop's invocation pattern.
|
|
#
|
|
# A common pattern: a wrapper script that sources the Cloverleaf profile and
|
|
# runs `<thread> route_test <INPUT>`, with output redirected to OUTPUT_DIR.
|
|
# Example template you might pass:
|
|
# --route-test-cmd 'cd {HCIROOT}/{HCISITE} && . ./.profile && {THREAD} route_test {INPUT} && cp *.out.* {OUTPUT_DIR}/'
|
|
#
|
|
# Usage:
|
|
# nc-regression.sh --scope <SCOPE>
|
|
# --count N
|
|
# --env-a HCIROOT_A --site-a SITENAME
|
|
# --env-b HCIROOT_B --site-b SITENAME
|
|
# --out DIR
|
|
# --route-test-cmd 'TEMPLATE'
|
|
# [--ignore "FIELDS"]
|
|
# [--include-fields "FIELDS"]
|
|
# [--phase 1|2|3|4|5|6|all]
|
|
# [--dry-run]
|
|
# [--inbound-mode tcp-listen|icl-or-file|all]
|
|
# [--env-b-host HOST] [--env-b-user USER] # for scp of inputs
|
|
#
|
|
# Scope formats:
|
|
# thread:NAME one specific thread
|
|
# threads:N1,N2,N3 comma-separated list
|
|
# site every inbound in the configured site
|
|
# server every inbound in every site under HCIROOT
|
|
set -o pipefail
|
|
|
|
NC_SELF="$0"
|
|
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
|
|
NCP="$LIB_DIR/nc-parse.sh"
|
|
NCI="$LIB_DIR/nc-inbound.sh"
|
|
NCM="$LIB_DIR/nc-msgs.sh"
|
|
HL7DIFF="$LIB_DIR/hl7-diff.sh"
|
|
|
|
die() { printf 'nc-regression: %s\n' "$*" >&2; exit 1; }
|
|
say() { printf 'nc-regression: %s\n' "$*" >&2; }
|
|
|
|
SCOPE=""
|
|
COUNT=10
|
|
ENV_A=""
|
|
SITE_A=""
|
|
ENV_B=""
|
|
SITE_B=""
|
|
OUT=""
|
|
ROUTE_TEST_CMD=""
|
|
IGNORE="MSH.7"
|
|
INCLUDE=""
|
|
PHASE="all"
|
|
DRY_RUN=0
|
|
INBOUND_MODE="all"
|
|
ENV_B_HOST=""
|
|
ENV_B_USER=""
|
|
BUNDLE_OUT="" # after env-A phases, tar up the artifacts here
|
|
BUNDLE_IN="" # at start, untar a bundle here as the env-A artifacts
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--scope) shift; SCOPE="$1" ;;
|
|
--count) shift; COUNT="$1" ;;
|
|
--env-a) shift; ENV_A="$1" ;;
|
|
--site-a) shift; SITE_A="$1" ;;
|
|
--env-b) shift; ENV_B="$1" ;;
|
|
--site-b) shift; SITE_B="$1" ;;
|
|
--out) shift; OUT="$1" ;;
|
|
--route-test-cmd) shift; ROUTE_TEST_CMD="$1" ;;
|
|
--ignore) shift; IGNORE="$1" ;;
|
|
--include-fields) shift; INCLUDE="$1" ;;
|
|
--phase) shift; PHASE="$1" ;;
|
|
--dry-run) DRY_RUN=1 ;;
|
|
--inbound-mode) shift; INBOUND_MODE="$1" ;;
|
|
--env-b-host) shift; ENV_B_HOST="$1" ;;
|
|
--env-b-user) shift; ENV_B_USER="$1" ;;
|
|
--bundle-out) shift; BUNDLE_OUT="$1" ;;
|
|
--bundle-in) shift; BUNDLE_IN="$1" ;;
|
|
-h|--help) sed -n '2,55p' "$NC_SELF"; exit 0 ;;
|
|
-*) die "unknown flag: $1" ;;
|
|
*) die "extra arg: $1" ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
[ -n "$OUT" ] || die "missing --out DIR"
|
|
# When --bundle-in is given, we don't need scope/env-a/etc. — the bundle has them.
|
|
if [ -z "$BUNDLE_IN" ]; then
|
|
[ -n "$SCOPE" ] || die "missing --scope (thread:NAME | threads:N1,N2 | site | server)"
|
|
[ -n "$ENV_A" ] || die "missing --env-a HCIROOT_A"
|
|
[ -n "$ENV_B" ] || die "missing --env-b HCIROOT_B"
|
|
[ -d "$ENV_A" ] || die "env-a is not a directory: $ENV_A"
|
|
fi
|
|
case "$PHASE" in 1|2|3|4|5|6|all|env-a|env-b) ;; *) die "bad --phase (use 1|2|3|4|5|6|all|env-a|env-b)" ;; esac
|
|
[ "$DRY_RUN" = "1" ] || [ -n "$ROUTE_TEST_CMD" ] || say "WARNING: --route-test-cmd is unset; phases 3 and 4 will be skipped (you can run them manually using the generated input files)"
|
|
|
|
mkdir -p "$OUT" "$OUT/inputs" "$OUT/outputs/env-a" "$OUT/outputs/env-b" "$OUT/diff" 2>/dev/null
|
|
|
|
# If --bundle-in given, untar the bundle into $OUT first. Manifest tells us
|
|
# what env-A was and (optionally) what route_test command to use.
|
|
if [ -n "$BUNDLE_IN" ]; then
|
|
[ -f "$BUNDLE_IN" ] || die "bundle-in file not found: $BUNDLE_IN"
|
|
say "unpacking bundle $BUNDLE_IN into $OUT/"
|
|
tar -xzf "$BUNDLE_IN" -C "$OUT" 2>&1 | tail -5
|
|
if [ -f "$OUT/manifest.json" ]; then
|
|
say "manifest from env-A:"
|
|
cat "$OUT/manifest.json" >&2
|
|
# Pull scope and route-test-cmd hints if not overridden
|
|
if [ -z "$SCOPE" ] && command -v jq >/dev/null 2>&1; then
|
|
SCOPE=$(jq -r '.scope // ""' "$OUT/manifest.json")
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Phase 1: discover inbound threads in scope
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
discover_inbounds() {
|
|
case "$SCOPE" in
|
|
thread:*) echo "${SCOPE#thread:}" ;;
|
|
threads:*) echo "${SCOPE#threads:}" | tr ',' '\n' ;;
|
|
site)
|
|
[ -n "$SITE_A" ] || die "scope=site requires --site-a"
|
|
"$NCI" "$ENV_A/$SITE_A/NetConfig" --mode "$INBOUND_MODE" --format tsv \
|
|
| awk -F'\t' 'NR>1 {print $1}'
|
|
;;
|
|
server)
|
|
while IFS= read -r nc; do
|
|
"$NCI" "$nc" --mode "$INBOUND_MODE" --format tsv \
|
|
| awk -F'\t' 'NR>1 {print $1}'
|
|
done < <(find "$ENV_A" -maxdepth 2 -name NetConfig -type f 2>/dev/null)
|
|
;;
|
|
*) die "bad --scope: $SCOPE" ;;
|
|
esac
|
|
}
|
|
|
|
phase_1() {
|
|
say "=== PHASE 1: discover inbounds ==="
|
|
local inbounds; inbounds=$(discover_inbounds | sort -u | grep -v '^$')
|
|
if [ -z "$inbounds" ]; then
|
|
say "no inbounds found in scope $SCOPE"
|
|
return 1
|
|
fi
|
|
printf '%s\n' "$inbounds" > "$OUT/inbounds.txt"
|
|
local count; count=$(printf '%s\n' "$inbounds" | wc -l | tr -d ' ')
|
|
say "discovered $count inbound thread(s) → $OUT/inbounds.txt"
|
|
printf '%s\n' "$inbounds" | sed 's/^/ - /'
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Phase 2: sample N messages per inbound from env-A's smatdbs
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
phase_2() {
|
|
say "=== PHASE 2: sample $COUNT messages per inbound from env-A ==="
|
|
[ -f "$OUT/inbounds.txt" ] || { say "no inbounds file from phase 1; running phase 1 first"; phase_1 || return 1; }
|
|
local thread sitedir
|
|
local count=0
|
|
while IFS= read -r thread; do
|
|
[ -z "$thread" ] && continue
|
|
# Locate the site for this thread
|
|
sitedir=""
|
|
if [ -n "$SITE_A" ]; then
|
|
sitedir="$ENV_A/$SITE_A"
|
|
else
|
|
# Search across all sites under ENV_A for the smatdb
|
|
sitedir=$(find "$ENV_A" -maxdepth 4 -name "${thread}.smatdb" -type f 2>/dev/null | head -1 | xargs -I{} dirname {} | sed "s#/exec/processes/.*##")
|
|
[ -z "$sitedir" ] && { say " skip $thread: smatdb not found under $ENV_A"; continue; }
|
|
fi
|
|
local input="$OUT/inputs/${thread}.msgs"
|
|
if [ "$DRY_RUN" = "1" ]; then
|
|
say " [dry-run] would sample $COUNT msgs from $thread → $input"
|
|
else
|
|
HCISITEDIR="$sitedir" "$NCM" "$thread" --limit "$COUNT" --format raw > "$input" 2>/dev/null
|
|
local got; got=$(tr -cd $'\x1c' < "$input" | wc -c | tr -d ' ')
|
|
say " sampled $thread → $input ($got messages)"
|
|
fi
|
|
count=$((count+1))
|
|
done < "$OUT/inbounds.txt"
|
|
say "phase 2 done: $count thread(s) processed"
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Phase 3 / 4: execute route_test on each env
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
render_cmd() {
|
|
local tmpl="$1" thread="$2" input="$3" outdir="$4" hciroot="$5" hcisite="$6"
|
|
local cmd="$tmpl"
|
|
cmd="${cmd//\{THREAD\}/$thread}"
|
|
cmd="${cmd//\{INPUT\}/$input}"
|
|
cmd="${cmd//\{OUTPUT_DIR\}/$outdir}"
|
|
cmd="${cmd//\{HCIROOT\}/$hciroot}"
|
|
cmd="${cmd//\{HCISITE\}/$hcisite}"
|
|
printf '%s' "$cmd"
|
|
}
|
|
|
|
phase_routes() {
|
|
local label="$1" hciroot="$2" hcisite="$3"
|
|
say "=== PHASE ${label}: route_test on env-${label} ==="
|
|
if [ -z "$ROUTE_TEST_CMD" ]; then
|
|
say "no --route-test-cmd; skipping phase ${label}"
|
|
say "to run manually, use the input files at $OUT/inputs/*.msgs"
|
|
return 0
|
|
fi
|
|
local thread
|
|
while IFS= read -r thread; do
|
|
[ -z "$thread" ] && continue
|
|
local input="$OUT/inputs/${thread}.msgs"
|
|
local outdir="$OUT/outputs/env-${label}/${thread}"
|
|
[ -f "$input" ] || { say " skip $thread: no input file"; continue; }
|
|
mkdir -p "$outdir"
|
|
local cmd; cmd=$(render_cmd "$ROUTE_TEST_CMD" "$thread" "$input" "$outdir" "$hciroot" "$hcisite")
|
|
if [ "$DRY_RUN" = "1" ]; then
|
|
say " [dry-run] $thread:"
|
|
say " \$ $cmd"
|
|
else
|
|
say " $thread:"
|
|
say " \$ $cmd"
|
|
bash -c "$cmd" 2>&1 | sed 's/^/ /' || say " (route_test exit non-zero — continuing)"
|
|
fi
|
|
done < "$OUT/inbounds.txt"
|
|
}
|
|
|
|
phase_3() { phase_routes "a" "$ENV_A" "$SITE_A"; }
|
|
|
|
phase_4() {
|
|
# Phase 4: copy inputs to env-b host (if remote), then route_test on env-B.
|
|
if [ -n "$ENV_B_HOST" ]; then
|
|
say "copying input files to ${ENV_B_USER:-$USER}@${ENV_B_HOST}:${OUT}/inputs/"
|
|
if [ "$DRY_RUN" = "1" ]; then
|
|
say " [dry-run] scp -r $OUT/inputs/ ${ENV_B_USER:-$USER}@${ENV_B_HOST}:${OUT}/"
|
|
else
|
|
ssh "${ENV_B_USER:-$USER}@${ENV_B_HOST}" "mkdir -p $OUT/inputs $OUT/outputs/env-b" || true
|
|
scp -r "$OUT/inputs/" "${ENV_B_USER:-$USER}@${ENV_B_HOST}:${OUT}/" || say "scp failed; you'll need to copy manually"
|
|
fi
|
|
fi
|
|
phase_routes "b" "$ENV_B" "$SITE_B"
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Phase 5: diff outputs pair-by-pair
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
phase_5() {
|
|
say "=== PHASE 5: diff env-a vs env-b outputs ==="
|
|
local diff_index="$OUT/diff/_index.md"
|
|
{
|
|
printf '# Regression diff index\n\n'
|
|
printf '- env-A: `%s`\n- env-B: `%s`\n- scope: `%s`\n- count: %s msgs per inbound\n- ignore: `%s`\n%s\n\n' \
|
|
"$ENV_A" "$ENV_B" "$SCOPE" "$COUNT" "$IGNORE" \
|
|
"$([ -n "$INCLUDE" ] && printf -- '- include-only: `%s`' "$INCLUDE")"
|
|
printf '| thread | dest | diffs | report |\n|---|---|---|---|\n'
|
|
} > "$diff_index"
|
|
|
|
local total_diff=0 total_pairs=0
|
|
local thread destfile destname
|
|
while IFS= read -r thread; do
|
|
[ -z "$thread" ] && continue
|
|
local a_dir="$OUT/outputs/env-a/${thread}"
|
|
local b_dir="$OUT/outputs/env-b/${thread}"
|
|
[ -d "$a_dir" ] || { say " skip $thread: no env-a outputs"; continue; }
|
|
[ -d "$b_dir" ] || { say " skip $thread: no env-b outputs"; continue; }
|
|
# Pair up by filename
|
|
while IFS= read -r destfile; do
|
|
destname=$(basename "$destfile")
|
|
local b_pair="$b_dir/$destname"
|
|
total_pairs=$((total_pairs+1))
|
|
if [ ! -f "$b_pair" ]; then
|
|
echo "| \`$thread\` | \`$destname\` | (missing on env-b) | — |" >> "$diff_index"
|
|
continue
|
|
fi
|
|
local report="$OUT/diff/${thread}.${destname}.md"
|
|
local count
|
|
if [ "$DRY_RUN" = "1" ]; then
|
|
echo " [dry-run] would diff $destfile vs $b_pair → $report"
|
|
echo "| \`$thread\` | \`$destname\` | [dry-run] | — |" >> "$diff_index"
|
|
continue
|
|
fi
|
|
local diff_args=(--ignore "$IGNORE")
|
|
[ -n "$INCLUDE" ] && diff_args+=(--include-fields "$INCLUDE")
|
|
count=$("$HL7DIFF" "${diff_args[@]}" --format count "$destfile" "$b_pair" 2>/dev/null || echo "?")
|
|
{
|
|
printf '# Diff: %s → %s\n\n- env-A: `%s`\n- env-B: `%s`\n\n' "$thread" "$destname" "$destfile" "$b_pair"
|
|
"$HL7DIFF" "${diff_args[@]}" --format text "$destfile" "$b_pair" 2>/dev/null || true
|
|
} > "$report"
|
|
echo "| \`$thread\` | \`$destname\` | $count | [report](./$(basename "$report")) |" >> "$diff_index"
|
|
total_diff=$((total_diff + count))
|
|
say " $thread → $destname: $count diff(s)"
|
|
done < <(find "$a_dir" -maxdepth 1 -type f 2>/dev/null)
|
|
done < "$OUT/inbounds.txt"
|
|
|
|
{
|
|
printf '\n## Summary\n\n- pairs compared: %s\n- total field differences (post-ignore): %s\n' "$total_pairs" "$total_diff"
|
|
} >> "$diff_index"
|
|
say "phase 5 done: $total_pairs pairs compared, $total_diff total diffs"
|
|
say "index: $diff_index"
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Phase 6: master summary
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
phase_6() {
|
|
say "=== PHASE 6: master regression-summary.md ==="
|
|
local summary="$OUT/regression-summary.md"
|
|
{
|
|
printf '# Regression test summary\n\n'
|
|
printf 'Generated: %s\n\n' "$(date -Iseconds 2>/dev/null || date)"
|
|
printf '## Configuration\n\n'
|
|
printf '- scope: `%s`\n' "$SCOPE"
|
|
printf '- count: %s messages per inbound\n' "$COUNT"
|
|
printf '- env-A: `%s` (site=%s)\n' "$ENV_A" "${SITE_A:-auto}"
|
|
printf '- env-B: `%s` (site=%s)\n' "$ENV_B" "${SITE_B:-auto}"
|
|
printf '- ignore: `%s`\n' "$IGNORE"
|
|
[ -n "$INCLUDE" ] && printf '- include-only: `%s`\n' "$INCLUDE"
|
|
printf '\n## Inbounds tested\n\n'
|
|
[ -f "$OUT/inbounds.txt" ] && awk '{print "- `" $0 "`"}' "$OUT/inbounds.txt"
|
|
printf '\n## Inputs\n\n'
|
|
find "$OUT/inputs" -maxdepth 1 -type f 2>/dev/null \
|
|
| awk '{print "- `" $0 "`"}' || true
|
|
printf '\n## Output directories\n\n'
|
|
printf -- '- env-A: `%s`\n' "$OUT/outputs/env-a"
|
|
printf -- '- env-B: `%s`\n' "$OUT/outputs/env-b"
|
|
printf '\n## Diff details\n\n'
|
|
printf 'See [diff/_index.md](./diff/_index.md) for the per-pair table.\n'
|
|
} > "$summary"
|
|
say "summary: $summary"
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Dispatch
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
case "$PHASE" in
|
|
1) phase_1 ;;
|
|
2) phase_1 && phase_2 ;;
|
|
3) phase_3 ;;
|
|
4) phase_4 ;;
|
|
5) phase_5 ;;
|
|
6) phase_6 ;;
|
|
all) phase_1 && phase_2 && phase_3 && phase_4 && phase_5 && phase_6 ;;
|
|
env-a) phase_1 && phase_2 && phase_3 ;; # everything that uses env-A
|
|
env-b) phase_4 && phase_5 && phase_6 ;; # everything that uses env-B + diff
|
|
esac
|
|
|
|
# Optional: produce a portable bundle of the env-A artifacts (inputs + a-outputs)
|
|
# so the user can move them to the env-B box manually.
|
|
if [ -n "$BUNDLE_OUT" ]; then
|
|
say "producing bundle: $BUNDLE_OUT"
|
|
{
|
|
printf '{'
|
|
printf '"generated":"%s",' "$(date -Iseconds 2>/dev/null || date)"
|
|
printf '"host":"%s",' "$(hostname 2>/dev/null || echo unknown)"
|
|
printf '"env_a":"%s",' "$ENV_A"
|
|
printf '"site_a":"%s",' "$SITE_A"
|
|
printf '"env_b_expected":"%s",' "$ENV_B"
|
|
printf '"site_b_expected":"%s",' "$SITE_B"
|
|
printf '"scope":"%s",' "$SCOPE"
|
|
printf '"count":%s,' "$COUNT"
|
|
printf '"ignore":"%s",' "$IGNORE"
|
|
printf '"route_test_cmd_hint":"%s"' "$ROUTE_TEST_CMD"
|
|
printf '}\n'
|
|
} > "$OUT/manifest.json"
|
|
cat > "$OUT/README.md" <<EOF
|
|
# Regression bundle — env-A artifacts
|
|
|
|
Take this bundle to the env-B box and run:
|
|
|
|
\`\`\`
|
|
nc-regression.sh --bundle-in $(basename "$BUNDLE_OUT") --out $OUT \\
|
|
--env-b /path/to/env-b/integrator --site-b <site> \\
|
|
--route-test-cmd '<env-b route_test command>' \\
|
|
--phase env-b
|
|
\`\`\`
|
|
|
|
The bundle contains:
|
|
- inputs/ — sampled messages from env-A (one .msgs per inbound)
|
|
- outputs/env-a/ — route_test outputs from env-A
|
|
- manifest.json — env-A metadata
|
|
- inbounds.txt — the threads tested
|
|
|
|
env-B side will produce outputs/env-b/ and the diff/ tree.
|
|
EOF
|
|
tar -czf "$BUNDLE_OUT" -C "$OUT" inputs outputs/env-a inbounds.txt manifest.json README.md 2>/dev/null
|
|
say "bundle ready: $BUNDLE_OUT ($(du -h "$BUNDLE_OUT" | awk '{print $1}'))"
|
|
fi
|
|
|
|
say "regression run done. Output root: $OUT"
|