Merge: B4.1 rootfs builder — and the finding that blocks B4.2

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-05 08:19:15 -07:00
co-authored by Claude Opus 5
+171
View File
@@ -0,0 +1,171 @@
#!/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 <ssh-host> <docker-image> [out-name] [size]
# scripts/fc-build-rootfs.sh osobh@tank clawmates/agent-base:dev agent-base 4G
#
# The result lands at $WORK/rootfs-<out-name>.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 <ssh-host> <docker-image> [out-name] [size]"
HOST="$1"
IMAGE="$2"
NAME="${3:-$(printf '%s' "$2" | tr '/:' '--')}"
SIZE="${4:-4G}"
OUT="$WORK/rootfs-$NAME.ext4"
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.
agent=$(ssh "$HOST" "
set -e
# python3 is how the agent is written; a rootfs without it cannot serve.
test -x /mnt/fcbuild/usr/bin/python3 || test -x /mnt/fcbuild/usr/local/bin/python3 \
|| { echo 'NO-PYTHON3'; exit 1; }
sudo cp /mnt/fcbuild/usr/local/bin/fcagent /tmp/.probe 2>/dev/null || true
# Reuse the agent already baked into the golden rootfs so there is ONE copy of
# this protocol on the node, not two that can drift.
sudo mkdir -p /mnt/fcgolden && sudo mount -o loop,ro '$WORK/rootfs.ext4' /mnt/fcgolden
sudo cp /mnt/fcgolden/usr/local/bin/fcagent /mnt/fcbuild/usr/local/bin/fcagent
sudo cp /mnt/fcgolden/usr/local/bin/fcinit /mnt/fcbuild/usr/local/bin/fcinit
sudo umount /mnt/fcgolden && sudo rmdir /mnt/fcgolden
sudo chmod 0755 /mnt/fcbuild/usr/local/bin/fcagent /mnt/fcbuild/usr/local/bin/fcinit
sudo umount /mnt/fcbuild
echo installed
" 2>&1)
case "$agent" in
*installed*) pass "guest agent installed (shared with the golden rootfs)" ;;
*NO-PYTHON3*) fail "$IMAGE has no python3 — the guest agent cannot run in it"; 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 <<JSON
{
\"boot-source\": {
\"kernel_image_path\": \"$WORK/vmlinux\",
\"boot_args\": \"console=ttyS0 reboot=k panic=1 pci=off init=/usr/local/bin/fcinit\"
},
\"drives\": [{\"drive_id\":\"rootfs\",\"path_on_host\":\"$WORK/probe-rootfs.ext4\",\"is_root_device\":true,\"is_read_only\":false}],
\"machine-config\": {\"vcpu_count\":2,\"mem_size_mib\":2048,\"smt\":false},
\"vsock\": {\"guest_cid\": 3, \"uds_path\": \"$WORK/probe.sock\"}
}
JSON
rm -f probe.sock probe.log
setsid timeout 40 firecracker --no-api --config-file 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.
for label, cmd in [('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')]:
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
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)"