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>
This commit is contained in:
Bryan Johnson 2026-05-28 19:38:07 -07:00
parent 7a715c802a
commit 39f0e00c01
5 changed files with 596 additions and 5 deletions

View File

@ -4,6 +4,84 @@ All notable changes to `cloverleaf-larry` / `larry-anywhere` are recorded here.
Versioning is loose-semver; bumps trigger the in-process self-update on every
running client via `LARRY_BASE_URL` + `MANIFEST`.
## v0.8.32 — 2026-05-28
**★ CAPSTONE TOOL: `nc_provision_jumps` — point at a SITE and build the
cross-environment `server_jump` thread set for ALL inbound (root) threads,
routing the existing-env inbound feed (the ADT feed) into a NEW environment.**
Bryan's stated ultimate goal. PURE COMPOSITION of tools all validated in the
read-pass (v0.8.29) and write/mutate pass (v0.8.30) — it reimplements nothing:
`nc-inbound.sh` enumerates the inbound roots → `nc-parse.sh` reads each root's
`PROTOCOL.PORT` + `ENCODING``nc-make-jump.sh` generates the per-inbound
3-thread set + splice route → `nc-insert-protocol.sh` persists them → `journal.sh`
wraps the WHOLE batch in ONE session.
- **Invocation:** `nc-provision-jumps --site <SITE> --new-host <H> --new-port-base <BASE>`.
Flags: `--dry-run` (preview the FULL plan — every inbound + every block/route
that WOULD be created — write NOTHING), `--confirm yes` (required to write;
absent = plan-only refusal), `--filter REGEX` (limit which inbound roots),
`--scope tcp-listen|all`, `--netconfig` / `--new-netconfig` (explicit OLD /
server_jump NetConfigs; new defaults to the site NetConfig), `--hciroot`,
`--inbound-host`, `--process-jump`, `--format text|tsv`.
- **Per-inbound loop is name-driven** (tag = the inbound name, so it loops
cleanly): `jump_port = new_port_base + index`, collision-checked. For each root
it generates `linux_<name>_out` (OLD/site NetConfig), `windows_<name>_in` +
`windows_<name>_out` (NEW-env server_jump NetConfig), and splices a route onto
the OLD inbound's DATAXLATE → `linux_<name>_out` (existing routes preserved).
- **★ SINGLE-SESSION JOURNALING — the key safety property.** All 3N inserts +
N splice routes for the whole batch share ONE `LARRY_SESSION_ID`, so
`larry-rollback.sh --session <id>` undoes the ENTIRE provisioning run
byte-identical in one shot (not per-thread).
- **★ FAIL-SAFE — the batch is ALL-OR-NOTHING (robustness hardening).** Each
inbound's later steps are gated on its earlier steps: if ① (`linux_<x>_out`
insert) fails, ②/③/④ are skipped — so the ④ splice can never route to a
thread that was never created (no dangling DEST). On the FIRST hard failure
ANYWHERE in the batch the loop STOPS and auto-rolls-back the whole session via
the proven byte-identical `larry-rollback.sh --session <id> --yes`, then exits
6 with `provisioning failed at <inbound>; auto-rolled-back the batch`. If the
rollback tool can't run, it fails loud and prints the manual rollback command.
- **★ PRE-FLIGHT COLLISION CHECK against the EXISTING NetConfig (robustness
hardening).** Before ANY write (and surfaced in `--dry-run`) it scans the
target NetConfig — and the `--new-netconfig` when different — via `nc-parse`
(`protocol-summary`; never a hand-grep) for (a) any planned `jump_port`
already in use by an existing `PROTOCOL.PORT` (would create a duplicate
listener) and (b) any planned thread NAME already present. ANY collision
ABORTS before writing anything (exit 5), listing every conflict (inbound,
port/name, what it collides with). This complements the existing intra-batch
port/name guards.
- Splice route artifact path is now a proper `PLAN_ROUTE[]` array built at
plan time (was a fragile `${oldout%old_out.tcl}route_add.tcl` suffix-strip).
- **Regression handoff (capstone's SEPARATE, already-shipped half):** output ends
with a `roots: <csv>` line consumable directly as the `nc_regression` scope
(server / threads:<csv>, `route_test_cmd` via the exposed `nc_engine route-test`).
This tool does NOT run regression.
- **Wired into all 4 larry.sh surfaces** (manual-tools registry, `tool_nc_provision_jumps`
wrapper, `execute_tool` case, TOOLS_JSON schema).
- Verified on a COPY of the real 24-site integrator (`/tmp/clvf_jumps_test`, site
`epic` = 21 inbound roots; read fixture sha-confirmed untouched at
`fa129cfc…`): (a) full-site `--dry-run` listed all 21 roots → 63 new threads +
21 splice routes, wrote NOTHING (sha unchanged); (b) a real full-site provision
inserted 63 balanced-brace blocks + 21 splice routes (topology spot-checked:
`windows_<x>_in` listens on the jump port and routes internally to
`windows_<x>_out` → 127.0.0.1:<orig_port>; `linux_<x>_out` → new-host:jump_port;
OLD inbound keeps its existing dests + the new jump-out), all under ONE journal
session (84 entries, 1 session id); (c) `larry-rollback.sh --session <id>`
removed the ENTIRE batch BYTE-IDENTICAL (sha back to `fa129cfc…`); (d)
`--filter '^MFNfr'` correctly limited to 2 roots, and that batch also rolled
back byte-identical.
- Robustness re-test on a COPY (`/tmp/clvf_jumps_h`, site `epic` = 21 roots; read
fixture `/tmp/clvf_realtest` sha-confirmed untouched): (a) a CLEAN run still
provisions 21 roots → 63 blocks + 21 splice routes under ONE session (84
entries) and `--session` rollback is still byte-identical; (b) both `--dry-run`
AND a real run ABORT cleanly (exit 5, sha unchanged, no journal minted) when a
`jump_port`/NAME collides with the existing NetConfig (crafted via
`--new-port-base` = an existing listener port); (c) a simulated mid-batch
insert failure fail-fasts (exit 6), auto-rolls-back the whole session
byte-identical, and leaves NO dangling `linux_<x>_out` / splice.
- VERSION + `larry.sh` `LARRY_VERSION` → 0.8.32; MANIFEST regenerated (`--check`
clean); `bash -n` clean. (Version held at 0.8.32 — still unpushed; the
fail-safe + collision pre-flight harden the same release.)
## v0.8.31 — 2026-05-28
**★ NEW WRITE TOOL: `nc_set_field` — change a settable field (PORT, HOST/IP,

View File

@ -23,16 +23,16 @@
# scripts/make-manifest.sh and bump VERSION.
# Top-level scripts
larry.sh 940d8fad8bffc42f6b5b7e7b295f8218ee43c03f327ca250e71bbb7dbce1a002
larry.sh e37681171c8d3b7de10b1ebaf4c6c6db4198b2d9c20b106c6b7d43ba48f5a90d
larry-tunnel.sh 6b050e4eeab15669f4858eaf3b807f168f211ced07815db9521bc40a093f6aaa
larry-auth.sh a220cdf7878569dc3028951ee57fc8d5e706a8ca5c6aa45347b58facb386f831
larry-rollback.sh 91b5e9aa6c79266bf306dcfba4ca791c07971bd6924d67a779037531648aa6d0
install-larry.sh fa36e23a39eacbd0d7ecedd3b42131902f816ee7e98241dfc6e28c6e4ba80423
# Metadata
VERSION bae94dce70052efa657cca9bf24209ef8ae9cb277deb79f38e7fdbdfdc5bd254
VERSION 4bd9f5643b9ceafed59c1be74ee8fec03786be48797dfa118157e894ddbc3ed9
MANUAL.md c64bd0251a51ad150508b4e1185355bc4826a64071d4de339f92ed550dbfacde
CHANGELOG.md 8696119944e16e8b7ab798d0641fb9f6beda48f871208132b7929401fc611d75
CHANGELOG.md d07489204cea869763e8f9a8091a5f5a2931b9aa893eaf92ca5113dc3981288c
# Agent personas (system-prompt overlays)
agents/larry.md 0a1ef737e7fc133ab35be09f79c3a4df33de814e0404b69b950932d0c8a01be1
@ -102,6 +102,7 @@ lib/nc-parse.sh 52fef42d7a4b361534ab0d921deef74586dfeb6c199c941cebb55abcc2c39d4f
lib/nc-paths.sh 388d2f4560736587a01218cadc1de612cd59e392819d16db2f56f19174c1111b
lib/nc-inbound.sh 52d28c5f8d97bdf96f0fc7b5300d35b106b8e1226578f4cda430deb2a8b4a91b
lib/nc-make-jump.sh 08a0bc58a299c95c60a59a5202792daf0ada3a8a0be7dc1b4cccc5724f5c9c79
lib/nc-provision-jumps.sh cf80abe572a4eb241b351363ffa85406829f0c458882dc8d14f1628c458432f8
lib/nc-msgs.sh 20517922d1153ec7827c833987497fb305d087b579911d1b9067d65ae156a19f
lib/nc-document.sh 47211e99089c0446d25a1e84545a734894720a1c9ad8f59b920332035e4ea880
lib/nc-revisions.sh c27856f7decfc4c2e2c990f59eb20136fdff9cf0a52b9d9fbd9370613666a802

View File

@ -1 +1 @@
0.8.31
0.8.32

View File

@ -78,7 +78,7 @@ set -o pipefail
# ─────────────────────────────────────────────────────────────────────────────
# Config
# ─────────────────────────────────────────────────────────────────────────────
LARRY_VERSION="0.8.31"
LARRY_VERSION="0.8.32"
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
# ─────────────────────────────────────────────────────────────────────────────
@ -353,6 +353,7 @@ nc-create-thread.sh|High-level: create a new thread in a NetConfig (and optional
nc-set-field.sh|Change ONE settable field (PORT, HOST/IP, PROCESSNAME, ENCODING) on an existing thread — anchored to the right block, journaled, rollback-reversible
nc-insert-protocol.sh|Low-level write side: insert/replace a protocol block in a NetConfig
nc-make-jump.sh|Generate the 3-thread "jump" pattern for cross-environment data replay
nc-provision-jumps.sh|CAPSTONE: point at a SITE and build server_jump thread sets for ALL inbound roots (routes the existing-env inbound feed to a new env). Composes nc-inbound + nc-make-jump + nc-insert-protocol; whole batch under ONE journal session → single --session rollback. --dry-run previews the full plan; --confirm yes writes
nc-tclgen.sh|Generate annotated TCL UPOC scaffolding (skeletons for common Cloverleaf proc patterns)
nc-document.sh|Generate a markdown knowledge entry documenting a Cloverleaf subsystem/interface
#Diff & regression
@ -1861,6 +1862,44 @@ tool_nc_regression() {
LARRY_LIB_DIR="$LARRY_LIB_DIR" "$LARRY_LIB_DIR/nc-regression.sh" "${args[@]}" 2>&1
}
# nc_provision_jumps — CAPSTONE. Point at a SITE and build the cross-environment
# server_jump thread set for EVERY inbound (root) thread, routing the existing-env
# inbound feed into a new environment. PURE COMPOSITION of validated tools
# (nc-inbound enumerate, nc-parse read port/encoding, nc-make-jump generate the
# 3-thread set + splice route, nc-insert-protocol persist) — reimplements nothing.
# THE WHOLE BATCH writes under ONE journal session, so larry-rollback.sh
# --session <id> undoes the entire provisioning run byte-identical in one shot.
# dry_run previews the FULL plan (N inbounds → 3N threads + N splice routes)
# without writing; confirm must be 'yes' to persist. The provisioned inbound-root
# list is emitted as a regression-ready scope (handoff to nc_regression, already
# shipped). Inherits LARRY_SESSION_ID so journal entries group with the session.
tool_nc_provision_jumps() {
local site="$1" new_host="$2" new_port_base="$3" hciroot="${4:-${HCIROOT:-}}"
local netconfig="${5:-}" new_netconfig="${6:-}" scope="${7:-tcp-listen}" filter="${8:-}"
local inbound_host="${9:-127.0.0.1}" process_jump="${10:-server_jump}"
local dry_run="${11:-0}" confirm="${12:-}" format="${13:-text}"
_lib_err_if_missing || return
[ -n "$new_host" ] && [ -n "$new_port_base" ] \
|| { echo "ERROR: nc_provision_jumps needs new_host and new_port_base"; return 1; }
[ -n "$site" ] || [ -n "$netconfig" ] \
|| { echo "ERROR: nc_provision_jumps needs site (or an explicit netconfig)"; return 1; }
local args=(--new-host "$new_host" --new-port-base "$new_port_base" \
--scope "$scope" --inbound-host "$inbound_host" \
--process-jump "$process_jump" --format "$format")
[ -n "$site" ] && args+=(--site "$site")
[ -n "$hciroot" ] && args+=(--hciroot "$hciroot")
[ -n "$netconfig" ] && args+=(--netconfig "$netconfig")
[ -n "$new_netconfig" ] && args+=(--new-netconfig "$new_netconfig")
[ -n "$filter" ] && args+=(--filter "$filter")
if [ "$dry_run" = "1" ]; then
args+=(--dry-run)
elif [ "$confirm" = "yes" ]; then
args+=(--confirm yes)
fi
LARRY_SESSION_ID="${LARRY_SESSION_ID:-$SESSION_ID}" \
"$LARRY_LIB_DIR/nc-provision-jumps.sh" "${args[@]}" 2>&1
}
tool_hl7_diff() {
local left_path="$1" right_path="$2" ignore="${3:-MSH.7}" include="${4:-}" format="${5:-text}"
_lib_err_if_missing || return
@ -4423,6 +4462,12 @@ execute_tool() {
"$(J '.route_test_cmd // ""')" "$(J '.ignore // "MSH.7"')" \
"$(J '.phase // "all"')" "$(J '.dry_run // 0' | sed "s/false/0/;s/true/1/")" \
"$(J '.source_ssh_alias // ""')" "$(J '.target_ssh_alias // ""')" ;;
nc_provision_jumps) tool_nc_provision_jumps "$(J '.site // ""')" "$(J '.new_host')" "$(J '.new_port_base')" \
"$(J '.hciroot // ""')" "$(J '.netconfig // ""')" "$(J '.new_netconfig // ""')" \
"$(J '.scope // "tcp-listen"')" "$(J '.filter // ""')" \
"$(J '.inbound_host // "127.0.0.1"')" "$(J '.process_jump // "server_jump"')" \
"$(J '.dry_run // 0' | sed "s/false/0/;s/true/1/")" \
"$(J '.confirm // ""')" "$(J '.format // "text"')" ;;
lesson_record) tool_lesson_record "$(J '.text')" "$(J '.topic // ""')" "$(J '.site // ""')" "$(J '.severity // "info"')" ;;
hl7_sanitize) tool_hl7_sanitize "$(J '.input_path')" "$(J '.strict // 0' | sed "s/false/0/;s/true/1/")" ;;
ssh_exec) tool_ssh_exec "$(J '.alias')" "$(J '.command')" "$(J '.max_lines // 500')" ;;
@ -4491,6 +4536,8 @@ TOOLS_JSON=$(cat <<'TOOLS_END'
{"name":"nc_regression","description":"End-to-end regression testing between two Cloverleaf environments. 6 phases: discover inbounds in scope, sample N messages per inbound from env-A smatdbs, run route_test on env-A, run route_test on env-B with same inputs, hl7_diff every paired output file, compile summary report. Phases 3/4 require the Cloverleaf route_test command; pass it via route_test_cmd with placeholders {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE}. If route_test_cmd is empty, phases 3/4 are skipped and you can run them manually using the generated input files. For cross-env regression testing across SSH-aliased hosts, set source_ssh_alias and target_ssh_alias to existing SSH aliases (run ssh_status to list them first). When set, phases 14 run remotely via ssh_exec + ssh_pull/ssh_push; phases 56 stay local. env_a / env_b remain the HCIROOT paths AS SEEN ON THE REMOTE for that alias.","input_schema":{"type":"object","properties":{"scope":{"type":"string","description":"thread:NAME | threads:N1,N2 | site (needs site_a) | server (all sites)"},"count":{"type":"integer","description":"Messages to sample per inbound. Default 10."},"env_a":{"type":"string","description":"HCIROOT of env-A (the test/source env). If source_ssh_alias is set, this is the remote-side path."},"site_a":{"type":"string","description":"Site name on env-A. Required if scope=site."},"env_b":{"type":"string","description":"HCIROOT of env-B (the prod/target env). If target_ssh_alias is set, this is the remote-side path."},"site_b":{"type":"string","description":"Site name on env-B."},"out":{"type":"string","description":"LOCAL output root directory for inputs, outputs, diffs, and summary."},"route_test_cmd":{"type":"string","description":"Command template for invoking route_test. Use {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE} as placeholders."},"ignore":{"type":"string","description":"hl7_diff ignore list. Default MSH.7."},"phase":{"type":"string","enum":["1","2","3","4","5","6","all"],"description":"Run a specific phase or all. Default all."},"dry_run":{"type":"integer","description":"1 = print what would happen, do not execute. Default 0."},"source_ssh_alias":{"type":"string","description":"SSH alias for the env-A (source) host. When set, phases 13 run remotely. Master must be open (ssh_status). Default empty = local."},"target_ssh_alias":{"type":"string","description":"SSH alias for the env-B (target) host. When set, phase 4 runs remotely. Master must be open. Default empty = local."}},"required":["scope","env_a","env_b","out"]}},
{"name":"nc_provision_jumps","description":"CAPSTONE provisioner — Bryan's ultimate goal. Point at a SITE and build the cross-environment 'server_jump' thread set for EVERY inbound (root) thread there, to route the existing-env inbound feed (e.g. the ADT feed) into a NEW environment. PURE COMPOSITION of the validated read+write tools — it reimplements nothing: nc_find_inbound enumerates the inbound roots; nc_protocol_nested/nc_protocol_field read each root's PROTOCOL.PORT + ENCODING; nc_make_jump generates the per-inbound 3-thread set (linux_<name>_out on the OLD/site NetConfig + windows_<name>_in & windows_<name>_out on the NEW-env server_jump NetConfig) plus the splice route onto the OLD inbound's DATAXLATE; nc_insert_protocol / nc_add_route persist them. PER-INBOUND LOOP is name-driven: tag = the inbound thread name, jump_port = new_port_base + index. THE KEY SAFETY PROPERTY — the ENTIRE batch (all 3N inserts + N splice routes) writes under ONE journal session, so larry-rollback.sh --session <id> undoes the WHOLE provisioning run byte-identical in a single shot (not per-thread). dry_run=true previews the COMPLETE plan (N inbounds → 3N new threads + N splice routes) and writes NOTHING; otherwise you MUST pass confirm=yes to actually write (no confirm = a plan-only refusal, still no writes). filter is a regex limiting which inbound roots are provisioned. Output is pipe-friendly and ends with a 'roots: <csv>' line — that provisioned-root list is directly consumable as the nc_regression scope (server / threads:<csv>, route_test_cmd via nc_engine route-test) for the capstone's SEPARATE regression half (already shipped). This tool does NOT run regression.","input_schema":{"type":"object","properties":{"site":{"type":"string","description":"The target site whose inbound roots get jump sets (resolved as $HCIROOT/<site>/NetConfig). Give this OR netconfig."},"new_host":{"type":"string","description":"Hostname/IP of the NEW (linux) env that each OLD-side linux_<name>_out TCPs to."},"new_port_base":{"type":"string","description":"Base TCP port for the OLD→NEW jump hops. Per-inbound jump_port = new_port_base + index (stable, collision-checked)."},"hciroot":{"type":"string","description":"Override $HCIROOT for site resolution."},"netconfig":{"type":"string","description":"Explicit OLD/site NetConfig path (overrides site-based resolution)."},"new_netconfig":{"type":"string","description":"NEW-env server_jump NetConfig (gets windows_<name>_in/_out). Default = the site NetConfig (self-contained single-file run)."},"scope":{"type":"string","enum":["tcp-listen","all"],"description":"Which inbound class to provision. tcp-listen (default) = upstream-fed TCP-listener roots (those with a PROTOCOL.PORT, which the jump pattern needs). all = include ICL/file inbounds too (most will be skipped — no listen port)."},"filter":{"type":"string","description":"Regex limiting which inbound root NAMES are provisioned."},"inbound_host":{"type":"string","description":"Host that each windows_<name>_out connects to on the NEW env (the cloned existing inbound). Default 127.0.0.1 (loopback)."},"process_jump":{"type":"string","description":"Process for the NEW-side server_jump threads. Default server_jump."},"dry_run":{"type":"boolean","description":"true = print the FULL plan (every inbound + every block/route that WOULD be created) and write NOTHING. Default false."},"confirm":{"type":"string","description":"Must be 'yes' to actually write. Anything else (or unset) = plan-only, no writes. The whole batch stays reversible via larry-rollback.sh --session."},"format":{"type":"string","enum":["text","tsv"],"description":"Summary format. text (default) = per-inbound plan blocks + rollback command; tsv = one row per inbound (inbound, orig_port, jump_port, encoding, process, the three generated thread names, route_splice)."}},"required":["new_host","new_port_base"]}},
{"name":"ssh_pull","description":"Pull a file from a remote SSH-aliased host to a local path via the existing ControlMaster (no second auth, no second TCP handshake). Use this BEFORE calling any local tool (read_file, nc_diff_interface, grep_files, hl7_diff, etc.) when the source file lives on a remote host. The local path returned by this tool is stable for re-use within and across turns — pulling the same remote_path again returns the same local_path. If local_path is omitted, a deterministic temp path /tmp/larry-pulls/<alias>.<basename>.<hash> is used. Verifies the master is open first; if not, fails with a clear message ('open the master with /ssh-setup <alias> first'). Validates the transferred size matches the remote stat.","input_schema":{"type":"object","properties":{"alias":{"type":"string","description":"SSH alias (see ssh_status). Master must be open."},"remote_path":{"type":"string","description":"Absolute path on the remote host."},"local_path":{"type":"string","description":"Optional explicit local destination. If omitted, a deterministic /tmp/larry-pulls/<alias>.<basename>.<hash> path is used and printed in the tool result."}},"required":["alias","remote_path"]}},
{"name":"ssh_push","description":"Push a local file to a remote SSH-aliased host via the existing ControlMaster. Use for sending small input bundles to a remote env (e.g. regression-test input messages, a sanitized HL7 file to feed into route_test). Same multiplexing + error handling as ssh_pull. Validates remote-side size matches local size post-transfer.","input_schema":{"type":"object","properties":{"alias":{"type":"string","description":"SSH alias (see ssh_status). Master must be open."},"local_path":{"type":"string","description":"Absolute local path to the file to send."},"remote_path":{"type":"string","description":"Absolute remote destination path."}},"required":["alias","local_path","remote_path"]}},

465
lib/nc-provision-jumps.sh Executable file
View File

@ -0,0 +1,465 @@
#!/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