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 β or onecombined.rootfs.sfswith a subtree per env whenshared_store.dedupis set, deduplicating files shared across images). - (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, tbox-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 + cache (ISO)
β β βββ initramfs.go # initramfs probe + dracut rebuild + cache
β β βββ bootloader.go # systemd-boot install + BLS entry writer
β βββ blockdev/ # sgdisk + mkfs wrappers
β βββ runner/ # subprocess wrapper (verbose toggle, sudo)
βββ embedded.go # go:embed of src/dracut/ (consumed by initramfs.go)
βββ 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.
- Per-env install loop (
installEnv), dispatched onTarget.InstallMode():- Both modes start with initramfs preparation (
install.PrepareInitramfs):- Compute cache key from image ID + required module set. Module set is
determined by target type β ISO:
[tbox-live, tbox-root]; Block:[tbox-root]. Both are tacklebox's own embedded modules, so images need only core dracut β no distro-specific packages. - Cache hit (
<output-base>/initramfs-cache/<key>.img): use as-is. - Cache miss: probe the image's stock initramfs (
lsinitrd -m) inside a container; if all modules are present, cache the stock initramfs; otherwise rundracut --add β¦in a privileged container derived from the image, bind-mounting the embedded95tbox-rootmodule source in (embedded.goat the repo root carries it viago:embed). - Skipped entirely when
"skip_initramfs_rebuild": trueis set on the env (use this for images that already ship the required modules).
- Compute cache key from image ID + required module set. Module set is
determined by target type β ISO:
Bootc:podman run β¦ <image> bootc install to-filesystem β¦ --stateroot <env> /target, followed byExtractBootFiles(vmlinuz + prepared initrd into the ESP).Live:podman image mount+mksquashfsintoLiveOS/<env>.rootfs.sfs, followed byExtractBootFilesintoimages/pxeboot/<env>/. The squashfs is cached under<output-base>/squashfs-cache/keyed by image ID + compression settings and hardlinked into the staging tree, so rebuilding a multi-env ISO only re-squashes envs whose image changed.Live+shared_store.dedup: onemksquashfspass packs every env as a subtree ofLiveOS/combined.rootfs.sfs(cross-env file dedup). Every BLS entry points at the same squashimg plustacklebox.root=<env>; at boot, tbox-live mounts the combined squashfs + overlay and thetbox-rootmodule bind-mounts/sysroot/<env>over/sysrootβ the same pivot it performs for block targets. Cache key covers all image IDs, so any image change rebuilds the whole combined squashfs.Live+shared_store.dedup_layout: "delta": thedelta_baseenv's rootfs becomesLiveOS/base.rootfs.sfs; every other env gets a smallLiveOS/<env>.delta.sfsβ a file-level diff against the base with overlayfs whiteouts, produced by re-execingtacklebox tree-diffinsidepodman unshare(internal/install/treediff.go). Non-base BLS entries carrytacklebox.live.delta=<env>.delta.sfsand tbox-live stacks the delta as an extra overlay lowerdir. Deltas are cached per (base image, env image) pair, so single-image updates re-diff only the changed env β the per-env caching the combined layout gives up.- Both: write a BLS entry under
loader/entries/<id>.conf(menu title from the recipe's per-envtitle, falling back to the env ID).
- Both modes start with initramfs preparation (
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 modules: 90tbox-live and 95tbox-rootβ
Both live under src/dracut/, are embedded in the tacklebox binary
(embedded.go), and are injected into each env's initramfs by
PrepareInitramfs using the image's own dracut. Neither needs anything
beyond core dracut, which is why live ISOs work from any distro's bootc
image (tuna-os/tacklebox#90 β Fedora's dracut-live/dmsquash-live is
no longer required).
90tbox-live β the live root (ISO targets)β
Claimed by root=tbox:CDLABEL=<iso-label> on the kernel cmdline. A
cmdline hook validates the arg and queues an initqueue script that, once
the labeled device appears:
- Mounts the ISO at
/run/initramfs/live(the dmsquash-live-compatible pathsuperiso-store.mountexpects for offline payloads). - Loop-mounts
LiveOS/<squashimg>(fromtacklebox.live.squashimg=) at/run/rootfsbase. - Mounts a dedicated tmpfs (
tacklebox.live.overlay.size=MiB) at/run/tbox-overlayfor the overlay upper/work dirs.
The final overlay mount onto /sysroot is a systemd sysroot.mount
unit written by the module's generator into the early generator dir β
this must be a generator because systemd-fstab-generator otherwise
copies the unrecognized root=tbox:β¦ into a broken sysroot.mount of
its own (observed on systemd 257). Non-systemd initramfses use a
classic dracut mount hook instead.
95tbox-root β the per-env pivot (all targets)β
Its job at boot time, for block targets:
- 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 per-env-squashfs ISOs, this module is a no-op (no tacklebox.root=
arg); tbox-live has already landed the env's own squashfs on
/sysroot. For shared_store.dedup ISOs the module IS the per-env
mechanism: every entry mounts the same combined squashfs and
tacklebox.root=<env> makes the module bind-mount the env subtree over
/sysroot (step 2 above, without the tbox-install/ prefix).
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. Ordering on sysroot.mount is After= only (no
Requires=): on live boots no generator creates sysroot.mount β
tbox-live mounts /sysroot from dracut-initqueue.service, which the
unit also orders After=.
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:44, runstacklebox verifyandtacklebox statusagainst it. Restores/saves the image-ID-keyed build caches (initramfs-cache/,squashfs-cache/) viaactions/cache; theupdatestep shares the build's-bdir so cache reuse is itself under test. Then boots the image in QEMU (TCG) and greps the serial console for thetbox-root/ostree-prepare-rootpivot + login.iso-smoke(~20-30 min) β the ISO counterpart. Builds two fixture live images in-job (fixtures/iso-smoke.Containerfile: stockfedora-bootc:44+ a per-env marker file) into the runner user's rootless store, then builds both ISO layouts from them: the per-env-squashfsfixtures/iso-2env.jsonand the combinedfixtures/iso-dedup-2env.json. Verifies each, asserts the dedup ISO is meaningfully smaller (the marker is the only diff, so the shared base must dedup), and QEMU-boots both β the per-env ISO intobeta, the dedup ISO intoalphawith a required-pattern assertion that thetbox-rootsubtree pivot loggedrebased OK. This is the only job that exercises the live/ISO boot path end to end.
scripts/test-boot.sh <image> [timeout] [required-patternβ¦] is shared by
both QEMU steps: extra args are literal strings that must also appear in
the serial log (e.g. tacklebox.env=alpha, Tacklebox: rebased OK), and
QEMU_LOG= separates logs when a job boots more than one image.
.github/workflows/poc-artifacts.yml (manual workflow_dispatch +
weekly cron) builds the PoC ISOs β the fixture pair in both layouts, or a
caller-supplied registry recipe β verifies them, and publishes to
Cloudflare R2 with rclone, using the org-wide secrets
(R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET)
and the same convention as dakota-iso/ubuntu-26.04-iso: under the
tacklebox/ prefix as <name>-<date>-<sha>.iso plus a rolling
<name>-latest.iso, served from https://download.tunaos.org/tacklebox/.
Upload is skipped on PRs, on the skip_upload dry-run input, and on forks
with no R2_BUCKET; build + verify always run.
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 |