Unattended FreeBSD Installs Over IPMI, Without Rebuilding the ISO
Provisioning a bare-metal FreeBSD box through a BMC's virtual media often starts the same way: mount the stock installer ISO, watch bsdinstall's interactive menus over a KVM session, and click through partitioning, network configuration, and distribution selection by hand. That does not scale past a handful of machines, and it does not scale at all if the machine is a colocated server you only reach through IPMI in the first place.
bsdinstall already has an answer for this: place a script on the install media at /etc/installerconfig and it will be run automatically at boot, with no interaction required. (See the bsdinstall(8) man page for more information.) The awkward part has always been getting that one file onto the ISO. The obvious approach is to use mkisofs/genisoimage and build a brand-new image from an extracted copy of the release, or run growisofs in full-rewrite mode. However, this means reading and rewriting every byte of a multi-gigabyte DVD image just to change one config file.
repack-freebsd-iso (see its software page for the full feature list) takes a different approach: it appends a second ISO9660 session containing only the new files and leaves the rest of the image untouched. This post covers the following:
- How the mechanism works
- What a scripted
installerconfiglooks like - How to add a custom distribution set to install additional files on the destination system
- How to drive the whole thing from a BMC so a machine goes from powered-off to fully installed without anyone touching a console
ISO9660 sessions: built for this
ISO9660 was designed for optical media, which is typically write-once/append-only. To let a disc be burned in multiple sittings, the format has a native concept of sessions: a disc can hold several independent volume descriptors and directory trees, each written at a different point in time. Whatever reads the disc (a kernel driver, disc-burning software, or an installer's own ISO9660 parser) always follows the most recently written volume descriptor.
growisofs -M was built to drive mkisofs/genisoimage for exactly this case: appending a session to media that already has one. Its own documentation describes it as "originally designed as a frontend to genisoimage to facilitate appending of data to ISO9660 volumes residing on random-access media such as DVD+RW, DVD-RAM, plain files, hard disk partitions". Note the "plain files." The target of -M does not have to be an optical drive; repack.sh points it at an ordinary new.iso on disk and gets correct multisession behavior from a tool that has supported this mode since long before "provision a server over Redfish" was a sentence anyone wrote.
When growisofs -M runs, it reads the existing image's last volume descriptor to find where the current session ends, invokes mkisofs/genisoimage with -C last_sector,next_sector -M <existing image>, and writes only the new session's directory records, path tables, and any new file payloads past that point. For every path in the old tree that is not re-specified via -graft-points, the tool reuses the old directory entry's extent location instead of re-reading and re-writing that file's data. Only the paths you explicitly graft get new extents in the appended region.
That is the entire efficiency story: a several-gigabyte DVD image gains a new session that is only as large as the files that actually changed. On a dvd1 image, that is the difference between a rebuild that reads and writes several gigabytes and an append that writes a few hundred kilobytes.
-graft-points is what makes this practical. It lets you hand TARGET_PATH_IN_ISO=SOURCE_FILE pairs instead of mirroring an entire source tree onto the image root, so repack.sh can override exactly two paths: /etc/installerconfig and /usr/freebsd-dist/custom.txz (or anything else one wants, with minor modifications to the script). Everything else is inherited, unmodified, from the merged session.
What repack.sh actually does
The essentials of the script are the following:
# Determine what needs grafting
grafts=
if [ -s installerconfig.txt ]; then
grafts="$grafts /etc/installerconfig=$MYDIR/installerconfig.txt"
fi
if [ -s $filesdir/custom.mtree ]; then
tar -cpf - @$filesdir/custom.mtree | xz --compress --stdout > custom.txz
grafts="$grafts /usr/freebsd-dist/custom.txz=$curdir/custom.txz"
fi
if [ -z "$grafts" ]; then
echo "ERROR: Nothing to graft" >&2
exit 1
fi
# Append the session
growisofs -M new.iso -no-cache-inodes -d -l -r -V "$volid" -graft-points $grafts
Whether either payload gets grafted depends entirely on which input files sit next to the script. The script does not use a separate flag to enable each one. If you run repack.sh with neither installerconfig.txt nor files/custom.mtree present, it exits with ERROR: Nothing to graft rather than silently producing a copy of the original image.
Beyond the grafting itself, the script can fetch a release image directly:
which downloads the matching .iso.xz from download.freebsd.org, fetches the corresponding CHECKSUM.SHA512-* file, and verifies the image against it with sha512sum -c before doing anything else. Feeding it a tampered or partially-downloaded image is exactly the kind of failure mode you want caught before it reaches a BMC's virtual media store, not discovered when bsdinstall fails to boot on a machine three time zones away.
Writing an installerconfig that actually runs
bsdinstall(8) documents the format directly: a script placed at /etc/installerconfig on the install media runs at boot and the system reboots automatically once installation completes. The file has two parts: a preamble of environment variable assignments that control installation parameters and an optional shell script (introduced by a #! line) that runs chrooted into the freshly installed system.
The variables that matter most for a scripted install:
| Variable | Purpose |
|---|---|
nonInteractive |
set to YES for a fully unattended run |
PARTITIONS |
disk layout; DEFAULT uses the automatic guided layout |
DISTRIBUTIONS |
traditional .txz distribution sets to install, e.g. base.txz kernel.txz |
COMPONENTS |
base-system-package equivalent of DISTRIBUTIONS, e.g. base lib32 |
ZFSBOOT_DISKS / ZFSBOOT_VDEV_TYPE |
disks and pool layout for a ZFS-root install |
BSDINSTALL_DISTDIR |
where distribution files are read from; defaults to /usr/freebsd-dist |
That default for BSDINSTALL_DISTDIR is not incidental to this whole approach as it is the reason grafting custom.txz into /usr/freebsd-dist works at all. bsdinstall scans that directory for .txz sets to extract, and it has no way to distinguish a set that shipped on the original media from one a second ISO9660 session grafted in five minutes ago. A minimal installerconfig.txt:
PARTITIONS=DEFAULT
DISTRIBUTIONS="base.txz kernel.txz custom.txz"
nonInteractive=YES
#!/bin/sh
sysrc ifconfig_DEFAULT=DHCP
sysrc sshd_enable=YES
pkg install -y puppet
The preamble drives the guided installer non-interactively; the #!/bin/sh block runs afterward, inside the new system, to enable networking and SSH and pull in whatever configuration management the fleet uses from there. The manual page also notes that scripted installations normally expect their distribution files to already be present on the media rather than fetched remotely. This is exactly the gap grafting custom.txz fills for site-specific content that is not part of a stock release.
Custom distribution sets from an mtree manifest
If files/custom.mtree exists, it is used as a standard mtree(8) specification listing files relative to the files/ directory. repack.sh feeds it straight to tar:
tar's @-prefixed argument tells it to read the file list from the mtree spec instead of the command line, so custom.mtree doubles as both the manifest and the thing that makes the resulting archive deterministic. Anything referenced there gets packaged into custom.txz and shows up on the installed system under whatever paths the mtree spec places it, the same way base.txz populates /. Typical examples include a site-specific rc.conf fragment, package mirror configuration, and TLS certificates for a config-management client.
Driving it from a BMC
This is where the ISO-session trick and bsdinstall's scripted-install support meet the actual problem: a server that is only reachable through its BMC, with no PXE infrastructure and no one available to sit at a KVM session.
repack.sh
grafts installerconfig and custom.txz into new.iso
Serve the image
new.iso hosted over plain HTTP, reachable from the BMC's management network
Mount virtual media
BMC calls VirtualMedia.InsertMedia: new.iso becomes a virtual CD drive
Boot override
set next boot to the virtual CD, then power on or restart depending on the host's current state
bsdinstall runs
reads /etc/installerconfig from Session 2, installs unattended, reboots into FreeBSD
No operator ever touches a keyboard, a serial console, or the disc itself.
Two pieces of BMC functionality do the actual work, and they come from different standards:
-
Virtual media is not part of the IPMI 2.0 specification. Historically it meant a vendor-specific Java KVM applet or a CLI tool like
racadmoripmicfg; today, Redfish'sVirtualMedia.InsertMediaaction is the closest thing to a common interface, implemented across Dell iDRAC, HPE iLO, Lenovo XCC, and Supermicro's newer BMCs. It takes a URL, sonew.isoneeds to be reachable over HTTP from the BMC's management network. Python's built-inhttp.servermodule is enough for a one-off provisioning run:That serves the current directory at
http://buildhost:8000/, sonew.isois reachable athttp://buildhost:8000/new.iso, the same URL theVirtualMedia.InsertMediapayload below points at. -
Boot device override and power control are standardized, both in plain IPMI 2.0 LAN (
ipmitool chassis bootdev cd,ipmitool chassis power cycle) and in Redfish (BootSourceOverrideTargeton theComputerSystemresource, followed by aComputerSystem.Resetaction).
The following is a representative sequence using Redfish end to end. It cannot be assumed that the target is sitting powered-on waiting for a restart, as a colocated box may just as easily have been left powered off. Therefore the sequence checks PowerState on the ComputerSystem resource before deciding how to bring the host up: On if it is currently off, ForceRestart if it is already on. Sending ForceRestart to a system that is off is not reliable across vendors; some BMCs no-op it, others reject it outright. wget can drive the same calls as curl: --method sets the verb, --body-data supplies the JSON payload, and --http-user/--http-password handle basic auth. FreeBSD's own fetch(1) cannot: it has no flag for a non-GET method, a request body, or a custom header, so it is left out here, as it is a file-retrieval tool, not a general HTTP client.
# 0. Check current power state
power=$(curl -sk -u "$BMC_USER:$BMC_PASS" \
https://$BMC_HOST/redfish/v1/Systems/1 \
| grep -o '"PowerState"[: ]*"[A-Za-z]*"' | cut -d'"' -f4)
# 1. Mount the repacked image as virtual media
curl -k -u "$BMC_USER:$BMC_PASS" -X POST \
-H "Content-Type: application/json" \
https://$BMC_HOST/redfish/v1/Managers/1/VirtualMedia/CD1/Actions/VirtualMedia.InsertMedia \
-d '{"Image": "http://buildhost:8000/new.iso", "Inserted": true, "WriteProtected": true}'
# 2. Boot from it once
curl -k -u "$BMC_USER:$BMC_PASS" -X PATCH \
-H "Content-Type: application/json" \
https://$BMC_HOST/redfish/v1/Systems/1 \
-d '{"Boot": {"BootSourceOverrideTarget": "Cd", "BootSourceOverrideEnabled": "Once"}}'
# 3. Power on if off, force a restart if already on
[ "$power" = "Off" ] && reset_type=On || reset_type=ForceRestart
curl -k -u "$BMC_USER:$BMC_PASS" -X POST \
-H "Content-Type: application/json" \
https://$BMC_HOST/redfish/v1/Systems/1/Actions/ComputerSystem.Reset \
-d "{\"ResetType\": \"$reset_type\"}"
# 0. Check current power state
power=$(wget -qO - \
--http-user="$BMC_USER" --http-password="$BMC_PASS" \
--no-check-certificate \
https://$BMC_HOST/redfish/v1/Systems/1 \
| grep -o '"PowerState"[: ]*"[A-Za-z]*"' | cut -d'"' -f4)
# 1. Mount the repacked image as virtual media
wget -O - --method=POST \
--http-user="$BMC_USER" --http-password="$BMC_PASS" \
--header="Content-Type: application/json" \
--body-data='{"Image": "http://buildhost:8000/new.iso", "Inserted": true, "WriteProtected": true}' \
--no-check-certificate \
https://$BMC_HOST/redfish/v1/Managers/1/VirtualMedia/CD1/Actions/VirtualMedia.InsertMedia
# 2. Boot from it once
wget -O - --method=PATCH \
--http-user="$BMC_USER" --http-password="$BMC_PASS" \
--header="Content-Type: application/json" \
--body-data='{"Boot": {"BootSourceOverrideTarget": "Cd", "BootSourceOverrideEnabled": "Once"}}' \
--no-check-certificate \
https://$BMC_HOST/redfish/v1/Systems/1
# 3. Power on if off, force a restart if already on
[ "$power" = "Off" ] && reset_type=On || reset_type=ForceRestart
wget -O - --method=POST \
--http-user="$BMC_USER" --http-password="$BMC_PASS" \
--header="Content-Type: application/json" \
--body-data="{\"ResetType\": \"$reset_type\"}" \
--no-check-certificate \
https://$BMC_HOST/redfish/v1/Systems/1/Actions/ComputerSystem.Reset
Resource paths (/Managers/1, VirtualMedia/CD1, /Systems/1) vary by vendor. One always needs to confirm them against GET /redfish/v1/ on the target BMC before scripting against them. Once the host reaches the requested power state, the loader boots off the virtual CD, bsdinstall finds /etc/installerconfig in the merged session exactly where repack.sh grafted it, and the install runs to completion with no operator involvement at any point after step 3.
Does this generalize beyond FreeBSD?
The append-a-session mechanism is a property of the ISO9660 container format, not of FreeBSD specifically. This mechanism works on any ISO9660 image. What does not generalize automatically is whether a given installer reads its config from loose files in the ISO9660 tree at all. Many installers instead read their configuration only from inside an embedded image, such as a squashfs, an initrd cpio archive, or install.wim, and a graft cannot reach into any of those. bsdinstall reads /etc/installerconfig and the BSDINSTALL_DISTDIR contents straight off the mounted media, which is exactly why this works for FreeBSD. The
project's multisession mechanism writeup goes through the same question for Debian preseed, RHEL kickstart, and Windows Setup, including which of those installers can and cannot be reached the same way.