From 39f0e00c016dc00c78618e7d303b109d1a6cc44d Mon Sep 17 00:00:00 2001 From: Bryan Johnson Date: Thu, 28 May 2026 19:38:07 -0700 Subject: [PATCH] =?UTF-8?q?v0.8.32:=20nc=5Fprovision=5Fjumps=20=E2=80=94?= =?UTF-8?q?=20capstone=20inter-server=20jump-thread=20provisioner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ` to nc_regression for bulk env-A-vs-B testing. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 78 +++++++ MANIFEST | 7 +- VERSION | 2 +- larry.sh | 49 +++- lib/nc-provision-jumps.sh | 465 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 596 insertions(+), 5 deletions(-) create mode 100755 lib/nc-provision-jumps.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e29ac..6bcd5a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --new-host --new-port-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__out` (OLD/site NetConfig), `windows__in` + + `windows__out` (NEW-env server_jump NetConfig), and splices a route onto + the OLD inbound's DATAXLATE → `linux__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 ` 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__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 --yes`, then exits + 6 with `provisioning failed at ; 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: ` line consumable directly as the `nc_regression` scope + (server / threads:, `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__in` listens on the jump port and routes internally to + `windows__out` → 127.0.0.1:; `linux__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 ` + 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__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, diff --git a/MANIFEST b/MANIFEST index 6cb57c0..b1488fe 100644 --- a/MANIFEST +++ b/MANIFEST @@ -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 diff --git a/VERSION b/VERSION index d076386..a3fcdfd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.31 +0.8.32 diff --git a/larry.sh b/larry.sh index 7e882d6..3135ba1 100755 --- a/larry.sh +++ b/larry.sh @@ -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 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 1–4 run remotely via ssh_exec + ssh_pull/ssh_push; phases 5–6 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 1–3 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__out on the OLD/site NetConfig + windows__in & windows__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 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: ' line — that provisioned-root list is directly consumable as the nc_regression scope (server / threads:, 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//NetConfig). Give this OR netconfig."},"new_host":{"type":"string","description":"Hostname/IP of the NEW (linux) env that each OLD-side linux__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__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__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/.. is used. Verifies the master is open first; if not, fails with a clear message ('open the master with /ssh-setup 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/.. 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"]}}, diff --git a/lib/nc-provision-jumps.sh b/lib/nc-provision-jumps.sh new file mode 100755 index 0000000..3a6cbe0 --- /dev/null +++ b/lib/nc-provision-jumps.sh @@ -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 +# 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