build
Image bake internals
Image bake internals
What this page does
Walk through how an deputyOS image is baked end to end — the
shared Ansible role, the variant gates, the per-Packer-template
invocation, the pre-build staging in scripts/build.sh, and the
hermetic-build invariants. This page is for contributors who want to
understand the build system enough to extend it (new hardware
target, new profile, new policy baseline).
The shared Ansible role + variant gates pattern
Every shipped image is the same Debian 12 (bookworm) base plus the
deputyOS Ansible role at roles/deputyos/. The role applies in a
fixed order:
security-baseline.yml— sysctls, ufw, fail2ban, SSH hardening, AppArmor framework, zram, ClamAV, Magika, the agent user,/etc/deputyos/layout,deputyctlbinary install.networking-baseline.yml— mDNS via avahi (LAN targets only), Cloudflare Tunnel + Tailscale binaries (installed but not started).wizard-baseline.yml—deputywizardbinary install + first-boot systemd unit + qr-on-tty unit (LAN targets).hooks-baseline.yml—/etc/deputyos/hooks.d/<kind>/directory tree creation.voice-baseline.yml(gated) — whisper.cpp + Piper + voice-relay shell bridge +deputyos-voice-relay.service+ AppArmor profile. Self-gates ondeputyos_voice_enabledand thedeputyos_no_voice_hwlist.variant-<hw>.yml— per-target tuning (kernel package, bootloader cmdline, governor, on-demand vs daemon ClamAV, voice gates, …).profile-<id>.yml— agent code install, gateway systemd unit, AppArmor profile,.bake-meta.
Each step is idempotent. The role never starts gateways at bake time — first boot of the image is what runs the first-boot unit, which in turn runs the wizard, which on apply enables and starts the gateway.
Layout
roles/deputyos/
├── defaults/
│ └── main.yml ← role-wide facts (no_voice_hw, cloudish_hw, ...)
├── meta/
│ └── main.yml ← role metadata (Ansible compatibility)
├── handlers/
│ └── main.yml ← Reload sysctl / ufw / Avahi / AppArmor / systemd
├── tasks/
│ ├── main.yml ← entry point; dispatches to the rest
│ ├── security-baseline.yml
│ ├── networking-baseline.yml
│ ├── wizard-baseline.yml
│ ├── hooks-baseline.yml
│ ├── voice-baseline.yml ← gated
│ ├── variant-rpi5.yml
│ ├── variant-rpi4.yml
│ ├── variant-arm64-generic.yml
│ ├── variant-qemu-aarch64.yml
│ ├── variant-qemu-x86_64.yml
│ ├── variant-x86_64-mini-pc.yml
│ ├── variant-wsl2.yml
│ ├── variant-macos-qemu.yml
│ ├── variant-digitalocean.yml
│ ├── variant-oracle-arm-free.yml
│ ├── variant-hetzner-cloud.yml
│ ├── variant-vultr.yml
│ ├── variant-linode.yml
│ ├── variant-fly-machines.yml
│ ├── profile-openclaw.yml
│ ├── profile-hermes.yml
│ └── profile-khoj.yml
├── templates/ ← *.j2 — systemd units + per-profile config
└── files/ ← AppArmor profiles, stub binaries, limits.<hw>.json
The shared invocation pattern: every Packer template runs the role
with deputyos_hw=<hw> and deputyos_profile=<id>, both injected via
Packer var blocks. The shared playbook lives at
packer/playbook.yml.
Entry point: tasks/main.yml
The main.yml is a flat import_tasks chain:
- name: Assert required role variables are set
ansible.builtin.assert:
that:
- deputyos_hw | length > 0
- deputyos_profile | length > 0
- name: Apply security baseline
ansible.builtin.import_tasks: security-baseline.yml
- name: Apply networking baseline
ansible.builtin.import_tasks: networking-baseline.yml
- name: Apply wizard baseline
ansible.builtin.import_tasks: wizard-baseline.yml
- name: Apply hooks baseline
ansible.builtin.import_tasks: hooks-baseline.yml
- name: Apply voice baseline (gated)
ansible.builtin.import_tasks: voice-baseline.yml
# 14 variant dispatches, when: deputyos_hw == "<hw>"
- name: Apply rpi5 variant tuning
ansible.builtin.import_tasks: variant-rpi5.yml
when: deputyos_hw == "rpi5"
# ... rpi4, arm64-generic, qemu-aarch64, qemu-x86_64,
# x86_64-mini-pc, wsl2, macos-qemu,
# digitalocean, oracle-arm-free, hetzner-cloud, vultr,
# linode, fly-machines
# 3 profile dispatches, when: deputyos_profile == "<id>"
- name: Apply OpenClaw profile bake
ansible.builtin.import_tasks: profile-openclaw.yml
when: deputyos_profile == "openclaw"
# ... hermes, khoj
Pre-build staging: scripts/build.sh
Before invoking Packer, scripts/build.sh populates
build/staging/ with the host-built artefacts the role copies into
the guest. Pre-stage steps:
deputyctlrelease binary —cargo build --release --bin deputyctl; copied tobuild/staging/deputyctl.deputywizardrelease binary — same;build/staging/deputywizard.- Profile manifest —
cp profiles/<id>.toml build/staging/profiles/<id>.toml. limits.json— per-target. Falls back to a synthesized inline default if neitherdeputyctl/etc/limits.<hw>.jsonnorroles/deputyos/files/limits.<hw>.jsonexists.- Voice assets —
whisper.cppmodel + Piper binary + Piper voice. Skipped ifDEPUTYOS_VOICE_OFFLINE=1or the target is in the no-voice set. Failures are non-fatal — the role tolerates missing assets and refuses to start the unit. - Cloud-init seed — for QEMU-style targets that need an SSH
session for Packer. A throwaway Ed25519 keypair is generated under
build/staging/ssh-key.
Then Packer is invoked:
packer init <template>
packer validate -var "profile=<id>" -var "channel=<channel>" \
-var "tier=<tier>" -var "deputyos_staging_dir=<staging>" \
<template>
packer build ... <template>
Per-Packer-template anatomy
Each template at packer/<hw>.pkr.hcl does the same shape with
target-specific blocks:
packer {
required_plugins { ... }
}
variable "profile" { type = string }
variable "channel" { type = string }
variable "tier" { type = string }
variable "deputyos_staging_dir" { type = string }
source "<builder>" "<name>" {
# ... source image, accelerator, output path
}
build {
sources = ["source.<builder>.<name>"]
provisioner "file" {
source = "${var.deputyos_staging_dir}/deputyctl"
destination = "/tmp/deputyctl"
}
# ... more file copies for limits.json, profiles, voice assets
provisioner "ansible" {
playbook_file = "packer/playbook.yml"
extra_arguments = [
"-e", "deputyos_hw=<hw>",
"-e", "deputyos_profile=${var.profile}",
"-e", "deputyos_channel=${var.channel}",
"-e", "deputyos_tier=${var.tier}",
]
}
post-processor "compress" { ... } # img.xz / qcow2 stay as-is
post-processor "shell-local" {
inline = ["sha256sum build/<output>.<format> > build/<output>.<format>.sha256"]
}
}
For QEMU targets the source is the Debian nocloud cloud image. For
Pi targets, packer-builder-arm chroot-mounts a Pi OS Lite image. For
cloud targets (digitalocean, oracle-arm-free), the builder is the
provider plugin; the output is a snapshot, not a local file.
Hermetic-build invariants
deputyOS aims at SLSA L3. The hermetic invariants today:
- Pinned base SHAs. Every Packer template pins
iso_url/source_imageby checksum. The Debiannocloudand Raspberry Pi OS Lite image SHAs are checked into the templates. - Pinned package versions. Critical packages
(
linux-image-rpi-2711,clamav,apparmor, …) lock to apt’s version-pinning shape via the role’sdefaults/main.yml. The base distro is Debian 12 stable, which is itself pin-shaped. - Pinned npm/pip versions. Every profile bake recipe uses
pinned_versionfrom the manifest. The release-tracker bot bumps it; humans don’t edit the venv install command. - Pinned voice asset URLs + SHAs.
scripts/build.shrecords them inbuild/staging/voice/MANIFESTand the role compares against the same record at install time. - Determinism markers.
SOURCE_DATE_EPOCHis honored where Debian tooling supports it. Pi images usepi-gen’s deterministic shape.
What’s not yet hermetic, by design:
freshclam— ClamAV’s signature DB pulls from a CDN at bake time. Best-effort; a fresh-DB-failed image is still functional (signatures update on first boot).apt update’s mirror selection. Debian’s archive is by date, but individual mirrors can lag.- Voice-asset CDN. Hugging Face + GitHub Releases serve the upstream whisper / Piper artefacts. SHA-pinning in MANIFEST gates the per-file integrity, but the URL itself is mutable.
Future work closes the remaining gaps with vendored mirrors for the unstable upstreams.
QEMU smoke harness
Smoke uses cloud-init seed files plus SSH-based assertions. The
shared scaffolding is test/smoke/_common.sh; per-target harnesses
(test/smoke/qemu-aarch64.sh, qemu-x86_64.sh, rpi5.sh) source
it.
SMOKE_LEVEL controls assertion depth:
scaffold— boot succeeds, kernel up, ssh reachable.m1(default) — wizard service active, gateway service active,deputyctl statusexits 0, healthz endpoint serves 200.full— adds extended assertions: cost ledger format, hooks dispatcher, apparmor enforce confirmation.
End-to-end timeline of make build TARGET=qemu-aarch64 PROFILE=khoj
make build TARGET=qemu-aarch64 PROFILE=khojscripts/build.shruns.cargo build --release --bin deputyctl→build/staging/deputyctl.cargo build --release --bin deputywizard→build/staging/deputywizard.cp profiles/khoj.toml build/staging/profiles/khoj.toml.- limits.json fallback (qemu-aarch64 has a Lane-A sample at
deputyctl/etc/limits.qemu-aarch64.json). - Voice assets: skipped if
DEPUTYOS_VOICE_OFFLINE=1or target is in no-voice set; otherwise downloaded. - Throwaway SSH keypair generated.
packer init packer/qemu-aarch64.pkr.hcl.packer validate ....packer build ...— boots the Debian cloud image under qemu, SSHs in, runs the role withdeputyos_hw=qemu-aarch64 deputyos_profile=khoj.- The role applies all 7 phases above. Khoj profile bake installs
khoj==1.32.0from PyPI (or the stub on failure). - Packer powers off, post-processes the qcow2.
- Output:
build/qemu-aarch64-khoj.qcow2plusbuild/qemu-aarch64-khoj.qcow2.sha256.
Total time: ~6-9 minutes on a recent Intel laptop.
Troubleshooting
packer buildhangs at ‘Waiting for SSH’ The cloud-init seed didn’t land or the throwaway SSH key wasn’t accepted. Checkbuild/staging/cloud-init/user-datais well-formed YAML and the pubkey ended up inusers.[0].ssh_authorized_keys.
Pi 4 build fails with ‘no binfmt_misc handler’
packer-builder-armneedsqemu-user-staticregistered. On Debian:sudo apt install qemu-user-static binfmt-support.make doctorflags this.
Iterate without re-baking For role-only changes, run the playbook directly against a booted dev VM:
ansible-playbook -i <ip>, packer/playbook.yml -e 'deputyos_hw=qemu-aarch64 deputyos_profile=khoj'. 30 seconds vs. 9 minutes.