Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

.nornir/install-system.md

nornir-install — Installer, Airgap Deployer & Service Orchestrator

Design document for the airgap deploy/installer system (crate nornir-airgap, codename Skíðblaðnir). Replaces: x14-datalabb Ansible playbooks. Targets: holger, nornir-server, mail-server, tunnr, and any future LAKEhouse component. Airgap is the primary use case — every design decision must work with zero network access.

⚠️ CLI MOVED (2026-06-20) — nornir install was repurposed

The verbs in this document originally lived under nornir install …. As of commit aa6745b the nornir install namespace was repurposed for the single-host server lifecycle (add | update | remove), and the airgap bundle/component verbs moved under nornir airgap. Translate while reading:

this doc says run today
nornir install pack-lake nornir airgap pack
nornir install from-archive nornir airgap install (one component)
nornir install verify nornir airgap fetch (verify-on-receipt)
nornir install upgrade --delta nornir airgap component upgrade --delta
nornir-install remove <component> nornir airgap component remove <name>
(by-name install/status) nornir airgap component add|status <name>

The nornir install verb now means only the nornir-server systemd lifecycle — add (install + create the nornir user + /etc/nornir + unit), update (local binary swap + restart; no registry fetch), remove (tear down). That flow is documented in running-as-a-server.md, not here. This doc remains the design reference for the airgap engine beneath both surfaces. See airgap-install.md for the current airgap guide.

holger self-installs too (2026-06-21)

The systemd-install limb (nornir-airgap::systemd, design: crates/nornir-airgap/.nornir/airgap-installer.md) is now reused by holger: holger-server install {add | update | remove | status | render} drives the same engine — Skíðblaðnir's "every product installs itself the same way" goal, realized for holger first. The engine grew the matching remove_systemd / status_systemd verbs so the whole lifecycle (add · update · delete · status) lives at the spec layer. holger builds its own SystemdServiceSpec (its serve verb is start --config, config is RON) — the engine only applies it.


Problem statement

x14-datalabb is an Ansible-based playbook that installs the full Nordic stack on air-gapped Linux machines. It:

  1. Creates Linux users and directory trees (data, program, PID paths).
  2. Installs and enables systemd units.
  3. Starts services in a specific dependency order.
  4. Handles per-machine/per-service PKI (keys + certificates).
  5. Pre-stages a compressed archive before connectivity is lost.

Pain points: Ansible is an extra dependency, YAML is not type-safe, ordering is implicit, PKI wiring is bespoke per playbook, and znippy archives need full decompression before any component is reachable.


Airgap model — the primary deployment path

An air-gapped machine has no network access after the initial staging step. Every byte that will ever run on it must arrive before the gap closes. This is not an edge case — it is the x14-datalabb default.

The two phases

PHASE 0: PRE-STAGING  (internet-connected staging host OR secure courier)
──────────────────────────────────────────────────────────────────────────

  nornir install pack-lake --components holger,nornir-server,tunnr \
                           --platform linux-x86_64,windows-x86_64  \
                           --out lake-2026-06-14.znippy

  What this produces (a SINGLE signed znippy archive):
  ┌──────────────────────────────────────────────────────┐
  │  lake-2026-06-14.znippy                              │
  │  ├── installer/nornir-install   ← the installer itself│
  │  ├── manifests/                                      │
  │  │   ├── holger.toml            ← ComponentSpec      │
  │  │   ├── nornir-server.toml                          │
  │  │   └── tunnr.toml                                  │
  │  ├── linux-x86_64/                                   │
  │  │   ├── holger                 ← statically linked  │
  │  │   ├── nornir-server                               │
  │  │   └── tunnr.img              ← UEFI appliance     │
  │  ├── windows-x86_64/                                 │
  │  │   └── x14_sorcerer_1.1.29.msi                    │
  │  └── MANIFEST.blake3            ← whole-archive hash │
  └──────────────────────────────────────────────────────┘

  Transfer: USB stick / encrypted courier / one-time relay drop
  Verify on receive: nornir install verify lake-2026-06-14.znippy


PHASE 1: AIRGAP INSTALL  (target machine, zero network)
──────────────────────────────────────────────────────────────────────────

  # The installer is IN the archive — bootstrap it out first:
  znippy extract lake-2026-06-14.znippy installer/nornir-install \
         | install -m 0755 /dev/stdin /usr/local/bin/nornir-install

  nornir-install from-archive lake-2026-06-14.znippy \
                 --select holger,nornir-server

  ↓ for each selected component (in dependency order):
  ├── stream component binary from archive (NO full decompress)
  ├── blake3-verify the streamed bytes against MANIFEST.blake3
  ├── scaffold filesystem (user, dirs, permissions)
  ├── write binary (atomic: temp → rename)
  ├── generate systemd unit from embedded ComponentSpec
  ├── generate PKI (machine CA if not present, leaf cert)
  └── enable + start (health-check before proceeding)

Airgap constraints that shape every design decision

Constraint Implication
No DNS, no HTTP All deps embedded in the znippy archive; no download calls at install time
No package manager Binaries must be statically linked (musl on Linux); no apt/dnf
No container registry WiX container pre-built on staging host, not pulled at install time
No time server (NTP) PKI cert validity cannot rely on accurate wall-clock at install time; use a wide window (10 years) or monotonic-only
No log aggregation journalctl output is the only visibility; structured errors must be human-readable
Bootstrap problem The installer itself must be inside the archive; znippy can extract a single file before the full install starts
Partial install If power fails mid-install, re-running must be idempotent (each step checks before acting)
Offline upgrade Delta packages (pack-delta) must also be airgap-deliverable znippy archives

What "no full decompress" means

znippy archives are Arrow IPC files with an index at the end. The installer uses archive::seek_to(archive, "linux-x86_64/holger") — a single index lookup + ranged read — to stream the binary directly to disk. The rest of the archive (e.g. the Windows MSI) is never touched. This is critical when the USB stick has limited I/O throughput or the target has limited /tmp space.

Secure transfer options

  1. USB stick — simplest; verify MANIFEST.blake3 after copy.
  2. One-time relay drop — staging host uploads to a short-lived HTTPS endpoint (e.g. presigned S3); target downloads once then the endpoint expires.
  3. Encrypted courier — archive encrypted with age (recipient = target's pubkey, provisioned out-of-band); nornir install decrypt-and-verify unwraps before install.

Use case: x14-datalabb air-gapped deployment

┌─────────────────────────────────────────────────────────────────┐
│  OFFLINE MACHINE (air-gap)                                      │
│                                                                 │
│  1. znippy archive arrives (USB / secure relay)                 │
│     ↓ nornir-install from-archive lake.znippy                   │
│  2. Installer streams individual components from the archive    │
│     WITHOUT full decompression (znippy selective read)          │
│     ↓                                                           │
│  3. Per-component:                                              │
│     a. Create Linux user (e.g. _holger, _nornir)                │
│     b. Create directories:                                      │
│        /opt/<name>/bin   (program, non-writable)                │
│        /var/lib/<name>   (data)                                 │
│        /run/<name>       (PID / socket)                         │
│        /etc/<name>       (config, secrets)                      │
│     c. Write binary + config                                    │
│     d. Generate systemd unit from ComponentSpec                 │
│     e. Register PKI: per-machine CA + per-service leaf cert     │
│     f. Enable + start unit                                      │
│  4. Dependency-ordered start: holger → nornir-server → ...      │
│  5. Health-check each service; report failures with context     │
└─────────────────────────────────────────────────────────────────┘

Architecture

nornir-install crate (in nornir workspace)

nornir-install/
  src/
    lib.rs           — public API surface
    spec.rs          — ComponentSpec + DependencyGraph
    installer.rs     — Installer: install / upgrade / remove / status
    filesystem.rs    — user creation, directory layout, permissions
    systemd.rs       — unit file render + journalctl drain
    pki.rs           — PKI: machine CA + per-service leaf cert gen
    archive.rs       — znippy selective read (no full decompress) + pack-lake
    airgap.rs        — airgap-specific: verify MANIFEST.blake3, decrypt (age),
                       bootstrap extraction, idempotency guards
    delta.rs         — DeltaPackager: bsdiff-style patch via znippy
    health.rs        — health-check poller (HTTP /health or TCP)
    redfish.rs       — RedfishClient: bare-metal BMC provisioning
    error.rs         — InstallError enum
    msi.rs           — Windows MSI builder (WiX 4.0.6 via Wine/podman)

ComponentSpec — the unit of installation

pub struct ComponentSpec {
    pub name: &'static str,       // "holger", "nornir-server", "tunnr"
    pub binary: PathBuf,          // path inside znippy archive
    pub user: &'static str,       // "_holger"
    pub data_dir: PathBuf,        // /var/lib/holger
    pub config_dir: PathBuf,      // /etc/holger
    pub pid_dir: PathBuf,         // /run/holger
    pub ports: Vec<u16>,          // [7070]
    pub after: Vec<&'static str>, // ["holger"] — start AFTER these
    pub restart: RestartPolicy,   // Always / OnFailure / Never
    pub health: HealthCheck,      // Http("/health") | Tcp(port) | Pid
    pub pki: PkiSpec,             // None | Leaf { ca_ref, san }
    pub env: Vec<EnvSpec>,        // static + generated (PKI paths)
}

Each product ships its own ComponentSpec::holger(), ComponentSpec::tunnr(), etc.


Phases of nornir install from-archive

Phase 0 — airgap pre-step (znippy archive)

The archive is a znippy .znippy file (Arrow IPC, zero-copy indexed).

lake.znippy
  components/
    holger          ← binary
    nornir-server   ← binary
    tunnr           ← binary (UEFI appliance image)
    ...
  manifests/
    holger.toml
    nornir-server.toml
    ...

nornir install from-archive lake.znippy --select holger,nornir-server streams ONLY the selected components out of the archive — no full decompress. znippy's indexed format means seeking to components/holger is O(1).

Phase 1 — filesystem scaffold

/opt/<name>/bin/<name>    0755  owned by root, group _<name>
/var/lib/<name>/          0750  owned by _<name>
/var/log/<name>/          0750  owned by _<name>
/run/<name>/              0755  owned by _<name>
/etc/<name>/              0700  owned by _<name>

Linux user _<name> is created with --system --no-create-home --shell /sbin/nologin.

Phase 2 — PKI: machine CA + per-service leaf certs

An air-gapped machine needs its own PKI. The installer:

  1. Checks if a machine CA exists at /etc/x14-ca/ — creates one if not (ed25519, 10-year validity, CN=<hostname>.x14.local).
  2. For each component that declares pki: Leaf { san }: generates a leaf key+cert signed by the machine CA, writes to /etc/holger/holger.key + holger.crt.
  3. For inter-service mTLS: each service gets a client cert too.

Option for future: a temp PKI service boots first, services request certs via a gRPC PKI.IssueCert call during their startup, then the PKI service shuts down. This lets certs be re-issued without the installer needing root long-term.

Phase 3 — systemd unit generation

TLS is configured in the RON config, NOT by environment. This unit deliberately sets only HOLGER_DATA. Earlier revisions also set HOLGER_TLS_CERT and HOLGER_TLS_KEY, and holger reads NEITHER — its complete env-read surface is HOLGER_DATA, HOLGER_WAREHOUSE, HOLGER_TOKEN, HOLGER_QUARANTINE_PROPERTY, HOLGER_REQUIRE_NETWORK, HOLGER_PROMOTE_{ENFORCE,REQUIRE_PROPERTY,DENY_LICENSES,ADVISORY_DB} and HOLGER_DEMO_{IMAGE,BEAT_MS}. Transport security comes exclusively from exposed_endpoints[].ron_tls { cert, key, client_ca } in the file passed to --config.

That mattered more than a stale line usually does: holger's own auth guide states that with no auth block a server "accepts anonymous writes over cleartext". An operator who copied this unit, pointed the two variables at real certificates and saw the service come up green had cleartext plus anonymous writes, and two settings that looked like they had done something. A security control that fails silently OPEN — the same shape as the OIDC cache-TTL rename (holger 0e5a635, edda 0c1bffd). Skidbladnir's shipped ComponentSpec was always consistent with the code (product.rs asserts only HOLGER_DATA); it was this DOCUMENT that was fiction.

[Unit]
Description=Holger artifact repository
After=network.target nornir-server.service
Requires=nornir-server.service

[Service]
Type=notify
User=_holger
ExecStart=/opt/holger/bin/holger serve --config /etc/holger/holger.toml
PIDFile=/run/holger/holger.pid
Restart=on-failure
RestartSec=5s
Environment=HOLGER_DATA=/var/lib/holger
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/holger /run/holger /var/log/holger

[Install]
WantedBy=multi-user.target

The unit file is rendered from ComponentSpec — hardening flags (NoNewPrivileges, ProtectSystem, etc.) are always on unless spec.hardening = false.

Phase 4 — dependency-ordered start

The DependencyGraph topologically sorts all ComponentSpec::after edges. Services are started in waves: each wave = all components whose dependencies are already running. After each service starts, the health-check polls up to 30s.

Wave 1: machine-ca-service (if temp PKI mode)
Wave 2: holger (no after-deps)
Wave 3: nornir-server (after: holger)
Wave 4: mail-server (after: nornir-server)
Wave 5: tunnr (after: nornir-server)

If a service fails to pass its health-check, the installer reports:


nornir install CLI verbs

# Staging host (internet-connected)
nornir install pack-lake --components holger,nornir-server,tunnr \
                         --platform linux-x86_64,windows-x86_64  \
                         --out lake-2026-06-14.znippy             # airgap bundle
nornir install pack-delta <old_bin> <new_bin> --out delta.znippy  # upgrade patch
nornir install pack-msi [--spec sorcerer.toml] [--out path]       # Windows MSI
nornir install pack-windows [--spec sorcerer.toml]                # MSI → znippy bundle

# Target machine (airgap — zero network)
nornir-install verify lake.znippy                   # check MANIFEST.blake3
nornir-install decrypt lake.znippy.age --key id_age # unwrap encrypted courier
nornir-install from-archive lake.znippy [--select holger,nornir-server]
nornir-install upgrade --delta delta.znippy         # apply binary patch
nornir-install remove <component>
nornir-install status                               # table: name/version/health/uptime

# Image building
nornir install build-image                          # build WiX podman container

Delta package delivery (znippy)

For upgrades where bandwidth or time matters:

  1. nornir install pack-delta v1_binary v2_binary --out delta.znippy — computes bsdiff-style binary delta, wraps in a signed znippy chunk — the patch is zstd-compressed + blake3-signed
  2. nornir install upgrade --delta — fetches delta.znippy (or reads from local archive) — verifies signature, applies patch in-memory (zero alloc for the binary delta), writes to a temp file, atomic-rename onto the existing binary
  3. Falls back to full binary if patch would be larger than the full binary.

tunnr integration

tunnr is a UEFI appliance — its "binary" in the znippy archive is a disk image. ComponentSpec::tunnr() declares type: Appliance instead of Service. The installer:

  1. Writes the image to a block device (or loop-mounted file).
  2. Registers it with the tunnr KVM supervisor (a systemd service that manages VMs).
  3. Health-check: tunnr status <vm-name> returns BOOT-OK.

For Redfish / bare-metal: ComponentSpec::redfish_target() wraps a RedfishClient that PXE-boots the target, pushes the image, and polls for POST-complete.


Redfish + Terraform stubs

RedfishClient (feature redfish):

Terraform provider stubs (feature terraform):


Crate structure in nornir workspace

# nornir/Cargo.toml
[workspace.members]
+ "crates/nornir-install"

# crates/nornir-install/Cargo.toml
[package]
name = "nornir-install"
version = "0.1.0"

[dependencies]
znippy-zoomies = "0.1.10"   # selective archive read + GATLING save
bsdiff = "0.2"               # binary delta
zstd = "0.13"
blake3 = "1"
tonic = ...                  # PKI gRPC (optional)
serde = { version = "1", features = ["derive"] }

[features]
default = ["systemd"]
systemd = []                 # systemd unit render + journalctl drain
pki = []                     # machine CA + leaf cert gen
redfish = ["reqwest"]        # BMC provisioning
terraform = []               # provider stubs
delta = ["bsdiff"]           # delta package delivery

Test obligations (LAW 1 — inject-assert)


Prior art: talos-airgap bundler (git/airgap-kubernetes-main)

This Go project implements the exact same two-phase bundle→deploy pattern as nornir-install, but for Talos Linux / Kubernetes clusters (KVM + Scaleway). It was assembled quickly and is the direct inspiration for every major nornir-install architectural decision. Study it.

What it does

Connected machine:                  Airgap machine:
  ./bundler bundle                    tar -xf talos-bundle.tar.zst
    → talos-bundle-v1.12.6.tar.zst     podman load -i images/bundler.tar
                                        ./bundler provision kvm \
                                          --libvirt-addr 192.168.1.10:16509
Phase bundler verb nornir-install verb
Pack on connected host bundle [archive|container] pack-lake
Deploy on airgap target provision kvm|scaleway from-archive
Upgrade bundle --upgrade-to v1.10.0 pack-delta + upgrade --delta

Five patterns to adopt directly

1. Concurrent download → GATLING
saveImage spawns a goroutine per image into a buffered channel — wall-clock = slowest image.

ch := make(chan result, len(imgs))
for _, img := range imgs { go func() { ch <- result{img, saveImage(img, dir)} }() }

In Rust: znippy GATLING worker pool (one worker per download slot, 32-core encode, save stage also GATLING — LAW 8).

2. Resumable download with atomic rename
dlFile writes to dest.tmp, checks existing .tmp size for Range: bytes=N- resume, renames to dest only on completion. Never leaves a partial file at the real path.
→ Identical to how archive::extract_component must write binaries (temp → atomic rename).

3. Self-bootstrap detection (insideContainer)
The binary detects whether it is running inside its own container by checking /.dockerenv or exe == "/bundler", and switches default paths accordingly. The nornir-install equivalent:

fn inside_archive_bootstrap() -> bool {
    std::env::var("NORNIR_INSTALL_BOOTSTRAP").is_ok()
    || std::env::current_exe().map(|p| p == Path::new("/usr/local/bin/nornir-install")).unwrap_or(false)
}

This lets the same binary be both the pack-side tool and the airgap-side installer.

4. Upgrade bundles carry from+to for rollback
upgradeImageList includes both the current and the target installer image so the cluster can roll back if the new version fails to boot. Talos uses A/B partition swap. → nornir install pack-delta should embed the old binary alongside the patch so upgrade --delta can fall back to the known-good binary without a network fetch.

5. Pure-language provisioner (no CGO, no C libs)
go-libvirt re-implements the libvirt wire protocol in pure Go over TCP:16509 — zero CGO, distroless-compatible. The Rust equivalent is direct TCP using the libvirt RPC protocol (XDRGEN structs) or calling virsh as a subprocess — the latter is acceptable for nornir-install since KVM provisioning is a staging-host-only step, not airgap.

What the Go bundler has that we don't yet

Feature bundler status nornir-install status
Kubernetes cluster provisioning (KVM + cloud) ✓ go-libvirt + OpenTofu not planned — different scope
Talos imageCache.localEnabled (local OCI registry on first boot) ✓ YAML config → could inform container appliance bootstrap in tunnr
Dual WireGuard layers (KubeSpan node-level + Cilium pod-level) ✓ → relevant when korp appliance runs on a Talos-managed node
OCI image assembly via buildah (daemonless) ✓ Path B msi.rs covers the Windows side; Linux appliance uses existing container images
bundle.json version manifest ✓ → MANIFEST.blake3 + manifest.toml covers this
--skip-images flag for dev iteration ✓ → add --skip-components to pack-lake for rapid iteration
Progress bar during large downloads ✓ progressReader → wire into nornir viz streaming telemetry (LAW 5)

Key file mapping

bundler file nornir-install equivalent
versions.go — single source of truth for version pins ComponentSpec::version() — per-component version in Rust
bundle.go — download + image pull + compress archive.rs + airgap.rs pack_lake()
kvm.go — libvirt RPC provisioner out of scope (LAKEhouse ≠ K8s cluster)
bootstrap.go — talosctl cluster bootstrap systemd.rs + health.rs service start
templates.go — embedded Dockerfiles/scripts systemd.rs render_unit() from ComponentSpec
probe.go — pre-flight checks health.rs PreflightCheck
util.go — run, runOutput, mkdirp, logStep error.rs + filesystem.rs helpers

What this replaces (x14-datalabb Ansible)

Ansible role nornir-install equivalent
create_user.yml filesystem::create_system_user
create_dirs.yml filesystem::scaffold
install_binary.yml archive::extract_component + atomic rename
systemd_unit.yml systemd::render_unit + systemd::enable
start_ordered.yml DependencyGraph::topo_waves + health::poll
generate_pki.yml pki::machine_ca + pki::issue_leaf
holger_install.yml ComponentSpec::holger()
tunnr_install.yml ComponentSpec::tunnr()

The znippy archive replaces the Ansible files/ directory and the Ansible controller's network access — no controller needed at deploy time.


Windows MSI builder (msi-agent)

What msi-agent is

msi-agent (git/msi-agent) is the existing Windows MSI production toolchain for the X14 Sorcerer CDC product: Kafka Connect Standalone + Debezium SQL Server connector + Confluent Schema Registry + Eclipse Temurin JDK 17, wrapped by x14javaservice.exe as a Windows service.

Key facts:

Integration into nornir-install

The Perl scripts (tools/*.pl) are the shell-equivalent anti-pattern (LAW 7). The nornir-install crate absorbs this as src/msi.rs:

pub struct WindowsComponentSpec {
    pub name: &'static str,          // "Sorcerer"
    pub display_name: &'static str,  // "X14 Sorcerer CDC"
    pub version: &'static str,       // "1.1.29.0"
    pub upgrade_code: Uuid,          // stable per product, never changes
    pub install_dir: &'static str,   // "X14\\Sorcerer"
    pub service: WindowsServiceSpec,
    pub files: Vec<WixFile>,         // source → dest mappings
    pub registry: Vec<RegistryValue>,
}

pub struct WindowsServiceSpec {
    pub account: &'static str,       // "LocalSystem"
    pub start: ServiceStart,         // Auto / Manual / Disabled
    pub delayed: bool,
    pub env_block: Vec<(String, String)>,
}

pub struct WixBuilder {
    pub image: String,               // "wix4-bundlev2" (pre-built container)
}

impl WixBuilder {
    /// Generate Product.wxs + Payload.wxs from spec, run WiX in container → .msi
    pub fn build(&self, spec: &WindowsComponentSpec, out: &Path) -> Result<()>;

    /// Build the WiX container image (wraps `podman build -f wixnotricks.dockerfile`)
    pub fn build_image(&self) -> Result<()>;
}

GUID stability: component GUIDs are generated as UUID v5 (SHA-1, namespace 7f5b3f9e-3a2a-4e96-a5c3-4a2b9f7e6a10, key = lowercase component Id). This is exactly what tools/build.pl does — ported to Rust (uuid crate v5 API).

Dependency staging (replaces tools/download.pl → Rust)

pub struct WindowsPayload {
    pub jdk: JdkSource,    // Temurin v17 or v21, Windows x64
    pub kafka: KafkaSource, // version + scala version
    pub plugins: Vec<PluginSource>, // Debezium, Confluent SR jars
}

pub enum PluginSource {
    Maven { group, artifact, version },
    Direct { url, sha256 },
}

WixBuilder::stage(payload, into_dir):

  1. Fetches each source into a local cache dir (idempotent — skip if SHA256 matches)
  2. Prunes JAR patterns (kafka-connect-file-*.jar, CloudEventsConverter.class, etc.) using Rust zip manipulation (the zip crate, not shell calls to unzip)
  3. Outputs the flat file tree that WiX Product.wxs references

Archive delivery (znippy)

For airgap Windows deployments, nornir install pack-windows --spec sorcerer.toml produces a sorcerer_windows.znippy containing:

On the Windows target, nornir install from-archive sorcerer_windows.znippy calls msiexec /i msi\x14_sorcerer.msi /qn (silent install) using a Windows build of nornir-install (cross-compiled x86_64-pc-windows-gnu).

CLI additions

nornir install build-image          # build the WiX container image
nornir install pack-msi [--spec <sorcerer.toml>] [--out <path>]
                                    # stage + WiX-build → .msi
nornir install pack-windows [--spec <sorcerer.toml>]
                                    # pack-msi → znippy bundle

What replaces each Perl script

tools/*.pl nornir-install Rust equivalent
download.pl — HTTP fetch + unpack JDK/Kafka/jars msi::WindowsPayload::stage() using reqwest + zip crate
build.pl — UUID-v5 GUID injection + duplicate check msi::guid_v5(namespace, component_id) + wxs::inject_guids()
prune.pl — strip unused JARs + CloudEventsConverter msi::prune_jars(dir, patterns) + msi::prune_zip_entries(jar, class_globs)
heat.pl — WiX file harvesting msi::harvest(dir) → Vec<WixFile>
build.sh — podman invocation WixBuilder::build() via std::process::Command
wixnotricks.dockerfile unchanged (pre-built once, pushed to local registry)

Test obligations