cloverleaf-larry/lib/nc-provision-jumps.sh
Bryan Johnson 39f0e00c01 v0.8.32: nc_provision_jumps — capstone inter-server jump-thread provisioner
Point at a site and provision server_jump thread sets for ALL inbound root
threads (route the existing-env inbound feed to a new env). Pure composition
of validated tools (nc_find_inbound, nc-parse, nc_make_jump, nc_insert_protocol,
nc_add_route) under ONE journal session — whole batch rolls back in one command.
ALL-OR-NOTHING: steps gated on prior success, first failure auto-rolls-back the
session (exit 6); pre-flight collision check aborts (exit 5) before any write if
a jump-port or thread-name already exists. --dry-run previews the full plan.
Output hands `roots: <csv>` to nc_regression for bulk env-A-vs-B testing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:38:07 -07:00

466 lines
22 KiB
Bash
Executable File

#!/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 <id>
# 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_<NAME>_out (OLD env / site NetConfig, same process as NAME)
# windows_<NAME>_in (NEW env server_jump NetConfig)
# windows_<NAME>_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_<tag>_out + the splice route → the SITE NetConfig (--netconfig).
# windows_<tag>_in + windows_<tag>_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 <SITE> --new-host <HOST> --new-port-base <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:<csv>) 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_<name>_in in the NEW-env NetConfig
# linux_<name>_out → SITE NetConfig
# windows_<name>_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:<csv>),\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 <id>`, 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_<tag>_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_<tag>_out that was never
# created (dangling DEST). Fail-fast + rollback the whole batch.
abort_rollback "insert linux_${inb}_out"
fi
# ② NEW env: insert windows_<tag>_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_<tag>_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_<tag>_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:<csv>),\n'
printf ' route_test_cmd via nc_engine route-test.\n'
exit 0