feat(fleet): B4.2 — static Rust guest agent replaces the python one

The python guest agent only ever worked because Firecracker's CI Ubuntu
image happens to ship python3. NONE of our images do — agent-base has
neither python nor git, agent-terminal has git but no python — so it
could never have run in a real mission rootfs. An agent that dictates
what must be installed in the image has the dependency backwards.

crates/bins/fcagent is a 905K static x86_64-unknown-linux-musl binary
that needs nothing from the rootfs it is dropped into. The wire is
unchanged on purpose — 4-byte BE length + JSON, ops ping/exec/put/get —
so microvm.rs and microvm_client.rs needed no edit at all.

std has no AF_VSOCK and the workspace denies `unsafe`, so it uses the
`vsock` crate. `process_group(0)` gives each command its own group without
unsafe, so a command that spawns background children can be killed
wholesale rather than outliving the run.

A unit test caught a bug that would have broken EVERY exec: sourcing the
image-env file with `. env.sh 2>/dev/null; cmd` returns rc=1 WITHOUT
running cmd, because `.` on a missing file makes a non-interactive POSIX
shell exit immediately. On any rootfs lacking that file every command
would have failed while looking like an ordinary non-zero exit. Guarded
with `if [ -f ]` now.

Other places a failure must not borrow an outcome's representation: a
killed command reports ok:false with no rc (not rc=124, which would read
as a build failure); `get` on a missing path is an error, not an empty
archive; a signalled process reports 128+signal rather than success.

Verified on tank: --vm-selftest still 8/8 with the agent swapped
(create 949ms, wire identical), fc-node-setup 8/8, and — the point of the
change — a rootfs built from clawmates/agent-terminal:dev, which has NO
python3, boots and reports `git version 2.39.5` from inside the VM.

Also fixes a shell bug in fc-build-rootfs.sh: $HOME in a double-quoted
default expanded on this Mac, so it looked for the node's binary under
/Users/quantum on a Linux host.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-05 10:02:05 -07:00
co-authored by Claude Opus 5
parent 78da62f156
commit 08847e6a63
6 changed files with 483 additions and 98 deletions
Generated
+42
View File
@@ -1843,6 +1843,16 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fcagent"
version = "0.1.0"
dependencies = [
"base64",
"serde_json",
"tar",
"vsock",
]
[[package]] [[package]]
name = "ff" name = "ff"
version = "0.13.1" version = "0.13.1"
@@ -2874,6 +2884,15 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -2964,6 +2983,19 @@ dependencies = [
"pin-utils", "pin-utils",
] ]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset 0.9.1",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -5762,6 +5794,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsock"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba782755fc073877e567c2253c0be48e4aa9a254c232d36d3985dfae0bd5205"
dependencies = [
"libc",
"nix 0.31.3",
]
[[package]] [[package]]
name = "wait-timeout" name = "wait-timeout"
version = "0.2.1" version = "0.2.1"
+1
View File
@@ -23,6 +23,7 @@ members = [
"crates/bins/clawmates-server", "crates/bins/clawmates-server",
"crates/bins/clawmates-broker", "crates/bins/clawmates-broker",
"crates/bins/clawmates-node", "crates/bins/clawmates-node",
"crates/bins/fcagent",
"tools/bundler", "tools/bundler",
] ]
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "fcagent"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[[bin]]
name = "fcagent"
path = "src/main.rs"
[dependencies]
# std has no AF_VSOCK, and the workspace denies `unsafe`, so raw libc is not an
# option. This is a safe wrapper over the socket calls.
vsock = "0.5"
serde_json = { workspace = true }
tar = { workspace = true }
base64 = "0.22"
# NOTE: a `[profile.release]` here would be silently ignored — cargo only honours
# profiles at the workspace root. The binary is small enough on the default
# release profile (~1 MB static) that overriding the whole workspace's profile to
# shave it would be a bad trade.
[lints]
workspace = true
+377
View File
@@ -0,0 +1,377 @@
//! ClawMates microVM guest agent — pid 1 inside a Firecracker microVM.
//!
//! Runs as `init=/usr/local/bin/fcagent`'s exec target and answers the host over
//! **vsock** (port 9001), never the serial console: feeding a guest over stdin
//! races its startup and arrives half-consumed. The console stays a log.
//!
//! # Why this is a static Rust binary and not the python script it replaces
//!
//! The python version worked only because Firecracker's CI Ubuntu image happens
//! to ship python3. **None of our own images do** — `agent-base` has neither
//! python nor git, `agent-terminal` has git but no python — so the agent could
//! never have run in a real mission rootfs. An agent that dictates what must be
//! installed in the image has the dependency backwards. This is a
//! `x86_64-unknown-linux-musl` static binary: it needs nothing from the rootfs
//! it is dropped into.
//!
//! # Wire protocol (unchanged from the python agent, deliberately)
//!
//! One request per connection: a 4-byte big-endian length followed by JSON, and
//! the reply framed the same way. The length prefix is the point — a reply
//! larger than a socket buffer arrives in pieces, and reading "whatever was
//! available" would parse a truncated object as a complete one.
//!
//! Ops: `ping`, `exec`, `put`, `get`. `crates/bins/clawmates-node/src/microvm.rs`
//! and `crates/cm-api/src/microvm_client.rs` speak this and needed no change.
use std::io::{Read, Write};
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use base64::Engine;
use serde_json::{json, Value};
const PORT: u32 = 9001;
/// Cap on a single request. A hostile or broken host must not be able to make
/// pid 1 allocate without bound and get the VM OOM-killed.
const MAX_REQUEST: u32 = 512 * 1024 * 1024;
const B64: base64::engine::general_purpose::GeneralPurpose =
base64::engine::general_purpose::STANDARD;
fn main() {
// The mounts the init script would otherwise do. Done here so the agent
// works whether it is exec'd from a shell init or used as `init=` directly:
// /proc missing makes every process-inspecting tool in the guest lie.
for (fstype, target) in [
("proc", "/proc"),
("sysfs", "/sys"),
("devtmpfs", "/dev"),
("tmpfs", "/tmp"),
] {
if !Path::new(target).join(".").exists() {
let _ = std::fs::create_dir_all(target);
}
let _ = Command::new("mount")
.args(["-t", fstype, fstype, target])
.status();
}
let listener = match vsock::VsockListener::bind_with_cid_port(libc_vmaddr_cid_any(), PORT) {
Ok(l) => l,
Err(e) => {
// Printed to the console, which is where the host's boot check
// looks. Exiting pid 1 panics the kernel, which is the honest
// outcome: a VM whose agent cannot listen is unusable, and it must
// not sit there looking booted.
eprintln!("FC-AGENT-FATAL could not bind vsock port {PORT}: {e}");
std::process::exit(1);
}
};
// The host greps the console for this before it tries to connect.
println!("FC-AGENT-LISTENING port={PORT}");
let _ = std::io::stdout().flush();
for conn in listener.incoming() {
match conn {
Ok(mut s) => {
if let Err(e) = serve_one(&mut s) {
// A bad request must never kill the agent — the VM would
// look booted and answer nothing, the worst of both.
eprintln!("FC-AGENT-ERROR {e}");
}
}
Err(e) => eprintln!("FC-AGENT-ERROR accept: {e}"),
}
}
}
/// `VMADDR_CID_ANY` — bind for any host CID.
fn libc_vmaddr_cid_any() -> u32 {
u32::MAX
}
fn serve_one(s: &mut vsock::VsockStream) -> Result<(), String> {
let mut len = [0u8; 4];
s.read_exact(&mut len)
.map_err(|e| format!("read length: {e}"))?;
let len = u32::from_be_bytes(len);
if len > MAX_REQUEST {
// Answer rather than hang up: a caller that sent something absurd needs
// to be told, not left waiting for a reply that will never come.
return reply(s, &json!({ "ok": false, "error": format!("request of {len} bytes exceeds the {MAX_REQUEST} cap") }));
}
let mut buf = vec![0u8; len as usize];
s.read_exact(&mut buf)
.map_err(|e| format!("read body: {e}"))?;
let resp = match serde_json::from_slice::<Value>(&buf) {
Ok(req) => handle(&req),
Err(e) => json!({ "ok": false, "error": format!("undecodable request: {e}") }),
};
reply(s, &resp)
}
fn reply(s: &mut vsock::VsockStream, v: &Value) -> Result<(), String> {
let body = serde_json::to_vec(v).map_err(|e| format!("encode reply: {e}"))?;
s.write_all(&(body.len() as u32).to_be_bytes())
.map_err(|e| format!("write length: {e}"))?;
s.write_all(&body)
.map_err(|e| format!("write body: {e}"))?;
s.flush().map_err(|e| format!("flush: {e}"))
}
fn handle(req: &Value) -> Value {
let op = req.get("op").and_then(Value::as_str).unwrap_or_default();
match op {
"ping" => json!({ "ok": true, "pid": std::process::id() }),
"exec" => op_exec(req),
"put" => op_put(req),
"get" => op_get(req),
other => json!({ "ok": false, "error": format!("unknown op: {other}") }),
}
}
fn op_exec(req: &Value) -> Value {
let cmd = req.get("cmd").and_then(Value::as_str).unwrap_or_default();
if cmd.is_empty() {
return json!({ "ok": false, "error": "exec needs a cmd" });
}
let cwd = req.get("cwd").and_then(Value::as_str).unwrap_or("/");
let secs = req.get("timeout").and_then(Value::as_u64).unwrap_or(3600);
// The image's ENV was written to /etc/profile.d by the rootfs builder;
// `sh -c` does not read it, so source it here — otherwise a CLI that relies
// on `ENV PATH` behaves differently in the VM than in the container, which
// is exactly the drift the builder extracted that file to prevent.
//
// The `if [ -f ]` guard is load-bearing. `. missing-file` makes a
// NON-INTERACTIVE POSIX shell exit immediately with status 1, so the naive
// `. env.sh 2>/dev/null; cmd` returned rc=1 without running `cmd` at all on
// any rootfs lacking that file — every exec silently failing while looking
// like an ordinary non-zero exit. Caught by the exit-7 unit test.
const ENV_FILE: &str = "/etc/profile.d/00-image-env.sh";
let sourced = format!("if [ -f {ENV_FILE} ]; then . {ENV_FILE}; fi\n{cmd}");
let mut c = Command::new("/bin/sh");
c.arg("-c")
.arg(&sourced)
.current_dir(if Path::new(cwd).is_dir() { cwd } else { "/" })
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// A new process group so a command that spawns background children can
// be killed wholesale. Without it a stray daemon keeps the run alive and
// the host's timeout is the only thing that ends it.
.process_group(0);
let mut child = match c.spawn() {
Ok(ch) => ch,
Err(e) => return json!({ "ok": false, "error": format!("spawn: {e}") }),
};
let pid = child.id() as i32;
// std has no wait-with-timeout, so poll. The output pipes are read after
// the wait, which is safe here because a command producing more than a pipe
// buffer of output while we are not draining it would deadlock — so the
// deadline is enforced by killing the group, and the pipes are drained by
// `wait_with_output` immediately after.
let deadline = Instant::now() + Duration::from_secs(secs);
let timed_out = loop {
match child.try_wait() {
Ok(Some(_)) => break false,
Ok(None) => {}
Err(e) => return json!({ "ok": false, "error": format!("wait: {e}") }),
}
if Instant::now() >= deadline {
kill_group(pid);
break true;
}
std::thread::sleep(Duration::from_millis(20));
};
let out = match child.wait_with_output() {
Ok(o) => o,
Err(e) => return json!({ "ok": false, "error": format!("collect output: {e}") }),
};
if timed_out {
// Reported as ok:false, not as rc=124: "we stopped it" is a different
// fact from "it exited non-zero", and the caller must be able to tell.
return json!({
"ok": false,
"error": format!("command exceeded its {secs}s budget and was killed"),
"stdout": String::from_utf8_lossy(&out.stdout),
"stderr": String::from_utf8_lossy(&out.stderr),
});
}
json!({
"ok": true,
// A signalled process has no exit code; report the conventional
// 128+signal rather than silently claiming success.
"rc": exit_code(&out.status),
"stdout": String::from_utf8_lossy(&out.stdout),
"stderr": String::from_utf8_lossy(&out.stderr),
})
}
fn exit_code(status: &std::process::ExitStatus) -> i32 {
use std::os::unix::process::ExitStatusExt;
status
.code()
.unwrap_or_else(|| 128 + status.signal().unwrap_or(0))
}
fn kill_group(pid: i32) {
let _ = Command::new("kill")
.args(["-9", "--", &format!("-{pid}")])
.status();
}
fn op_put(req: &Value) -> Value {
let dest = req.get("dest").and_then(Value::as_str).unwrap_or_default();
if dest.is_empty() {
return json!({ "ok": false, "error": "put needs a dest" });
}
let b64 = req.get("tar_b64").and_then(Value::as_str).unwrap_or_default();
let raw = match B64.decode(b64) {
Ok(r) => r,
Err(e) => return json!({ "ok": false, "error": format!("undecodable archive: {e}") }),
};
if let Err(e) = std::fs::create_dir_all(dest) {
return json!({ "ok": false, "error": format!("mkdir {dest}: {e}") });
}
let mut ar = tar::Archive::new(&raw[..]);
ar.set_overwrite(true);
// Ownership from the host archive is meaningless in here and re-applying it
// is how the container path grew a uid split. The guest is root; let it own
// what it is given.
ar.set_preserve_permissions(false);
match ar.unpack(dest) {
Ok(()) => json!({ "ok": true, "dest": dest, "bytes": raw.len() }),
Err(e) => json!({ "ok": false, "error": format!("unpack into {dest}: {e}") }),
}
}
fn op_get(req: &Value) -> Value {
let path = req.get("path").and_then(Value::as_str).unwrap_or_default();
if path.is_empty() {
return json!({ "ok": false, "error": "get needs a path" });
}
let p = Path::new(path);
if !p.exists() {
// A missing path is an error, NOT an empty archive — an empty tar looks
// exactly like a run that produced nothing.
return json!({ "ok": false, "error": format!("no such path: {path}") });
}
let name = p
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "root".to_string());
let mut b = tar::Builder::new(Vec::new());
// Do not follow symlinks: a link pointing outside the collected tree would
// otherwise be dereferenced and its target smuggled back to the host.
b.follow_symlinks(false);
let added = if p.is_dir() {
b.append_dir_all(&name, p)
} else {
b.append_path_with_name(p, &name)
};
if let Err(e) = added {
return json!({ "ok": false, "error": format!("archive {path}: {e}") });
}
match b.into_inner() {
Ok(bytes) => json!({ "ok": true, "tar_b64": B64.encode(&bytes), "bytes": bytes.len() }),
Err(e) => json!({ "ok": false, "error": format!("finish archive for {path}: {e}") }),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unknown_op_is_reported_not_ignored() {
let r = handle(&json!({ "op": "teleport" }));
assert_eq!(r["ok"], json!(false));
assert!(r["error"].as_str().unwrap().contains("teleport"));
}
#[test]
fn ping_answers() {
assert_eq!(handle(&json!({ "op": "ping" }))["ok"], json!(true));
}
/// A missing path must be an error, not an empty archive: an empty tar is
/// indistinguishable from a run that produced nothing.
#[test]
fn getting_a_missing_path_is_an_error() {
let r = op_get(&json!({ "op": "get", "path": "/definitely/not/here" }));
assert_eq!(r["ok"], json!(false));
assert!(r["tar_b64"].is_null(), "no archive may be returned");
}
/// A command that ran and failed reports `rc`; one we killed reports
/// `ok:false`. Collapsing the two would make a timeout look like a build
/// failure and vice versa.
#[test]
fn a_failing_command_reports_rc_and_a_killed_one_does_not() {
let r = op_exec(&json!({ "op": "exec", "cmd": "exit 7", "timeout": 30 }));
assert_eq!(r["ok"], json!(true), "it ran, so ok is true");
assert_eq!(r["rc"], json!(7));
let r = op_exec(&json!({ "op": "exec", "cmd": "sleep 30", "timeout": 1 }));
assert_eq!(r["ok"], json!(false), "we killed it, so ok is false");
assert!(r["rc"].is_null(), "a killed command has no exit code");
assert!(r["error"].as_str().unwrap().contains("budget"));
}
#[test]
fn exec_needs_a_command() {
assert_eq!(op_exec(&json!({ "op": "exec" }))["ok"], json!(false));
}
/// A tar must round-trip through put and get.
#[test]
fn a_tar_round_trips_through_put_and_get() {
let tmp = std::env::temp_dir().join(format!("fcagent-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let mut b = tar::Builder::new(Vec::new());
let body = b"ROUND-TRIP-OK\n";
let mut h = tar::Header::new_gnu();
h.set_path("marker.txt").unwrap();
h.set_size(body.len() as u64);
h.set_mode(0o644);
h.set_entry_type(tar::EntryType::Regular);
h.set_cksum();
b.append(&h, &body[..]).unwrap();
let archive = b.into_inner().unwrap();
let r = op_put(&json!({
"op": "put",
"dest": tmp.display().to_string(),
"tar_b64": B64.encode(&archive),
}));
assert_eq!(r["ok"], json!(true), "put failed: {r}");
assert_eq!(
std::fs::read_to_string(tmp.join("marker.txt")).unwrap(),
"ROUND-TRIP-OK\n"
);
let r = op_get(&json!({ "op": "get", "path": tmp.display().to_string() }));
assert_eq!(r["ok"], json!(true), "get failed: {r}");
let bytes = B64.decode(r["tar_b64"].as_str().unwrap()).unwrap();
let mut ar = tar::Archive::new(&bytes[..]);
let found = ar
.entries()
.unwrap()
.filter_map(Result::ok)
.any(|e| e.path().map(|p| p.ends_with("marker.txt")).unwrap_or(false));
assert!(found, "the collected archive must contain marker.txt");
let _ = std::fs::remove_dir_all(&tmp);
}
}
+18 -14
View File
@@ -35,6 +35,11 @@ IMAGE="$2"
NAME="${3:-$(printf '%s' "$2" | tr '/:' '--')}" NAME="${3:-$(printf '%s' "$2" | tr '/:' '--')}"
SIZE="${4:-4G}" SIZE="${4:-4G}"
OUT="$WORK/rootfs-$NAME.ext4" 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'}
echo "── building $OUT on $HOST from $IMAGE ──" echo "── building $OUT on $HOST from $IMAGE ──"
@@ -79,26 +84,25 @@ pass "unpacked $IMAGE into $OUT ($(printf '%s' "$built" | tr '\n' ' '))"
# Not a systemd unit: a `docker export` rootfs usually has no init system at # 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 # 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 # 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. # 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" " agent=$(ssh "$HOST" "
set -e set -e
# python3 is how the agent is written; a rootfs without it cannot serve. # The guest agent is a STATIC musl binary, so it needs nothing from the image.
test -x /mnt/fcbuild/usr/bin/python3 || test -x /mnt/fcbuild/usr/local/bin/python3 \ # The python version could only run where python3 happened to be installed —
|| { echo 'NO-PYTHON3'; exit 1; } # which is no image of ours — and an agent that dictates the image's contents
sudo cp /mnt/fcbuild/usr/local/bin/fcagent /tmp/.probe 2>/dev/null || true # has the dependency backwards.
# Reuse the agent already baked into the golden rootfs so there is ONE copy of test -x $AGENT_BIN || { echo NO-AGENT-BINARY; exit 1; }
# this protocol on the node, not two that can drift. sudo install -m0755 $AGENT_BIN /mnt/fcbuild/usr/local/bin/fcagent
sudo mkdir -p /mnt/fcgolden && sudo mount -o loop,ro '$WORK/rootfs.ext4' /mnt/fcgolden printf '%s\\n' '#!/bin/sh' 'echo FC-GUEST-ALIVE kernel=\$(uname -r) cpus=\$(nproc)' \
sudo cp /mnt/fcgolden/usr/local/bin/fcagent /mnt/fcbuild/usr/local/bin/fcagent 'exec /usr/local/bin/fcagent' | sudo tee /mnt/fcbuild/usr/local/bin/fcinit >/dev/null
sudo cp /mnt/fcgolden/usr/local/bin/fcinit /mnt/fcbuild/usr/local/bin/fcinit sudo chmod 0755 /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 sudo umount /mnt/fcbuild
echo installed echo installed
" 2>&1) " 2>&1)
case "$agent" in case "$agent" in
*installed*) pass "guest agent installed (shared with the golden rootfs)" ;; *installed*) pass "static guest agent installed (no image dependency)" ;;
*NO-PYTHON3*) fail "$IMAGE has no python3 — the guest agent cannot run in it"; ssh "$HOST" "sudo umount /mnt/fcbuild 2>/dev/null; true" ;; *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" ;; *) fail "could not install the guest agent: $(printf '%s' "$agent" | tail -2)"; ssh "$HOST" "sudo umount /mnt/fcbuild 2>/dev/null; true" ;;
esac esac
+18 -84
View File
@@ -27,6 +27,8 @@ ROOTFS="${FC_ROOTFS:-ubuntu-24.04.squashfs}"
WORK="${FC_WORK:-/opt/clawmates-fc}" WORK="${FC_WORK:-/opt/clawmates-fc}"
FAILURES=0 FAILURES=0
# Single-quoted $HOME: it must expand on the NODE, not here.
AGENT_BIN=${FC_AGENT_BIN:-'$HOME/clawmates/target/x86_64-unknown-linux-musl/release/fcagent'}
pass() { printf 'PASS %-14s %s\n' "$1" "$2"; } pass() { printf 'PASS %-14s %s\n' "$1" "$2"; }
fail() { printf 'FAIL %-14s %s\n' "$1" "$2"; FAILURES=$((FAILURES + 1)); } fail() { printf 'FAIL %-14s %s\n' "$1" "$2"; FAILURES=$((FAILURES + 1)); }
@@ -96,98 +98,30 @@ for host in "$@"; do
" >/dev/null 2>&1 || { fail "$host" "could not stage kernel/rootfs"; continue; } " >/dev/null 2>&1 || { fail "$host" "could not stage kernel/rootfs"; continue; }
pass "$host" "kernel + rootfs staged in $WORK" pass "$host" "kernel + rootfs staged in $WORK"
# 4. Bake the guest agent into the shared rootfs. # 4. Install the static guest agent into the shared rootfs.
# #
# It has to be baked rather than injected per VM: mounting an ext4 image to # A loop mount needs root, and the node daemon deliberately runs as an
# write into it needs root, and the node daemon deliberately runs as an # ordinary user, so this is the one place root is available.
# ordinary user (in the kvm group). Baking once at setup time is the only
# place root is available, so the per-VM path stays unprivileged.
# #
# Control is length-prefixed JSON over vsock, NOT the serial console — see # The agent is a STATIC musl binary (crates/bins/fcagent). It used to be a
# the spike: stdin races the guest's startup and arrives half-consumed. # python script, which only worked because Firecracker's CI Ubuntu image
# happens to ship python3 — no image of ours does, so it could never have run
# in a real mission rootfs. An agent that dictates what must be installed in
# the image has the dependency backwards.
#
# Control is length-prefixed JSON over vsock, NOT the serial console: stdin
# races the guest's startup and arrives half-consumed.
ssh "$host" " ssh "$host" "
set -e set -e
cd '$WORK' cd '$WORK'
test -x $AGENT_BIN || { echo NO-AGENT-BINARY; exit 1; }
sudo mkdir -p /mnt/fcroot && sudo mount -o loop rootfs.ext4 /mnt/fcroot sudo mkdir -p /mnt/fcroot && sudo mount -o loop rootfs.ext4 /mnt/fcroot
sudo tee /mnt/fcroot/usr/local/bin/fcagent >/dev/null <<'PY' sudo install -m0755 $AGENT_BIN /mnt/fcroot/usr/local/bin/fcagent
#!/usr/bin/env python3 printf '%s\\n' '#!/bin/sh' 'echo FC-GUEST-ALIVE kernel=\$(uname -r) cpus=\$(nproc)' \
# ClawMates microVM guest agent. One request per frame, framed as a 4-byte 'exec /usr/local/bin/fcagent' | sudo tee /mnt/fcroot/usr/local/bin/fcinit >/dev/null
# big-endian length followed by JSON, so a reply larger than a socket buffer
# cannot be mistaken for a complete one.
import socket, struct, subprocess, json, os, base64, tarfile, io, sys
def recv_exact(c, n):
buf = b''
while len(buf) < n:
chunk = c.recv(n - len(buf))
if not chunk:
return None
buf += chunk
return buf
def handle(req):
op = req.get('op')
if op == 'ping':
return {'ok': True, 'pid': os.getpid()}
if op == 'exec':
# Setsid so background children join a new process group the host can
# kill wholesale; without it a stray daemon keeps the run alive forever.
p = subprocess.run(req['cmd'], shell=True, capture_output=True, text=True,
cwd=req.get('cwd') or '/', timeout=req.get('timeout', 3600),
preexec_fn=os.setsid)
return {'ok': True, 'rc': p.returncode, 'stdout': p.stdout, 'stderr': p.stderr}
if op == 'put':
# A tar, not a raw file: it carries directories, modes and multiple
# entries, and is the same shape the mission checkout already travels in.
raw = base64.b64decode(req['tar_b64'])
dest = req['dest']
os.makedirs(dest, exist_ok=True)
with tarfile.open(fileobj=io.BytesIO(raw)) as t:
t.extractall(dest)
return {'ok': True, 'dest': dest}
if op == 'get':
src = req['path']
if not os.path.exists(src):
return {'ok': False, 'error': 'no such path: ' + src}
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode='w') as t:
t.add(src, arcname=os.path.basename(src.rstrip('/')))
return {'ok': True, 'tar_b64': base64.b64encode(buf.getvalue()).decode()}
return {'ok': False, 'error': 'unknown op: ' + str(op)}
s = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM)
s.bind((socket.VMADDR_CID_ANY, 9001))
s.listen(8)
print('FC-AGENT-LISTENING', flush=True)
while True:
try:
conn, _ = s.accept()
hdr = recv_exact(conn, 4)
if hdr:
body = recv_exact(conn, struct.unpack('>I', hdr)[0])
try:
resp = handle(json.loads(body))
except Exception as e:
resp = {'ok': False, 'error': '%s: %s' % (type(e).__name__, e)}
out = json.dumps(resp).encode()
conn.sendall(struct.pack('>I', len(out)) + out)
conn.close()
except Exception as e:
# A bad request must never kill the agent — the VM would look booted
# and answer nothing, the worst of both outcomes.
print('FC-AGENT-ERROR', e, file=sys.stderr, flush=True)
PY
sudo chmod 0755 /mnt/fcroot/usr/local/bin/fcagent
printf '%s\n' '#!/bin/sh' \\
'mount -t proc proc /proc 2>/dev/null' \\
'mount -t sysfs sys /sys 2>/dev/null' \\
'mount -t devtmpfs dev /dev 2>/dev/null' \\
'echo FC-GUEST-ALIVE kernel=\$(uname -r) cpus=\$(nproc)' \\
'exec /usr/local/bin/fcagent' \\
| sudo tee /mnt/fcroot/usr/local/bin/fcinit >/dev/null
sudo chmod 0755 /mnt/fcroot/usr/local/bin/fcinit sudo chmod 0755 /mnt/fcroot/usr/local/bin/fcinit
sudo umount /mnt/fcroot sudo umount /mnt/fcroot
" >/dev/null 2>&1 || { fail "$host" "could not bake the guest agent into rootfs"; continue; } " >/dev/null 2>&1 || { fail "$host" "could not install the guest agent (build it: cargo build --release -p fcagent --target x86_64-unknown-linux-musl)"; continue; }
pass "$host" "guest agent baked into rootfs" pass "$host" "guest agent baked into rootfs"
# The daemon copies the shared rootfs per VM, so it must be readable by the # The daemon copies the shared rootfs per VM, so it must be readable by the