Architecture and build pipeline¶
buildctl is a thin wrapper around external build tools that produces a signed DDI. It does not contain a build system of its own; each backend delegates rootfs production to an existing tool (Docker, mkosi, bash, make). buildctl handles the rest: packaging, integrity, signing, and metadata, all in a pure-Go pipeline.
This page describes how the pieces fit together. For the conceptual design and the rationale behind choices like pure-Go and DPS partition layout, see the build specification in the Offline Lab documentation.
Design constraints¶
The pipeline is shaped by three constraints, all of them hard:
- Must run on macOS. The core pipeline cannot shell out to
mksquashfs,veritysetup, oropenssl; they are Linux-only or behave differently across platforms. - No daemon, no CGO. buildctl is a single statically-linked binary. Cross-compiling it must remain a
GOOS=... GOARCH=... go build. - systemd-native verification. The DDI must be verifiable by stock systemd without any buildctl-side runtime. No custom verifier on the device.
The first two dictate the pure-Go library choices. The third dictates the partition layout and signature format.
Components¶
buildctl
├── cmd/buildctl/ ← cobra CLI; one file per command
├── internal/
│ ├── backend/ ← Backend interface + registry
│ │ ├── docker/ ← docker buildx + export + OCI labels
│ │ ├── shell/ ← bash build.sh
│ │ ├── gnumake/ ← make OUTDIR=...
│ │ └── mkosi/ ← mkosi --output=directory
│ ├── buildignore/ ← .buildignore pattern matcher (moby/patternmatcher)
│ ├── ddi/ ← squashfs + verity + PKCS7 + GPT assembly
│ ├── verity/ ← vendored dm-verity encoder (Monogon, Apache-2.0)
│ └── repoindex/ ← repo scanning, index building, index writing
└── schema/ ← PackageConfig, PackageMetadata, repo index types, validation
Each backend registers itself from init(). The pipeline package does not import any specific backend; cmd/buildctl/commands/build.go blank-imports the four built-in backends and Resolve in internal/backend/registry.go does the rest. See Build backends.
Core pure-Go libraries¶
| Library | What it does |
|---|---|
github.com/diskfs/go-diskfs |
GPT image creation and partition writing. ProtectiveMBR: true is set explicitly; without it, systemd-dissect will not identify the image. |
github.com/KarpelesLab/squashfs |
squashfs read/write with zstd. Every entry is forced to uid=0, gid=0 via SetOwner. |
go.mozilla.org/pkcs7 |
PKCS7 signing. SHA-256 is set explicitly (SetDigestAlgorithm(OIDDigestAlgorithmSHA256)); the library defaults to SHA-1, which OpenSSL 3.x rejects. |
vendored internal/verity |
dm-verity hash tree encoder vendored from Monogon OS (Apache-2.0). Byte-compatible with the kernel target and veritysetup(8). |
What still shells out, and why:
docker buildx build/create: the Docker backend. The Docker daemon is the build tool; buildctl drives it.docker run --privileged tonistiigi/binfmt: optional, viabuildctl init. Registers QEMU for cross-arch emulation.bash build.sh,make,mkosi: the other backends. They are the build tool by design.rsync/ssh: not currently invoked by buildctl; publish is currently filesystem-only.
Build pipeline¶
package.yaml + backend source
│
▼
loadAndValidateConfig ← schema load, normalize, validate (errors/warnings)
│
▼
backend.Resolve ← explicit 'backend:' or auto-detection
backend.Setup ← per-backend (Docker: ensure buildx builder)
│
▼
backend.Build ← docker buildx | build.sh | make | mkosi
│ (populates a scratch directory)
▼
postBuildPipeline ← backend-agnostic transforms (below)
│
▼
ddi.Build ← squashfs → verity → PKCS7 → GPT assembly
│
▼
writePackageMetadata ← metadata JSON next to the .raw
│
▼
optional createZipFromDist ← --compress
Post-build pipeline¶
Runs in fixed order on the scratch directory the backend populated. Each step is a separate function in cmd/buildctl/commands/build.go.
- Skeleton merge: if the project has a
rootfs/directory, walk it and copy every entry into the scratch directory. Regular files overwrite; directories are created if missing; symlinks are recreated with relative targets only (absolute targets are rejected because the DDI mounts at unpredictable paths). .buildignore: if the project has a.buildignore, walk the scratch directory and remove matching paths. Usesmoby/patternmatcherso the syntax is identical to.dockerignore.- Generate
os-release: if/etc/os-releaseis absent, write a minimal two-line file (ID=<name>,VERSION_ID=<version>). If it is present, do not overwrite. - Generate service unit: if
/usr/lib/systemd/system/<name>.serviceis absent ANDpackage.yamlhas acommand:field, write a minimal unit. If neither a unit nor acommand:is provided, fail; a DDI must carry a service unit soportablectlknows what to run. - Reject hardcoded User=/Group=: scan every
.service,.socket,.timer,.target,.pathunit inusr/lib/systemd/system/andetc/systemd/system/. Any line beginning withUser=orGroup=(case-insensitive) is an error. appctl injects these at install time based on the allocated runtime user; hardcoding them would defeat the per-app user model. - Validate
os-releaseconsistency: if/etc/os-releaseis present (always, after step 3), itsID=andVERSION_ID=values must match the package name and version. A mismatch means the backend produced an image for a different package and would silently break package management. - Embed
package.yaml: copy it to/usr/share/<name>/package.yaml. Lets the installed image be inspected without the source repo or the metadata JSON.
Steps 3 and 4 are gap-fillers: they only generate what is missing. A unit file supplied by the backend or the skeleton takes precedence over command:, so authors who want full control keep it.
DDI assembly¶
internal/ddi/build.go. Five stages, each in its own function:
- squashfs: walk the scratch directory, add every entry to a
squashfs.Writer(128 KiB block, zstd), forceuid=0, gid=0on every entry. - verity hash tree: stream the squashfs through the vendored encoder with a randomly-generated 32-byte salt. Produces the Merkle tree and the roothash.
- PKCS7 signature: sign the hex-encoded roothash with
go.mozilla.org/pkcs7, detached, SHA-256. The hex form (not the raw bytes) is what systemd reconstructs from the verity-sig partition at verify time. - Signature partition JSON:
{rootHash, signature, certificateFingerprint}.certificateFingerprintis the SHA-256 of the DER encoding of the signing certificate. - GPT assembly: create the
.rawwithgo-diskfs. Three partitions with arch-specific DPS type UUIDs. The root and verity partition GUIDs are derived from the roothash (first / last 128 bits). Protective MBR is enabled. The signature partition is sector-padded.
Failure modes¶
The pipeline cleans up after itself. Intermediate files (the standalone squashfs and the standalone hash tree) are removed on the success path and on the error path. The scratch directory is removed with defer os.RemoveAll.
backend.Build returning ErrPartialOutput (an empty scratch directory after a "successful" build) is treated as a hard error: a backend that silently did nothing produces nothing useful.
Where to read next¶
- Build backends: backend contract, per-backend details, how to add one.
- DDI output and metadata: what is produced and the meaning of each field.
- Signing and trust model: how the PKCS7 chain reaches kernel-enforced dm-verity at attach time.