#!/usr/bin/env bash # Turn a Docker image into a Firecracker rootfs, and prove a VM boots from it. # # Phase B4. Until now microVMs booted Firecracker's CI Ubuntu image with a # python guest agent bolted on: no git, no toolchain, no CLI. That is fine for # proving vsock works and useless for running a mission. # # Building FROM a Docker image rather than debootstrapping a new one is the # point. The per-CLI images (agent-claude / agent-kimi / agent-glm, per A6) are # already Dockerfiles with a tested env contract — CLI on PATH, credentials in # known places, toolchain present. Rebuilding that as a VM image by hand would # mean maintaining the same facts twice and discovering the drift in production. # `docker export` flattens the image to a tar; this wraps it in an ext4 and adds # the guest agent. # # Usage: # scripts/fc-build-rootfs.sh [out-name] [size] # scripts/fc-build-rootfs.sh osobh@tank clawmates/agent-base:dev agent-base 4G # # The result lands at $WORK/rootfs-.ext4 on the node. It is NOT made # the default: `vm_create` still boots `rootfs.ext4`, and repointing that is a # deliberate act (B4.3 adds per-mission image selection). set -uo pipefail WORK="${FC_WORK:-/opt/clawmates-fc}" FAILURES=0 pass() { printf 'PASS %s\n' "$*"; } fail() { printf 'FAIL %s\n' "$*"; FAILURES=$((FAILURES + 1)); } die() { printf 'ABORT %s\n' "$*" >&2; exit 2; } [ $# -ge 2 ] || die "usage: $0 [out-name] [size]" HOST="$1" IMAGE="$2" NAME="${3:-$(printf '%s' "$2" | tr '/:' '--')}" SIZE="${4:-4G}" OUT="$WORK/rootfs-$NAME.ext4" # The static musl guest agent, built on the node (see B4.2). # Single-quoted $HOME on purpose: it must expand on the NODE, not here. The # first version used double quotes and looked for the binary under this Mac's # home directory on a Linux host. AGENT_BIN=${FC_AGENT_BIN:-'$HOME/clawmates/target/x86_64-unknown-linux-musl/release/fcagent'} # The agent CLI this image is supposed to contain, probed inside the booted VM. # Derived from the out-name so `... clawmates/agent-claude:dev claude` checks # claude without being told twice; override with FC_CLI for anything else, or # set it empty to skip. The first image built on this track turned out to hold # git and nothing else, and the boot said nothing about it — the whole point of # building an image is the CLI inside, so the builder asks. case "${FC_CLI-unset}" in unset) case "$NAME" in # `glm` and `kimi` are both Claude Code pointed at another # provider's Anthropic-compatible endpoint, so the binary in the # image is `claude` for all three. Moonshot ship their own `kimi` # CLI, and this map used to expect it — a leftover from before the # endpoint was measured, which failed a perfectly good rootfs. # `local-ornith` joins them: Claude Code again, pointed at the # node's own Ollama. The binary in the image is `claude` for all # four; only ANTHROPIC_BASE_URL differs. claude|glm|kimi|local-ornith) FC_CLI="claude --version" ;; *) FC_CLI="" ;; esac ;; esac echo "── building $OUT on $HOST from $IMAGE ──" # 1. Flatten the image to a tar and unpack it into an ext4. # # `docker export` on a created (never started) container gives the filesystem # with none of the image's metadata — no ENV, no ENTRYPOINT, no WORKDIR. That # metadata matters: a CLI that relies on ENV PATH or ENV HOME would silently # behave differently in the VM. It is extracted separately below and written # into the guest's profile, rather than left to be discovered later. built=$(ssh "$HOST" " set -e cd '$WORK' docker image inspect '$IMAGE' >/dev/null 2>&1 || docker pull -q '$IMAGE' >/dev/null cid=\$(docker create '$IMAGE' /bin/true) trap 'docker rm -f \$cid >/dev/null 2>&1 || true' EXIT rm -f '$OUT' export.tar docker export \"\$cid\" -o export.tar echo \"export=\$(du -m export.tar | cut -f1)MB\" # The ext4 is created empty and filled via a mount rather than \`mkfs -d\`: # -d cannot handle device nodes or hard links that a container image may # contain, and fails late and cryptically when it hits one. truncate -s '$SIZE' '$OUT' mkfs.ext4 -q -F '$OUT' sudo mkdir -p /mnt/fcbuild sudo mount -o loop '$OUT' /mnt/fcbuild sudo tar -xf export.tar -C /mnt/fcbuild rm -f export.tar # Image metadata the export dropped. Written to /etc/profile.d so both a # login shell and the agent's \`sh -c\` see it. docker image inspect '$IMAGE' --format '{{range .Config.Env}}export {{.}} {{end}}' | sudo tee /mnt/fcbuild/etc/profile.d/00-image-env.sh >/dev/null sudo chmod 0644 /mnt/fcbuild/etc/profile.d/00-image-env.sh echo \"env_vars=\$(wc -l < /mnt/fcbuild/etc/profile.d/00-image-env.sh)\" " 2>&1) || { fail "could not build the filesystem: $(printf '%s' "$built" | tail -3)"; ssh "$HOST" "sudo umount /mnt/fcbuild 2>/dev/null; true"; exit 1; } pass "unpacked $IMAGE into $OUT ($(printf '%s' "$built" | tr '\n' ' '))" # 2. Install the guest agent and init. # # Not a systemd unit: a `docker export` rootfs usually has no init system at # all, and adding one to boot a single agent would be a large amount of surface # for no benefit. `init=` runs the agent as pid 1 directly — and pid 1 must # never exit, so the init script execs it rather than backgrounding it. The # agent mounts /proc, /sys, /dev and /tmp itself, so it works either way. agent=$(ssh "$HOST" " set -e # The guest agent is a STATIC musl binary, so it needs nothing from the image. # The python version could only run where python3 happened to be installed — # which is no image of ours — and an agent that dictates the image's contents # has the dependency backwards. test -x $AGENT_BIN || { echo NO-AGENT-BINARY; exit 1; } sudo install -m0755 $AGENT_BIN /mnt/fcbuild/usr/local/bin/fcagent printf '%s\\n' '#!/bin/sh' 'echo FC-GUEST-ALIVE kernel=\$(uname -r) cpus=\$(nproc)' \ 'exec /usr/local/bin/fcagent' | sudo tee /mnt/fcbuild/usr/local/bin/fcinit >/dev/null sudo chmod 0755 /mnt/fcbuild/usr/local/bin/fcinit sudo umount /mnt/fcbuild echo installed " 2>&1) case "$agent" in *installed*) pass "static guest agent installed (no image dependency)" ;; *NO-AGENT-BINARY*) fail "no agent binary at $AGENT_BIN on $HOST — build it: cargo build --release -p fcagent --target x86_64-unknown-linux-musl"; ssh "$HOST" "sudo umount /mnt/fcbuild 2>/dev/null; true" ;; *) fail "could not install the guest agent: $(printf '%s' "$agent" | tail -2)"; ssh "$HOST" "sudo umount /mnt/fcbuild 2>/dev/null; true" ;; esac # 3. Boot it. An image that builds and cannot boot is worse than no image, # because it looks finished. Everything below runs as the daemon user. if [ "$FAILURES" -eq 0 ]; then boot=$(ssh "$HOST" " cd '$WORK' cp --sparse=always '$OUT' probe-rootfs.ext4 cat > probe-vm.json < probe.log 2>&1 & PG=\$! for i in \$(seq 1 400); do grep -qa FC-AGENT-LISTENING probe.log && break; sleep 0.05; done python3 - <<'PY' 2>&1 | tail -6 import socket, struct, json def call(cmd): s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect('$WORK/probe.sock') s.sendall(b'CONNECT 9001\n'); s.recv(64) req = json.dumps({'op':'exec','cmd':cmd,'timeout':60}).encode() s.sendall(struct.pack('>I', len(req)) + req) n = struct.unpack('>I', s.recv(4))[0] buf = b'' while len(buf) < n: buf += s.recv(n - len(buf)) s.close() return json.loads(buf) # What a mission actually needs, asked of the image rather than assumed. probes = [('git', 'git --version'), ('shell-env', '. /etc/profile.d/00-image-env.sh 2>/dev/null; echo PATH=\$PATH'), ('write', 'mkdir -p /mission && echo ok > /mission/x && cat /mission/x')] cli = '''$FC_CLI''' if cli: probes.append(('cli', cli)) for label, cmd in probes: try: r = call(cmd) print('%s rc=%s %s' % (label, r.get('rc'), (r.get('stdout') or r.get('stderr') or '').strip()[:90])) except Exception as e: print('%s UNREACHABLE %s' % (label, e)) PY kill -- -\$PG 2>/dev/null rm -f probe.sock probe-rootfs.ext4 probe-vm.json " 2>&1) printf '%s\n' "$boot" | sed 's/^/ /' case "$boot" in *"git rc=0"*) pass "the built rootfs boots and has git" ;; *UNREACHABLE*) fail "the built rootfs booted but its agent was unreachable" ;; *) fail "the built rootfs did not provide git" ;; esac case "$boot" in *"write rc=0"*) pass "the guest can write to /mission" ;; *) fail "the guest could not write to /mission" ;; esac if [ -n "$FC_CLI" ]; then case "$boot" in *"cli rc=0"*) pass "the guest provides the agent CLI (\`$FC_CLI\`)" ;; *) fail "the guest does NOT provide \`$FC_CLI\` — this image has no agent to run" ;; esac else echo " (no agent CLI probed for out-name '$NAME' — set FC_CLI to check one)" fi fi echo if [ "$FAILURES" -gt 0 ]; then echo "$FAILURES check(s) failed" exit 1 fi ssh "$HOST" "ls -lh '$OUT' | awk '{print \$5\" \"\$9}'" echo "rootfs ready (not yet the default — vm_create still boots rootfs.ext4)"