#!/usr/bin/env bash # nc-provision-jumps.sh — CAPSTONE orchestrator (v0.8.32). # # Point at a SITE and build the cross-environment "server_jump" thread sets for # EVERY inbound (root) thread at that site, to route the existing-env inbound # ADT feed into a NEW environment. This is the pure composition of the validated # read + write tools — it REIMPLEMENTS NOTHING: # # nc-inbound.sh enumerate inbound root threads at the site # nc-parse.sh read each inbound's PROTOCOL.PORT + ENCODING # nc-make-jump.sh generate the 3-thread jump set + splice route per inbound # nc-insert-protocol.sh persist each block (insert) and the splice (add-route) # journal.sh ONE session id wraps the WHOLE batch (via LARRY_SESSION_ID) # # THE KEY SAFETY PROPERTY — SINGLE-SESSION JOURNALING: # Every insert + route-add for the whole batch shares ONE LARRY_SESSION_ID, so # larry-rollback.sh --session # undoes the ENTIRE provisioning run in one shot (not per-thread), restoring # the NetConfig(s) byte-identical to baseline. # # PER-INBOUND LOOP (for each inbound root NAME found at the site): # 1. read NAME's PROTOCOL.PORT (the orig listen port) + ENCODING (via nc-parse) # 2. derive jump_port = port_base + index (stable, collision-checked) # 3. nc-make-jump --inbound NAME --new-host H --jump-port P → 4 artifacts: # linux__out (OLD env / site NetConfig, same process as NAME) # windows__in (NEW env server_jump NetConfig) # windows__out (NEW env server_jump NetConfig) # route_add snippet (spliced into NAME's DATAXLATE on the OLD/site NetConfig) # 4. persist: insert the 3 blocks + add the splice route, ALL under one session # # tag = the inbound thread name itself (nc-make-jump's auto-derived scheme) so # the loop is name-driven and idempotent. # # TOPOLOGY / WHICH FILE GETS WHAT: # linux__out + the splice route → the SITE NetConfig (--netconfig). # windows__in + windows__out → the NEW-env server_jump NetConfig # (--new-netconfig; defaults to the SITE # NetConfig so a single-file run is # self-contained and testable). # # Usage: # nc-provision-jumps.sh --site --new-host --new-port-base # [--hciroot DIR] # site discovery root (or $HCIROOT) # [--netconfig FILE] # explicit OLD/site NetConfig (overrides --site) # [--new-netconfig FILE] # NEW-env server_jump NetConfig (default = site NetConfig) # [--scope tcp-listen|all] # which inbound class (default tcp-listen = upstream-fed roots) # [--filter REGEX] # limit which inbound roots (matched on name) # [--inbound-host HOST] # NEW-side dest host (default 127.0.0.1) # [--process-jump PROC] # process for NEW-side threads (default server_jump) # [--dry-run] # preview the FULL plan, write NOTHING # [--confirm yes] # required to actually write (else dry-run-like refusal) # [--format text|tsv] # summary format (default text) # # DOWNSTREAM HANDOFF (regression — the capstone's OTHER half, already shipped): # The provisioned inbound roots are emitted as a `roots:` line (and, with # --format tsv, one root per row) so the list is directly consumable as the # nc_regression scope (scope=server / threads:) with route_test_cmd driven # by the now-exposed nc_engine route-test. This tool does NOT run regression. # # Exit: 0 OK (or clean dry-run), 2 usage, 3 target not found, 4 nothing to do, # 5 pre-flight collision with the existing NetConfig (nothing written), # 6 mid-batch provisioning failure — the batch was AUTO-ROLLED-BACK. set -u set -o pipefail NC_SELF="$0" LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)" NCP="$LIB_DIR/nc-parse.sh" NC_INBOUND="$LIB_DIR/nc-inbound.sh" NC_MAKE_JUMP="$LIB_DIR/nc-make-jump.sh" NC_INSERT="$LIB_DIR/nc-insert-protocol.sh" # larry-rollback.sh ships at the bundle root (sibling of lib/). Used by the # fail-fast auto-rollback (MAJOR 1). Fall back to one on PATH if not found there. NC_ROLLBACK="$LIB_DIR/../larry-rollback.sh" [ -f "$NC_ROLLBACK" ] || NC_ROLLBACK="$(command -v larry-rollback.sh 2>/dev/null || printf '%s' "$NC_ROLLBACK")" die() { printf 'nc-provision-jumps: %s\n' "$*" >&2; exit "${2:-1}"; } SITE="" NEW_HOST="" PORT_BASE="" HCIROOT_OPT="${HCIROOT:-}" NETCONFIG="" NEW_NETCONFIG="" SCOPE="tcp-listen" FILTER="" INBOUND_HOST="127.0.0.1" PROC_JUMP="server_jump" DRY_RUN=0 CONFIRM="" FMT="text" while [ $# -gt 0 ]; do case "$1" in --site) shift; SITE="${1:-}" ;; --new-host) shift; NEW_HOST="${1:-}" ;; --new-port-base) shift; PORT_BASE="${1:-}" ;; --hciroot) shift; HCIROOT_OPT="${1:-}" ;; --netconfig) shift; NETCONFIG="${1:-}" ;; --new-netconfig) shift; NEW_NETCONFIG="${1:-}" ;; --scope) shift; SCOPE="${1:-}" ;; --filter) shift; FILTER="${1:-}" ;; --inbound-host) shift; INBOUND_HOST="${1:-}" ;; --process-jump) shift; PROC_JUMP="${1:-}" ;; --dry-run) DRY_RUN=1 ;; --confirm) shift; CONFIRM="${1:-}" ;; --format) shift; FMT="${1:-}" ;; -h|--help) sed -n '2,80p' "$NC_SELF"; exit 0 ;; -*) die "unknown flag: $1" 2 ;; *) die "unexpected arg: $1 (use flags; see --help)" 2 ;; esac shift done # ── Validate + resolve the OLD/site NetConfig ──────────────────────────────── [ -n "$NEW_HOST" ] || die "missing --new-host" 2 [ -n "$PORT_BASE" ] || die "missing --new-port-base" 2 case "$PORT_BASE" in (*[!0-9]*|'') die "--new-port-base must be numeric: $PORT_BASE" 2 ;; esac case "$SCOPE" in tcp-listen|all) ;; *) die "bad --scope: $SCOPE (tcp-listen|all)" 2 ;; esac case "$FMT" in text|tsv) ;; *) die "bad --format: $FMT (text|tsv)" 2 ;; esac if [ -z "$NETCONFIG" ]; then [ -n "$SITE" ] || die "need --site or --netconfig" 2 [ -n "$HCIROOT_OPT" ] || die "no \$HCIROOT and no --hciroot; cannot resolve --site $SITE" 2 NETCONFIG="$HCIROOT_OPT/$SITE/NetConfig" fi [ -f "$NETCONFIG" ] || die "site NetConfig not found: $NETCONFIG" 3 # server_jump (NEW-env) NetConfig defaults to the site NetConfig (self-contained). [ -n "$NEW_NETCONFIG" ] || NEW_NETCONFIG="$NETCONFIG" [ -f "$NEW_NETCONFIG" ] || die "new-env NetConfig not found: $NEW_NETCONFIG" 3 [ -n "$SITE" ] || SITE="$(basename "$(dirname "$NETCONFIG")")" # ── Enumerate inbound roots (reuse nc-inbound.sh, do not reimplement) ──────── # Column 1 of the TSV is the thread name; skip the header. mapfile -t ALL_ROOTS < <("$NC_INBOUND" "$NETCONFIG" --mode "$SCOPE" --format tsv 2>/dev/null \ | tail -n +2 | awk -F'\t' 'NF>0 && $1!="" {print $1}') # Apply --filter (regex on the name) ROOTS=() for r in "${ALL_ROOTS[@]:-}"; do [ -n "$r" ] || continue if [ -n "$FILTER" ]; then printf '%s' "$r" | grep -qE "$FILTER" || continue fi ROOTS+=("$r") done if [ "${#ROOTS[@]}" -eq 0 ]; then printf 'No inbound roots matched at site %s (scope=%s%s). Nothing to provision.\n' \ "$SITE" "$SCOPE" "${FILTER:+, filter=/$FILTER/}" >&2 exit 4 fi # ── Build the per-inbound plan (read each root's port/encoding + jump set) ─── # We DRY-RUN nc-make-jump (stdout, no writes) to materialise each block + route # into temp files, regardless of whether we ultimately persist — same generator, # so the dry-run plan and the real run are byte-identical artifacts. PLAN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/nc-provjumps.XXXXXX")" || die "mktemp -d failed" trap 'rm -rf "$PLAN_DIR"' EXIT # Track names we'd create to catch intra-batch collisions early (port + thread). declare -A SEEN_PORT=() declare -A SEEN_NAME=() PLAN_COUNT=0 PLAN_ROOTS=() # the inbound roots actually planned (regression scope) PLAN_OLDOUT=() PLAN_NEWIN=() PLAN_NEWOUT=() PLAN_ROUTE=() # the route_add splice snippet for each inbound (proper array, not suffix-strip) PLAN_PORT=() PLAN_JUMP=() PLAN_ENC=() PLAN_PROC=() # Planned NAMEs per inbound (for the pre-flight collision pass below). One # space-joined list per planned inbound, parallel to PLAN_ROOTS. PLAN_NAMES=() idx=0 for inb in "${ROOTS[@]}"; do # Read the orig listen port + encoding via the native parser (reuse nc-parse). orig_port="$("$NCP" protocol-nested "$NETCONFIG" "$inb" PROTOCOL.PORT 2>/dev/null | head -1)" enc="$("$NCP" protocol-field "$NETCONFIG" "$inb" ENCODING 2>/dev/null | head -1)" [ -z "$enc" ] && enc="ASCII" if [ -z "$orig_port" ]; then printf 'SKIP %s — no PROTOCOL.PORT (not a TCP listener; jump pattern needs a listen port)\n' "$inb" >&2 continue fi jump_port=$(( PORT_BASE + idx )) idx=$(( idx + 1 )) # Intra-batch collision guards. if [ -n "${SEEN_PORT[$jump_port]:-}" ]; then die "internal: jump_port $jump_port collides (inbound $inb vs ${SEEN_PORT[$jump_port]})" fi SEEN_PORT[$jump_port]="$inb" prefix="$PLAN_DIR/$inb" # nc-make-jump with --out-prefix writes the 4 artifact files and prints paths. # This is its generation path — NO NetConfig write happens here. if ! "$NC_MAKE_JUMP" "$NETCONFIG" \ --inbound "$inb" --new-host "$NEW_HOST" --jump-port "$jump_port" \ --inbound-host "$INBOUND_HOST" --process-jump "$PROC_JUMP" \ --encoding "$enc" --out-prefix "$prefix" >/dev/null 2>"$prefix.err"; then printf 'SKIP %s — nc-make-jump failed: %s\n' "$inb" "$(cat "$prefix.err" 2>/dev/null)" >&2 continue fi oldout="$prefix.old_out.tcl" newin="$prefix.new_in.tcl" newout="$prefix.new_out.tcl" route="$prefix.route_add.tcl" # MINOR fix: proper array, not ${oldout%old_out.tcl}route_add.tcl # Names that would be created (for collision reporting against the NetConfig). planned_names="" for blk in "$oldout" "$newin" "$newout"; do nm=$(awk '/^protocol [A-Za-z0-9_]+/ {print $2; exit}' "$blk") if [ -n "${SEEN_NAME[$nm]:-}" ]; then die "internal: jump thread name $nm generated twice" fi SEEN_NAME[$nm]="$inb" planned_names="${planned_names:+$planned_names }$nm" done PLAN_ROOTS+=("$inb") PLAN_OLDOUT+=("$oldout") PLAN_NEWIN+=("$newin") PLAN_NEWOUT+=("$newout") PLAN_ROUTE+=("$route") PLAN_NAMES+=("$planned_names") PLAN_PORT+=("$orig_port") PLAN_JUMP+=("$jump_port") PLAN_ENC+=("$enc") PLAN_PROC+=("$("$NCP" protocol-field "$NETCONFIG" "$inb" PROCESSNAME 2>/dev/null | head -1)") PLAN_COUNT=$(( PLAN_COUNT + 1 )) done if [ "$PLAN_COUNT" -eq 0 ]; then printf 'No provisionable inbound roots (all skipped — see messages above).\n' >&2 exit 4 fi NEW_THREADS=$(( PLAN_COUNT * 3 )) # ── PRE-FLIGHT COLLISION CHECK against the EXISTING NetConfig(s) ────────────── # (MAJOR 2) Intra-batch collisions (port/name) are already caught above. But a # planned jump_port or generated thread NAME can ALSO collide with something # ALREADY in the target NetConfig — a duplicate listener (port) or a name # nc-insert-protocol would refuse late. Catch BOTH up front, before any write, # and surface in --dry-run. We read existing ports/names via nc-parse # (protocol-summary: col1=name, col4=port) — never a brittle hand-grep. # # Topology of where each planned object lands: # jump_port → listened on by windows__in in the NEW-env NetConfig # linux__out → SITE NetConfig # windows__in/out→ NEW-env NetConfig # To be conservative we check every planned NAME against BOTH files' existing # name sets, and each jump_port against the NEW-env file's existing ports (where # the listener is created). If site==new-env (the common self-contained run) # the two sets are the same file read once. declare -A EXIST_PORT_OLD=() # port -> existing protocol name (site NetConfig) declare -A EXIST_NAME_OLD=() # name -> 1 (site NetConfig) declare -A EXIST_PORT_NEW=() # port -> existing protocol name (new-env NetConfig) declare -A EXIST_NAME_NEW=() # name -> 1 (new-env NetConfig) # _scan_existing FILE PORTVAR NAMEVAR — populate the named assoc arrays from FILE. _scan_existing() { local file="$1" __pv="$2" __nv="$3" nm pt while IFS=$'\t' read -r nm pt; do [ -n "$nm" ] || continue eval "$__nv[\"\$nm\"]=1" [ -n "$pt" ] && eval "$__pv[\"\$pt\"]=\"\$nm\"" done < <("$NCP" protocol-summary "$file" 2>/dev/null | tail -n +2 | awk -F'\t' 'NF>0 && $1!="" {print $1"\t"$4}') } _scan_existing "$NETCONFIG" EXIST_PORT_OLD EXIST_NAME_OLD if [ "$NEW_NETCONFIG" = "$NETCONFIG" ]; then # Same file — alias the new-env sets to the old-env scan (avoid a second read). for k in "${!EXIST_PORT_OLD[@]}"; do EXIST_PORT_NEW["$k"]="${EXIST_PORT_OLD[$k]}"; done for k in "${!EXIST_NAME_OLD[@]}"; do EXIST_NAME_NEW["$k"]=1; done else _scan_existing "$NEW_NETCONFIG" EXIST_PORT_NEW EXIST_NAME_NEW fi PREFLIGHT_CONFLICTS=() for i in "${!PLAN_ROOTS[@]}"; do inb="${PLAN_ROOTS[$i]}" jp="${PLAN_JUMP[$i]}" # (a) jump_port already listened on in the NEW-env NetConfig? if [ -n "${EXIST_PORT_NEW[$jp]:-}" ]; then PREFLIGHT_CONFLICTS+=("inbound $inb: jump_port $jp already in use by existing protocol '${EXIST_PORT_NEW[$jp]}' in $NEW_NETCONFIG") fi # (b) any planned thread NAME already present in either NetConfig? for nm in ${PLAN_NAMES[$i]}; do if [ -n "${EXIST_NAME_OLD[$nm]:-}" ]; then PREFLIGHT_CONFLICTS+=("inbound $inb: thread name '$nm' already exists in $NETCONFIG") fi if [ "$NEW_NETCONFIG" != "$NETCONFIG" ] && [ -n "${EXIST_NAME_NEW[$nm]:-}" ]; then PREFLIGHT_CONFLICTS+=("inbound $inb: thread name '$nm' already exists in $NEW_NETCONFIG") fi done done if [ "${#PREFLIGHT_CONFLICTS[@]}" -gt 0 ]; then printf 'PRE-FLIGHT COLLISION CHECK FAILED — %d conflict(s) with the existing NetConfig:\n' \ "${#PREFLIGHT_CONFLICTS[@]}" >&2 for c in "${PREFLIGHT_CONFLICTS[@]}"; do printf ' ✗ %s\n' "$c" >&2; done printf 'ABORTED before writing anything. Resolve the collisions (e.g. choose a different\n' >&2 printf ' --new-port-base, rename the inbound roots, or --filter them out) and re-run.\n' >&2 exit 5 fi # ── Plan / summary renderer ────────────────────────────────────────────────── print_plan_header() { local mode="$1" if [ "$FMT" = "tsv" ]; then return 0; fi printf '═══════════════════════════════════════════════════════════════════════\n' printf '%s — site=%s\n' "$mode" "$SITE" printf ' site NetConfig (OLD) : %s\n' "$NETCONFIG" printf ' server_jump NetConfig : %s%s\n' "$NEW_NETCONFIG" \ "$( [ "$NEW_NETCONFIG" = "$NETCONFIG" ] && printf ' (same file)' )" printf ' new-env host : %s port-base : %s inbound-host : %s\n' "$NEW_HOST" "$PORT_BASE" "$INBOUND_HOST" printf ' scope=%s filter=%s process-jump=%s\n' "$SCOPE" "${FILTER:-(none)}" "$PROC_JUMP" printf ' PLAN: %d inbound root(s) → %d new threads + %d splice route(s)\n' \ "$PLAN_COUNT" "$NEW_THREADS" "$PLAN_COUNT" printf '═══════════════════════════════════════════════════════════════════════\n' } print_plan_body() { local i if [ "$FMT" = "tsv" ]; then printf 'inbound\torig_port\tjump_port\tencoding\tprocess\tlinux_out\twindows_in\twindows_out\troute_splice\n' for i in "${!PLAN_ROOTS[@]}"; do printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ "${PLAN_ROOTS[$i]}" "${PLAN_PORT[$i]}" "${PLAN_JUMP[$i]}" "${PLAN_ENC[$i]}" "${PLAN_PROC[$i]}" \ "linux_${PLAN_ROOTS[$i]}_out" "windows_${PLAN_ROOTS[$i]}_in" "windows_${PLAN_ROOTS[$i]}_out" \ "${PLAN_ROOTS[$i]}" done return 0 fi for i in "${!PLAN_ROOTS[@]}"; do local inb="${PLAN_ROOTS[$i]}" printf '\n• inbound %s (port %s → jump %s, enc %s, proc %s)\n' \ "$inb" "${PLAN_PORT[$i]}" "${PLAN_JUMP[$i]}" "${PLAN_ENC[$i]}" "${PLAN_PROC[$i]}" printf ' [OLD %s] + protocol linux_%s_out (tcpip-client → %s:%s)\n' \ "$(basename "$(dirname "$NETCONFIG")")" "$inb" "$NEW_HOST" "${PLAN_JUMP[$i]}" printf ' [NEW server_jump] + protocol windows_%s_in (tcpip-server, listen %s)\n' "$inb" "${PLAN_JUMP[$i]}" printf ' [NEW server_jump] + protocol windows_%s_out (tcpip-client → %s:%s)\n' "$inb" "$INBOUND_HOST" "${PLAN_PORT[$i]}" printf ' [OLD %s] ~ splice route on %s DATAXLATE → linux_%s_out\n' \ "$(basename "$(dirname "$NETCONFIG")")" "$inb" "$inb" done } # ── DRY-RUN: show the full plan, write NOTHING ─────────────────────────────── if [ "$DRY_RUN" = "1" ] || [ "$CONFIRM" != "yes" ]; then if [ "$DRY_RUN" = "1" ]; then print_plan_header "DRY-RUN PLAN (no writes)" else print_plan_header "PLAN (refused to write — pass --confirm yes)" fi print_plan_body if [ "$FMT" != "tsv" ]; then printf '\nroots: %s\n' "$(printf '%s,' "${PLAN_ROOTS[@]}" | sed 's/,$//')" if [ "$DRY_RUN" = "1" ]; then printf '(dry-run — nothing written. Re-run with --confirm yes to provision.)\n' else printf '(no --confirm yes — nothing written. Add --confirm yes to provision, or --dry-run to preview.)\n' fi printf 'regression handoff: feed the roots above as nc_regression scope (server / threads:),\n' printf ' route_test_cmd via nc_engine route-test.\n' fi exit 0 fi # ── REAL RUN: persist the whole batch under ONE journal session ────────────── # Establish ONE session id for the ENTIRE batch. Honour an inherited # LARRY_SESSION_ID (so larry.sh's running session groups it), else mint one. SESSION="${LARRY_SESSION_ID:-provjumps-$(date +%Y-%m-%d-%H%M%S)-$$}" export LARRY_SESSION_ID="$SESSION" export LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" print_plan_header "PROVISIONING (writing under one journal session)" # ── Fail-fast auto-rollback (MAJOR 1) ──────────────────────────────────────── # This batch is ALL-OR-NOTHING. On the FIRST hard failure of any step we STOP # provisioning the rest of the batch and auto-roll-back the WHOLE session via the # proven byte-identical `larry-rollback.sh --session `, so the NetConfig is # never left half-provisioned (and a route is never spliced to a thread that was # never created — see the per-inbound step gating below). Then exit 6. abort_rollback() { local where="$1" printf '\n✗ PROVISIONING FAILED at %s — stopping the batch.\n' "$where" >&2 printf 'Auto-rolling-back the WHOLE session %s (byte-identical) so nothing is left half-provisioned…\n' \ "$SESSION" >&2 if [ -f "$NC_ROLLBACK" ] && "$NC_ROLLBACK" --session "$SESSION" --yes >&2 2>&1; then printf '✓ Auto-rolled-back the batch. NetConfig(s) restored to baseline.\n' >&2 printf 'provisioning failed at %s; auto-rolled-back the batch.\n' "$where" >&2 else # Auto-rollback not feasible (rollback tool missing / errored): fail loud and # print the manual command so the operator can recover deterministically. printf '✗ AUTO-ROLLBACK COULD NOT RUN (rollback tool: %s).\n' "$NC_ROLLBACK" >&2 printf '!! The NetConfig MAY be partially provisioned. ROLL BACK MANUALLY NOW:\n' >&2 printf ' larry-rollback.sh --session %s\n' "$SESSION" >&2 fi exit 6 } inserts=0 routes=0 for i in "${!PLAN_ROOTS[@]}"; do inb="${PLAN_ROOTS[$i]}" # ① OLD env: insert linux__out into the SITE NetConfig. if "$NC_INSERT" insert "$NETCONFIG" "${PLAN_OLDOUT[$i]}" --mode end >/dev/null 2>&1; then inserts=$(( inserts + 1 )) else printf 'FAIL insert linux_%s_out into %s\n' "$inb" "$NETCONFIG" >&2 # ① failed → do NOT run ②/③/④ for this inbound. In particular the splice ④ # MUST NOT run, or we'd splice a route to a linux__out that was never # created (dangling DEST). Fail-fast + rollback the whole batch. abort_rollback "insert linux_${inb}_out" fi # ② NEW env: insert windows__in into the server_jump NetConfig. if "$NC_INSERT" insert "$NEW_NETCONFIG" "${PLAN_NEWIN[$i]}" --mode end >/dev/null 2>&1; then inserts=$(( inserts + 1 )) else printf 'FAIL insert windows_%s_in into %s\n' "$inb" "$NEW_NETCONFIG" >&2 abort_rollback "insert windows_${inb}_in" fi # ③ NEW env: insert windows__out into the server_jump NetConfig. if "$NC_INSERT" insert "$NEW_NETCONFIG" "${PLAN_NEWOUT[$i]}" --mode end >/dev/null 2>&1; then inserts=$(( inserts + 1 )) else printf 'FAIL insert windows_%s_out into %s\n' "$inb" "$NEW_NETCONFIG" >&2 abort_rollback "insert windows_${inb}_out" fi # ④ splice the route onto the OLD inbound's DATAXLATE (route_add artifact). # Only reached because ① succeeded above → linux__out exists → no dangling # DEST. Uses the proper PLAN_ROUTE[] array (MINOR fix), not a suffix-strip. if "$NC_INSERT" add-route "$NETCONFIG" "$inb" "${PLAN_ROUTE[$i]}" >/dev/null 2>&1; then routes=$(( routes + 1 )) else printf 'FAIL splice route on %s in %s\n' "$inb" "$NETCONFIG" >&2 abort_rollback "splice route on ${inb}" fi done print_plan_body printf '\n───────────────────────────────────────────────────────────────────────\n' printf 'PROVISIONED: %d inbound root(s) → %d block(s) inserted + %d splice route(s).\n' \ "$PLAN_COUNT" "$inserts" "$routes" printf 'journal session: %s\n' "$SESSION" printf 'WHOLE-BATCH ROLLBACK: larry-rollback.sh --session %s\n' "$SESSION" printf 'roots: %s\n' "$(printf '%s,' "${PLAN_ROOTS[@]}" | sed 's/,$//')" printf 'regression handoff: feed the roots above as nc_regression scope (server / threads:),\n' printf ' route_test_cmd via nc_engine route-test.\n' exit 0