feat(fleet): B3 — server-side microVM client over NodeHub

cm_api::microvm_client::MicroVm wraps the node's vm_* ops as typed calls
over the existing hub request/response channel: create / inject / exec /
collect / destroy, plus list() for reaping. No new transport.

Fixes a wire-contract mismatch B2 would have shipped. `Uplink::Result`
declares `output: String`, but the node's vm_* handler returned a JSON
object. The frame then failed to deserialize and hit the uplink match's
`Err(_) => {}` arm, so the reply VANISHED and every vm_* call would have
timed out after 20s with nothing anywhere explaining why. The node now
sends a string, matching the contract rather than what looked tidier.

That silent arm is fixed too: an unparseable frame now logs the node, the
parse error and the frame head, and says explicitly that the request it
was answering will time out. It is the arm that would have hidden this.

Two more places where a failure must not borrow a legitimate outcome's
representation:

  - vm_exec returning no `rc` is an error, not a zero. A missing exit code
    means the guest did not report one; reading it as success is how a
    failed command becomes a passing phase.
  - vm_collect on a missing path is an error, not an empty archive — an
    empty tar looks exactly like a run that produced nothing.

Timeouts: the hub's deadline is the guest's plus 30s, saturating. A
caller passing a huge budget would otherwise wrap to a tiny timeout and
turn a long agent turn into a spurious transport failure. clippy caught
the tautological assertion in the first version of that test, which is
what surfaced the overflow.

Verified: `--vm-selftest` on tank still 8/8 after the output-type change
(create 950ms), 427 tests green.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-05 07:59:13 -07:00
co-authored by Claude Opus 5
parent 22efb93775
commit 02ba557c3e
4 changed files with 320 additions and 6 deletions
+11 -5
View File
@@ -339,9 +339,15 @@ pub async fn list(vms: &Vms) -> Value {
json!({ "vms": held })
}
/// Dispatch a `vm_*` frame. Returns `(ok, output)` in the same shape every
/// other node op uses, so this needed no protocol change.
pub async fn handle_op(op: &str, v: &Value, vms: &Vms) -> (bool, Value) {
/// Dispatch a `vm_*` frame.
///
/// Returns `output` as a **String**, not a `Value`, because the server's
/// `Uplink::Result` declares `output: String`. Sending an object made the whole
/// result frame fail to deserialize, and the server's uplink match ends in
/// `Err(_) => {}` — so the reply vanished and the caller timed out after 20s
/// with nothing to explain why. The type had to match the wire contract, not
/// merely look tidier.
pub async fn handle_op(op: &str, v: &Value, vms: &Vms) -> (bool, String) {
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
let u = |k: &str, d: u64| v.get(k).and_then(Value::as_u64).unwrap_or(d);
let vm_id = s("vm_id");
@@ -362,8 +368,8 @@ pub async fn handle_op(op: &str, v: &Value, vms: &Vms) -> (bool, Value) {
// The guest reports its own `ok`, and a command that ran and failed is
// not a transport failure — the caller needs `rc` either way, so the
// frame is ok:true and the verdict lives in the payload.
Ok(out) => (true, out),
Err(e) => (false, json!({ "error": e })),
Ok(out) => (true, out.to_string()),
Err(e) => (false, json!({ "error": e }).to_string()),
}
}