Pack first-boot config scripts (.sh / .ps1 / .cmd / .bat) — and, since 0.2.0,
arbitrary payload files — into a config ISO: an ISO 9660 image with Joliet + Rock Ridge
(real long filenames) plus a firstboot.manifest at the root. On a deployed VM the
first-boot runner finds the disc by that manifest, runs the listed scripts in order, and
(v2) stages the listed payload files for those scripts to consume.
build_script_iso(script_paths, output_path) -> list[str]— legacy firstboot-only packing. Emits a version-1 manifest ({"version": 1, "scripts": [...]}) and always will; its output is a compatibility contract with pinned consumers.build_config_iso(output_path, *, scripts=(), files=(), volume_id=None, progress_cb=None, progress_opaque=None) -> IsoContents— generalized packing.scriptsare UTF-8 text, EOL-normalized per suffix (CRLF for.ps1/.cmd/.bat, LF otherwise), executed by the runner in order.filesare copied byte-for-byte (binaries welcome) and never executed. Emits a version-2 manifest.
Every scripts= / files= element is either a Path (packed under its own basename) or
a (disc_name, source) pair, where source is a Path to stream from or str/bytes
of in-memory content:
build_config_iso(
out,
scripts=[("10-hostname.ps1", rendered_text)], # never touches a temp dir
files=[("pki-executor.exe", agent_build_path)], # renamed without copying
volume_id="WEB01-CONFIG",
progress_cb=lambda done, total, _: publish(done / total),
)EOL normalization keys off the disc name's suffix, so renaming rendered.tmp to
10-hostname.ps1 still gets CRLF.
BundleError(also aValueError) — the bundle is invalid: duplicate or reserved names, a name too long for Joliet, nothing to pack. Retrying will not help; reject the row.IsoWriteError— the build failed for an environmental reason: disk full, unreadable payload, unwritable destination. The same inputs may succeed elsewhere; retry the row.
Both derive from IsoKitError. Writes are atomic — a failed build leaves the destination
untouched rather than a truncated .iso.
progress_cb(bytes_done, bytes_total, progress_opaque) is called as the image is written.
Exceptions it raises are logged and swallowed: progress reporting must not abort a build.
{
"version": 2,
"scripts": ["10-hostname.ps1", "30-install.cmd"],
"files": ["orchestrator.exe"]
}Load-bearing rules (the runners in VM-Setup-Scripts depend on them — coordinate releases):
- Key order is exactly
version,scripts,files, serializedjson.dumps(..., indent=2)so each array entry sits on its own line. The legacy (v1) Linux runner parsesscriptswith a sed range over that block; this layout is what lets a v1 runner execute a v2 disc's scripts while silently ignoringfiles. The v1 Windows runner parses real JSON and tolerates the extra key natively. - Both keys are always present in v2, even when empty. A scripts-empty disc is legal but warned against: v1 runners reject it, v2 runners stage the files transiently and then discard them.
- One flat root namespace: no duplicate basenames across
scripts+files; nothing may be namedfirstboot.manifest.
Limits: 99 entries per list; one flat root namespace (no subdirectories); single files cap
at 4 GiB − 1 (interchange level 3, no multi-extent); Joliet long names cap at 64
characters (checked up front and reported as a BundleError).
isokit holds no module state, does not depend on the working directory, and pycdlib is instance-per-call, so builds are safe in parallel across both threads and processes. This is covered by tests rather than asserted: 16-way thread-pool, 8-way process-pool, and a barrier-synchronised race of 8 builds into one directory.
Depends on pycdlib. Tests: uv run pytest.
Consumed as a versioned git dependency by the VM-Setup-Scripts superproject and the
EC-PKI-Playground backend. Install editable for local dev: uv pip install -e .
Pure library logic — no argparse/print/sys.exit. Collecting paths and reporting output is the caller's concern (see the superproject's
cli/).