Architecture
How the parts fit together. For features and known bugs see
TODO.md; for the SuperISO/tacklebox unification roadmap
see ../PLAN-merge.md.
What tacklebox doesβ
Tacklebox produces multi-boot media from one or more bootc images. A "media" is one of three things, picked at build time:
- A loop disk image (
.img) β for QEMU testing ordd-to-USB. - A real block device (
/dev/sdX) β provisioned in place. Destructive. - A UEFI-bootable ISO (
.iso) β for distribution / installer media.
Every media has the same logical layout regardless of target type:
- An ESP (FAT) holding
systemd-boot+ per-env kernel/initrd + BLS entries. - A shared store holding each env's content (ostree deployments for
block targets;
<env>.rootfs.sfssquashfs files for ISOs). - (block only) A persist partition for cross-env user state.
Each bootable environment is independently bootable from the systemd-boot
menu. Today envs install via either bootc install to-filesystem (block
targets, ostree or composefs) or podman image mount + mksquashfs (ISO
targets, dmsquash-live).
Code layoutβ
tacklebox/
βββ cmd/tacklebox/ # CLI entry points (cobra subcommands)
β βββ main.go # root command + persistent --output-base flag
β βββ build.go # the `build` orchestrator
β βββ update.go # the `update` command (host-side USB refresh)
β βββ update_all.go # `update-all` boot-time cross-env updater
β βββ status.go # the `status` command (inspect installed envs)
β βββ verify.go # the `verify` regression-checker
βββ internal/
β βββ recipe/ # JSON recipe schema
β βββ target/ # Target interface + implementations
β β βββ target.go # interface + Mountpoints + InstallMode enum
β β βββ block.go # BlockTarget (loop image / /dev/*)
β β βββ iso.go # IsoTarget (.iso)
β βββ install/ # per-env install backends
β β βββ bootc.go # `bootc install to-filesystem` (block)
β β βββ live.go # podman image mount + mksquashfs (ISO)
β β βββ initramfs.go # initramfs preparation + dracut rebuild + cache
β β βββ bootloader.go # systemd-boot install + BLS entry writer
β βββ blockdev/ # sgdisk + mkfs wrappers
β βββ runner/ # subprocess wrapper (verbose toggle, sudo)
βββ src/
β βββ dracut/95tbox-root/ # initramfs module (per-env root pivot)
β βββ systemd/ # boot-time updater units
βββ examples/ # human-curated example recipes
βββ fixtures/ # CI fixture recipes
βββ .github/workflows/ci.yml # lint-test + verify-smoke pipeline
The build flowβ
tacklebox build <recipe.json> [TARGET | --iso PATH] runs in cmd/tacklebox/build.go:
- Parse the recipe into
recipe.MediaRecipe. - Validate β bootable_envs is non-empty, size parses, target arg shape sane.
- Pre-flight warnings β free-space + per-env store-sizing estimates.
- Pick a Target:
--isoβIsoTarget/dev/*arg βBlockTargetprovisioning a real device- no arg β
BlockTargetwith a loop image
Target.Prepare(track)returnsMountpoints{EspMount, StoreMount}.- BlockTarget:
truncate+losetup+sgdisk+mkfs+mountESP+STORE +bootctl install. - IsoTarget: scratch
iso-root/+esp-staging/dirs.
- BlockTarget:
- Pre-pull all unique image refs in parallel.
- Initramfs preparation (
install.PrepareInitramfs), per env:- Compute cache key from OCI image digest + required module set.
- Cache hit (
<output-base>/initramfs-cache/<key>.img): use as-is, no rebuild. - Cache miss: run
dracutinside a privileged container derived from the image, bind-mountingsrc/dracut/95tbox-root/in. Module set is determined by target type β ISO:[dmsquash-live, tbox-root]; Block:[tbox-root]. Write result to cache keyed by digest so subsequent builds are instant. - Skipped entirely when
"skip_initramfs_rebuild": trueis set on the env (use this for images that already ship the required modules).
- Per-env install loop (
installEnv), dispatched onTarget.InstallMode():Bootc:podman run β¦ <image> bootc install to-filesystem β¦ --stateroot <env> /target, followed byExtractBootFiles(vmlinuz + initrd into the ESP).Live:podman image mount+mksquashfsintoLiveOS/<env>.rootfs.sfs, followed byExtractBootFilesintoimages/pxeboot/<env>/.- Both: write a BLS entry under
loader/entries/<env>.conf.
Target.Finalize(track)returns the artifact path.- BlockTarget: unmount + detach loop. Returns the .img / device path.
- IsoTarget: extract sd-boot from EFISource, mirror pxeboot to iso-root,
mkfs.fat+ mtools the ESP image, runxorrisoto wrap iso-root.
The Target interfaceβ
type Target interface {
Prepare(track Track) (*Mountpoints, error)
Finalize(track Track) (string, error) // returns artifact path
Cleanup() // idempotent
InstallMode() InstallMode // Bootc | Live β picks the per-env backend
KernelPath(envID) string // BLS-relative path for `linux=`
InitrdPath(envID) string // BLS-relative path for `initrd=`
}
Mountpoints are the rendezvous between the orchestrator and the per-env install code:
EspMountβ where BLS entries + per-env kernels are written.StoreMountβ where each env's content (ostree deploy or .sfs file) goes.
The orchestrator never touches partitioning or disk-vs-ISO specifics beyond constructing the right Target; conversely, Targets never touch recipes or per-env install logic. That separation is what makes adding a new output type (e.g. PXE netboot, OCI archive) a self-contained job.
The dracut module: 95tbox-rootβ
src/dracut/95tbox-root/ ships in each env's initramfs (the
SuperISO live containers --add tbox-root to dracut). Its job at
boot time, for block targets only:
- Read
tacklebox.root=tbox-install/<env>from the kernel cmdline. - Bind-mount
/sysroot/<env>over/sysrootsoostree-prepare-rootsees the per-env subtree as the root. - Optionally overlay
/homefrom the persist partition.
For ISO targets, this module is a no-op (no tacklebox.root= arg);
dmsquash-live does the equivalent work via rd.live.squashimg=.
The unit ordering took two iterations (see git log around 2026-05-11):
the service is symlinked into both initrd-root-fs.target.wants/ AND
ostree-prepare-root.service.requires/ so the Before= edge holds even
when ostree-prepare-root.service is started outside the target's
transaction.
The verify commandβ
tacklebox verify <path> (cmd/tacklebox/verify.go) sanity-checks a
built artifact. Auto-detects type by .iso suffix:
- ISO: extract
/EFI/efi.imgviaxorriso, list BLS entries viamtools, hash eachLiveOS/<env>.rootfs.sfsfor distinctness. - Block:
losetup --partscan --read-only+ mount ESP/STORE, enumerate BLS entries, walk per-envostree/deploy/<env>/deploy/for distinctness.
The distinctness check is the regression baseline for the cross-env collision bug (see TODO.md Β§Bugs). Two envs sharing one ostree commit hash β exit 1.
The update commandβ
tacklebox update <recipe.json> <target> (cmd/tacklebox/update.go) re-installs
every bootable environment on an existing media without reformatting or wiping
TBOX_PERSIST. The difference from build:
- No partitioning (
sgdisk,mkfs) β the ESP and STORE are mounted and reused. - Each env's
tbox-install/<id>subtree is cleared and repopulated via the samebootc install to-filesystempipeline asbuild. - BLS entries for envs present in the recipe are overwritten; entries for envs NOT in the recipe are left untouched (additive).
Use this when you change an image ref in the recipe, add a new env, or want to refresh stale deployments without erasing user persistence data.
Cross-env updates: the boot-time timerβ
When a tacklebox media has multiple envs, only the booted one normally
gets bootc upgrade'd. To keep all envs current the user would have to
boot into each one. The tacklebox-update-all machinery automates this.
Three pieces:
tacklebox update-allGo command (cmd/tacklebox/update_all.go). Reads/etc/tacklebox/recipe.json(written bytacklebox build), discovers TBOX_STORE viafindmnt LABEL=β¦, and for each env in the recipe:- Booted env (matched via
tacklebox.root=kernel arg):bootc upgrade --apply. - Other envs:
ostree container image pullinto that env's repo +ostree admin deploy --sysroot=<env>to stage. The next reboot into that env finalizes via bootc as usual.
- Booted env (matched via
src/systemd/tacklebox-update-all.serviceβ Type=oneshot,StandardOutput=journal+consoleso the image refs print at boot.src/systemd/tacklebox-update-all.timerβOnBootSec=2min, one-shot per boot,Persistent=false(don't catch up on missed runs).
tacklebox build installs the binary + units + recipe + enable symlink
into each env's deployment at install time (provisionUpdateSystem).
Updates are best-effort and never block boot; failures log but exit 0.
The CI pipelineβ
.github/workflows/ci.yml runs on every push/PR:
lint-test(~2 min) βgo vet,go test,go build, JSON-schema parse of every recipe, shellcheck the dracut module.verify-smoke(~10-15 min) β builds a 10 GB two-env block image fromcentos-bootc:stream10+fedora-bootc:42, runstacklebox verifyandtacklebox statusagainst it.- Stage 4 Boot Smoke β boots the
verify-smokeimage in QEMU (via TCG) and asserts that the boot menu, kernel, and initramfs (withtbox-rootpivot) all work by grepping the serial console for success patterns.
Key invariantsβ
- Each env is a separate stateroot.
bootc install --stateroot <env>writes to<store>/tbox-install/<env>/ostree/. Envs never share an ostree repo, only the partition they live on. - The shared store is content-distinct. If two envs end up with identical ostree commit hashes, that's the cross-env collision bug (currently open) β verify will catch it.
- The bootloader is single. One ESP, one
loader.conf, one systemd-boot binary. Each env gets one BLS entry permodelisted in the recipe. - The recipe is the source of truth.
tacklebox buildconsumes it,tacklebox verifydoesn't (verify reads what's actually on disk),tacklebox update-allreads a copy persisted to/etc/tacklebox/. - Targets don't know about recipes.
BlockTargetandIsoTargettake pre-computed inputs (partition layout, output paths, EFI source image); the orchestrator is the only thing that bridges recipe and Target.
Where to look when something breaksβ
| Symptom | First file to read |
|---|---|
| Build dies during partitioning | internal/blockdev/format.go |
Build dies inside bootc install | internal/install/bootc.go |
| Build dies during ISO assembly | internal/target/iso.go |
| BLS entry exists but kernel/initrd missing | cmd/tacklebox/build.go (installEnv) |
Boot stalls at ostree-prepare-root | src/dracut/95tbox-root/* |
Boot stalls at dracut-initqueue on a live ISO | cmd/tacklebox/build.go (buildLiveKernelCmdline) β overlay flag syntax |
| Two envs end up with the same content | bootc upstream bug; see TODO.md Β§Bugs |
tacklebox verify flags something | The check name maps 1:1 to a section of cmd/tacklebox/verify.go |