guides
Images and golden boots (pull, import, bake)
Pull grain-ubuntu goldens, import disks, bake images, and choose ubuntu/alpine bases.
Default image selection (auto)
Config default is image: auto:
- If a local
grain-ubuntugolden disk is Ready under~/.grain/images/, use it. - Otherwise use
ubuntu-cloud(downloadable Ubuntu 24.04 minimal cloud).
grain image ls
grain image pull grain-ubuntu # golden (agent baked in) from GitHub Releases
grain image pull ubuntu-cloud # Ubuntu 24.04 minimal cloud
grain image pull alpine-cloud # Alpine Linux cloud (multi-distro)
grain new # auto → grain-ubuntu if local, else ubuntu-cloud
grain new -i ubuntu-cloud # force Ubuntu cloud image
grain new -i alpine-cloud # Alpine cloud (SSH user alpine)Config:
image: auto # prefer golden when present (default)
# image: ubuntu-cloud
# image: grain-ubuntu
# image: alpine-cloud
ssh_user: ubuntu # override per image when needed (alpine-cloud → alpine)Wait mode: empty/auto (the create default) picks agent when the selected image has a baked agent (HasAgent / has_agent meta), otherwise ssh (soft agent deploy). Override with grain new --wait ssh|agent|userdata.
Catalog: ubuntu-cloud
- arm64 / amd64 from Ubuntu cloud images
- qcow2, cloud-init NoCloud, SSH user
ubuntu - ~300 MB download
- HasAgent: false — grain deploys
grain-agentover SSH when~/.grain/agent/grain-agent-linux-*orjust agent-linuxis available
Catalog: alpine-cloud
- arm64 / amd64 from Alpine cloud images (
genericUEFI + cloud-init qcow2) - qcow2, cloud-init (NoCloud auto-detected on the generic image), SSH user
alpine - ~200–240 MB download with pinned SHA-256 in the catalog (Alpine publishes companion
.sha512/ GPG.asc, not.sha256sidecars — grain pins the SHA-256 of the published qcow2) - HasAgent: false — same SSH agent deploy path as
ubuntu-cloud - Filenames use Alpine arch names (
aarch64/x86_64); grain mapsGOARCHaccordingly
grain image pull alpine-cloud
grain new -i alpine-cloud
# cloud-init still injects SSH keys for user "alpine"Refresh the catalog version when Alpine rolls a new cloud release under dl-cdn.alpinelinux.org/alpine/v*/releases/cloud/.
Golden image: grain-ubuntu
Ubuntu + grain-agent baked in. Prefer pull when online; import still works fully offline.
| Field | Value |
|---|---|
| ID | grain-ubuntu |
| LocalOnly | no (pullable on amd64/arm64) |
| HasAgent | true (create prefers agent wait before SSH deploy) |
| SSH user | ubuntu |
| URL | https://github.com/cxdy/grain/releases/download/golden-latest/grain-ubuntu-{arch}.qcow2 |
Pull from GitHub Releases (golden-latest)
The bake workflow publishes a movable release tag golden-latest (not a code v* release) and rewrites its assets on every successful bake:
| Asset | Notes |
|---|---|
grain-ubuntu-amd64.qcow2 | x86_64 golden (CI on ubuntu-latest) |
grain-ubuntu-amd64.qcow2.sha256 | companion checksum (sha256sum format) |
grain-ubuntu-arm64.qcow2 | aarch64 — needs self-hosted KVM runner (not matrixed yet) |
grain image pull grain-ubuntu
grain image ls # LOCAL=yes for grain-ubuntu
grain new -i grain-ubuntuPull downloads the large qcow2 with progress, then verifies against the companion .sha256 sidecar (catalog SHA256 is empty so the sidecar is authoritative). If the sidecar is missing or unreachable, pull fails (fail closed — no silent install without a digest).
Import remains the offline / custom path:
grain image import ./golden.qcow2
grain image import ./golden.qcow2 --id grain-ubuntu
grain image ls
grain new -i grain-ubuntuImport copies/converts the source into ~/.grain/images/grain-ubuntu/disk.qcow2, writes has_agent=true and ssh_user, and flattens qcow2 overlay chains when qemu-img is available.
Minimal cloud-init seed for golden clones
When the base image is agent-ready (HasAgent / local has_agent meta), create (and Start when regenerating a missing seed) writes a minimal NoCloud user-data instead of the full first-boot document:
Full seed (ubuntu-cloud) | Minimal seed (grain-ubuntu / HasAgent) |
|---|---|
| Hostname, keys, grain user | Same |
| Standard cloud-init module set | Limited cloud_config_modules (hostname, hosts, users, ssh, runcmd) |
| Default package behaviour | package_update / package_upgrade false |
| runcmd: SSH inject + grain-ready | Single runcmd: SSH inject + userdata-ran + grain-ready |
The seed ISO is still attached for per-clone hostname, SSH keys, and 9p mount runcmds. Heavy package installs and long cloud-init stages are avoided because the golden already has the agent and base tooling.
Bake prepares the disk for this path (cloud-init clean, userdata-ran stamp, enabled grain-agent, cleared machine-id). Clones are expected to finish cloud-init and report agent-ready sooner than a cold ubuntu-cloud first boot; measure locally if you need hard p50 numbers.
Bake from ubuntu-cloud (automated)
On a Mac with QEMU:
just build && just agent-linux
brew install qemu
./scripts/bake-golden.sh
# or dry-run:
./scripts/bake-golden.sh --dry-runThe script:
- Ensures
grain-agent-linux-*andubuntu-cloud - Creates a persistent bake VM (SSH deploy of the agent after boot)
- Enables
grain-agent, runscloud-init clean --logs, stamps/var/lib/grain/userdata-ran, and clears/etc/machine-id(systemd regenerates a unique id per clone) - Stops the VM cleanly
qemu-img convert -O qcow2flattens the overlay into a standalone basegrain image import … --id grain-ubuntu
Env knobs: BAKE_VM, IMAGE_ID, GRAIN_BIN, GRAIN_DATA_DIR, KEEP_BAKE_VM=1, ARTIFACT_DIR (with --ci).
CI bake artifacts
GitHub Actions workflow .github/workflows/bake-golden.yml builds grain-ubuntu on a schedule (weekly) and on manual workflow_dispatch.
| Output | Notes |
|---|---|
grain-ubuntu-amd64.qcow2 | Flattened golden disk (ubuntu-cloud + grain-agent, has_agent) |
grain-ubuntu-amd64.qcow2.sha256 | SHA-256 checksum file |
Arch: ubuntu-latest is amd64 only. arm64 golden bakes need a self-hosted runner with QEMU/KVM (not wired into the matrix yet).
KVM: grain QEMU uses -cpu host, which needs KVM on Linux (or HVF on macOS). Many GitHub-hosted runners lack /dev/kvm; the job may fail or time out under pure TCG. Prefer a self-hosted runner with KVM, or bake on a laptop and use the artifact/import path below.
Prefer pull (after a successful bake published golden-latest)
grain image pull grain-ubuntu
grain new -i grain-ubuntuDownload from Actions (fallback / pre-release)
- Open the repo on GitHub → Actions → workflow Bake golden image.
- Open a successful run → Artifacts → download
grain-ubuntu-amd64. - Unpack if needed, then import:
grain image import ./grain-ubuntu-amd64.qcow2 --id grain-ubuntu
# optional: verify checksum
sha256sum -c grain-ubuntu-amd64.qcow2.sha256
grain image ls
grain new -i grain-ubuntuLocal / CI script
just build && just agent-linux
# Full CI path (isolated data dir, writes dist/golden/…):
./scripts/ci-bake-golden.sh
# or:
./scripts/bake-golden.sh --ci
# ARTIFACT_DIR=./out CI_READY_TIMEOUT=15m ./scripts/bake-golden.sh --ciEvery successful bake always updates the golden-latest release (create tag if missing, replace assets). Optional workflow_dispatch input release_tag (e.g. v0.2.0) also attaches the qcow2 + checksum to that existing code release. Code releases (release.yml on v*) stay binary-only — no golden coupling.
Bake manually
just agent-linux
grain up
grain image pull ubuntu-cloud
grain new -p -n bake-vm -i ubuntu-cloud
# Agent runs as root — use one remote sh -c so && does not run on the host.
grain x bake-vm -- sh -c 'systemctl enable grain-agent'
grain x bake-vm -- sh -c 'cloud-init clean --logs'
grain x bake-vm -- sh -c 'mkdir -p /var/lib/grain && touch /var/lib/grain/userdata-ran'
grain x bake-vm -- sh -c 'truncate -s 0 /etc/machine-id' # unique id regenerated on clone boot
grain stop bake-vm
qemu-img convert -O qcow2 ~/.grain/vms/bake-vm/disk.img.qcow2 /tmp/grain-ubuntu.qcow2
grain image import /tmp/grain-ubuntu.qcow2 --id grain-ubuntu
grain rm bake-vm
grain new -i grain-ubuntuWhy bake?
| Path | First create | Agent on boot |
|---|---|---|
ubuntu-cloud | pull once + SSH deploy agent each new guest (or reuse if already on disk) | after deploy |
grain-ubuntu | pull once (or import) + agent already in image | systemd enable from bake |
Config can stay image: ubuntu-cloud. Prefer the golden id when local:
# optional helper in tooling: image.DefaultIDFor(dataDir) returns grain-ubuntu
# when that base is Ready, else ubuntu-cloud
grain new -i grain-ubuntuDownload once
Images land under ~/.grain/images/<id>/ (e.g. disk.qcow2).grain image pull no-ops if a usable base disk is already present. Each new VM uses a qcow2 overlay (or CoW clone) on top of that shared base—you do not re-download per sandbox.
grain image pull # once per machine (or after deleting the image dir)
grain new # overlay on local base
grain new # same base againBoot latency bench
Measure create readiness (image must already be local; daemon up):
grain up
grain image pull grain-ubuntu # or ubuntu-cloud / alpine-cloud
./scripts/bench-create.sh # default N=5, auto wait
./scripts/bench-create.sh -n 10 -i grain-ubuntu --wait agent
N=10 IMAGE=ubuntu-cloud WAIT=ssh ./scripts/bench-create.shThe script times N grain new runs, deletes each VM after timing (unless --keep), and prints min / max / avg / p50 / p95 in milliseconds. Use it to compare cold ubuntu-cloud (SSH + agent deploy) vs golden grain-ubuntu (agent already in image).
SHA-256 verification
Catalog production IDs always require a digest before install (fail closed):
| Image | Digest source |
|---|---|
ubuntu-cloud | Pinned in catalog from Ubuntu SHA256SUMS |
alpine-cloud | Pinned in catalog (SHA-256 of the published qcow2; Alpine ships .sha512/.asc only) |
grain-ubuntu | Companion URL.sha256 sidecar on the golden-latest release (catalog pin empty) |
After download (before rename into place):
- grain resolves the expected digest (catalog pin, else companion
URL.sha256) - if no digest is available, pull fails with
refusing unverified pull(unless a non-catalog Spec setsAllowUnverifiedfor tests/dev only) - grain hashes the partial file
- on mismatch, the partial is deleted and pull fails with
sha256 mismatch: got … want … - on success, the file is renamed to
disk.qcow2(or.img) andsource.url/ssh_userhints are written
Risk note: empty catalog SHA256 without a reachable sidecar used to skip verification. That path is closed for production IDs. VerifySHA256 with an empty want still no-ops for low-level callers, but grain image pull / pullSpec refuse that path unless AllowUnverified is set.
When Ubuntu rolls the release pointer, digests in the catalog must be refreshed to match SHA256SUMS. When Alpine rolls alpineCloudVersion / rev, recompute the qcow2 SHA-256 pins (cross-check against the published .sha512).
Why not tiny custom images by default?
grain targets real cloud-init Linux sandboxes: SSH, packages, agents, k3s labs. That needs:
| Requirement | Cloud image | Tiny custom |
|---|---|---|
| cloud-init NoCloud seed (SSH keys, hostname, runcmd, mounts) | yes | often missing or half-broken |
| virtio disk/net + UEFI (esp. aarch64) | tested | easy to misconfigure |
familiar apt / ubuntu user | yes | custom users and paths |
| security updates from upstream | yes | you own the rebuild |
A smaller rootfs can be added later as another catalog id; the default stays a known-good Ubuntu cloud image so grain new + grain sh works without hand-rolled kernels or init. Golden images (grain-ubuntu) layer agent readiness on that same base via grain image pull or local import.
Firecracker rootfs
When hypervisor: firecracker, guests need a raw root disk and a separate vmlinux kernel. Catalog qcow2 cloud images are converted with qemu-img convert -O raw at Start when possible; otherwise Start asks for a raw golden.
Firecracker does not use UEFI the same way as QEMU aarch64 cloud boots. Prefer a FC-oriented kernel + rootfs pair. See Firecracker for layout, vsock agent, and limits.
Related
- Troubleshooting — pull failures, doctor image check
- Firecracker — FC backend, kernel/rootfs, vsock agent, TAP publish
- Agent — wait modes, deploy, golden HasAgent path
- Mounts — 9p shares into the guest