<!-- nornir:class: history -->
# 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](running-as-a-server.md#install-as-a-systemd-service),
> **not** here. This doc remains the design reference for the airgap engine
> beneath both surfaces. See [airgap-install.md](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`](../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
```rust
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.
```ini
[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:
- Which service failed
- The last N lines from `journalctl -u <name> --no-pager -n 50`
- The exit code / signal
- Which downstream services are blocked
---
## `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`):
- `provision(bmc_url, image_url)` — POST to Redfish Systems/1/Actions/ComputerSystem.Reset
- `wait_post_complete(timeout)` — polls Redfish EventService for PostComplete
- `eject_virtual_media()` — cleanup
Terraform provider stubs (feature `terraform`):
- A thin `terraform-provider-nornir-install` binary that wraps the Rust API via
the Terraform plugin protocol — resource types: `nornir_component`, `nornir_pki_cert`.
---
## Crate structure in nornir workspace
```toml
# 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)
- Unit file render round-trip: `ComponentSpec::holger()` → render → parse → assert
`User=_holger`, `After=...`, all hardening flags present.
- Dependency-ordered start: build a 5-node `DependencyGraph`, assert topological
waves match expected order; assert cycle detection returns `Err`.
- Delta patch round-trip: `pack_delta(a, b)` → `apply_delta(a, patch)` == b.
- Filesystem scaffold: temp dir, `scaffold(spec)`, assert all paths exist with
correct permissions (stat mode bits).
- Archive selective read: build a test znippy archive with 3 components, read only
one, assert the other two are not touched.
- Health-check poller: mock TCP listener, assert `poll_until_healthy` returns Ok
within 1s; mock that never answers, assert timeout Err.
---
## 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.
```go
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:
```rust
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:
- **WiX 4.0.6** (freeware WiX Toolset) builds the `.msi`
- WiX runs under **Wine** inside a podman container (`wixnotricks.dockerfile`) — no
Windows host required; builds on Linux
- Perl scripts currently drive: dependency download (JDK/Kafka/Debezium/Confluent SR
jars), JAR pruning (strips `CloudEventsConverter` + unused connectors), UUID-v5
GUID generation for WiX component stability, podman invocation
- Product installs to `C:\Program Files\X14\Sorcerer\` as a `LocalSystem`
delayed-auto-start service; env block written to
`HKLM\SYSTEM\CurrentControlSet\Services\Sorcerer`
### 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`:
```rust
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)
```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:
- `msi/x14_sorcerer_1.1.29.msi` — the built installer
- `manifest.toml` — version + blake3 hash
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
- `guid_v5` matches the Perl SHA-1 UUID v5 output for known inputs (cross-checked).
- `inject_guids` round-trip: generate wxs with `Guid="*"` → inject → re-parse → all
component GUIDs are deterministic UUID-v5 values, no duplicates.
- `prune_jars` removes exactly the expected filenames and nothing else.
- `prune_zip_entries` removes `CloudEventsConverter.class` entries from a test jar.
- `harvest` produces a `WixFile` list with correct `Source`/`Id` for a test dir tree.