Files
clawmates/crates/cm-api/src/fleet.rs
T
Omar Sobh 0b89b8316c feat(observability): stream a microVM turn's stdout/stderr to the platform live
The Live tab showed nothing while a turn ran, and the agent's own account of it
went to stderr on the node and nowhere a user could reach. This is the path that
carries it.

The blocker was the guest agent. `fcagent` handled one connection at a time,
inline, so during an hour-long turn the VM accepted nothing — which is why every
existing probe (subagents, stop-gate blocks, cap) runs AFTER the turn rather than
during it. It now spawns a thread per connection, wrapped in `catch_unwind`
because this process is pid 1: a panic used to take the accept loop with it, and
an unbootable VM is a far worse outcome than a missing log. A failed spawn logs
and keeps accepting rather than dropping the listener.

PROVED against a live VM before building on it, since "sound reasoning about this
system" and "measurement" have diverged repeatedly today. Patched rootfs, booted
under Firecracker, ran an 8s exec and a concurrent tail:

    exec took 8.0s ok=True
    +0.0s 'line1\nline2\n'  +1.2s 'line4\n'  +3.2s 'line6\n'  +6.0s 'DONE\n'
    VERDICT: CONCURRENT — tail returned data before exec finished

The rest is the pattern the terminal already uses. New `tail` op streams a file
by OFFSET (so a dropped link resumes instead of replaying, and the tail always
terminates — one that never returns pins a thread for the life of the VM). The
node follows the log alongside the turn and pushes `Uplink::VmOut { run_id, at,
data }` over the WebSocket it already holds, mirroring `PtyOut`. The server does
what `PtyOut` deliberately does not: it APPENDS to the run's checkpoint as well
as fanning out, because a terminal has no history worth keeping and a mission log
is the record of what the agent did. `run_events_sse` emits the new bytes as
`step` events, which the live pane already renders — no frontend change.

The turn is `tee`d, not redirected: the file feeds the live stream and stdout
still becomes `VmOutcome::summary`. A redirect would have produced a live view
and an empty summary, which is the same green-and-empty shape as the bug this
fixes. Tested, along with the log living outside the collected tree so it never
lands in a user's delivered diff.

246 lib tests, 20 binaries; node and fcagent build clean.
2026-08-07 21:07:12 -07:00

784 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Fleet control plane: an in-memory hub of live daemon control channels plus
//! the WebSocket channel runner. Each connected `clawmates-node` daemon dials
//! `GET /api/nodes/agent?token=…` (outbound), and we drive that socket to
//! receive host-health heartbeats and to run commands on the node.
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::extract::ws::{Message, WebSocket};
use base64::Engine;
use cm_db::repo::nodes::{self, NodeHealth};
use cm_domain::NodeId;
use cm_sandbox::{
ExecResult, ManagedSandbox, PtySession, SandboxDriver, SandboxError, SandboxHandle, SandboxSpec,
};
use futures::{SinkExt, StreamExt};
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::PgPool;
use tokio::sync::{mpsc, oneshot, Mutex};
const B64: base64::engine::general_purpose::GeneralPurpose =
base64::engine::general_purpose::STANDARD;
/// Monotonic per-connection id. A reconnecting daemon gets a fresh epoch so a
/// stale channel's teardown can't clobber the newer connection's online status.
static CONN_EPOCH: AtomicU64 = AtomicU64::new(1);
/// Server keepalive: ping the daemon this often; if no inbound frame (incl. the
/// matching pong) arrives within the deadline, treat the socket as dead.
const PING_EVERY: Duration = Duration::from_secs(15);
const IDLE_DEADLINE: Duration = Duration::from_secs(35);
/// Backstop sweeper: flip any node whose heartbeat stopped (without a clean
/// channel close) to offline. Heartbeats refresh `last_seen`, so a live node
/// (5s heartbeat) is never older than the window and is never swept; a vanished
/// node goes offline within `stale_secs` + one tick even if its channel hangs.
pub fn spawn_node_sweeper(pool: PgPool, interval: Duration, stale_secs: i64) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(interval);
loop {
tick.tick().await;
if let Err(e) = nodes::mark_stale_offline(&pool, stale_secs).await {
eprintln!("node_sweeper: mark_stale_offline failed: {e}");
}
}
});
}
/// The result of running a command on a node.
#[derive(Debug, Clone)]
pub struct ExecOutput {
pub ok: bool,
pub output: String,
}
struct NodeConn {
tx: mpsc::UnboundedSender<String>,
pending: Mutex<HashMap<u64, oneshot::Sender<ExecOutput>>>,
/// Live terminal sessions: sid → byte sink for the browser bridge (WS-relay
/// PTY output). Bounded: a runaway PTY (say `cat /var/log/huge`) with a
/// stalled browser must not accumulate megabytes here. On overflow the
/// session is closed instead of holding output indefinitely.
pty_sinks: Mutex<HashMap<u64, mpsc::Sender<Vec<u8>>>>,
/// WebRTC signaling: sid → text sink delivering the daemon's answer/ICE to
/// the browser bridge.
signal_sinks: Mutex<HashMap<u64, mpsc::UnboundedSender<String>>>,
next_id: AtomicU64,
/// Unique per physical connection — see `CONN_EPOCH`.
epoch: u64,
}
/// Registry of live daemon channels, keyed by node id.
#[derive(Default)]
pub struct NodeHub {
conns: Mutex<HashMap<NodeId, Arc<NodeConn>>>,
/// Sync-readable set of connected node ids (so placement can check liveness
/// without an await — `conns` is behind an async mutex).
online: std::sync::Mutex<HashSet<NodeId>>,
/// Short-lived single-use terminal tickets (browser WS can't send a bearer).
tickets: Mutex<HashMap<String, (NodeId, Instant)>>,
}
impl NodeHub {
pub fn new() -> Self {
Self::default()
}
pub async fn is_online(&self, id: NodeId) -> bool {
self.conns.lock().await.contains_key(&id)
}
async fn get(&self, id: NodeId) -> Option<Arc<NodeConn>> {
self.conns.lock().await.get(&id).cloned()
}
/// Run the node's built-in verification (host + docker check) and await its
/// output. The gateway never sends arbitrary shell — only typed ops the
/// daemon vets and runs itself (verify today; container ops later).
pub async fn verify(&self, id: NodeId) -> Result<ExecOutput, String> {
self.request(id, |req_id| {
json!({ "t": "verify", "id": req_id }).to_string()
})
.await
}
/// Provision + run + tear down a fully hardened throwaway container on the
/// node (readiness check that it can host agent workloads).
pub async fn sandbox_check(&self, id: NodeId) -> Result<ExecOutput, String> {
self.request(id, |req_id| {
json!({ "t": "sb_check", "id": req_id }).to_string()
})
.await
}
/// Sync liveness check (no await) — used by placement.
pub fn is_connected(&self, id: NodeId) -> bool {
self.online.lock().map(|s| s.contains(&id)).unwrap_or(false)
}
/// Every currently-connected node id. Sync (no await), like `is_connected`,
/// so the container reapers can enumerate nodes to sweep.
pub fn online_ids(&self) -> Vec<NodeId> {
self.online
.lock()
.map(|s| s.iter().copied().collect())
.unwrap_or_default()
}
/// Send a typed op with JSON args and await its result (20s default).
pub async fn call(&self, id: NodeId, op: &str, args: Value) -> Result<ExecOutput, String> {
self.call_timeout(id, op, args, 20).await
}
/// Like `call` but with a custom result timeout — for long ops (e.g. tool
/// updates) whose result legitimately takes longer than the default.
pub async fn call_timeout(
&self,
id: NodeId,
op: &str,
args: Value,
secs: u64,
) -> Result<ExecOutput, String> {
let op = op.to_owned();
self.request_timeout(
id,
move |req_id| {
let mut o = args.as_object().cloned().unwrap_or_default();
o.insert("t".to_owned(), Value::String(op));
o.insert("id".to_owned(), Value::from(req_id));
Value::Object(o).to_string()
},
std::time::Duration::from_secs(secs),
)
.await
}
/// Send a typed request frame and await the node's matching result (20s).
async fn request(
&self,
id: NodeId,
frame: impl FnOnce(u64) -> String,
) -> Result<ExecOutput, String> {
self.request_timeout(id, frame, std::time::Duration::from_secs(20))
.await
}
async fn request_timeout(
&self,
id: NodeId,
frame: impl FnOnce(u64) -> String,
dur: std::time::Duration,
) -> Result<ExecOutput, String> {
let conn = self.get(id).await.ok_or("node is not connected")?;
let req_id = conn.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
conn.pending.lock().await.insert(req_id, tx);
conn.tx
.send(frame(req_id))
.map_err(|_| "node channel closed".to_string())?;
match tokio::time::timeout(dur, rx).await {
Ok(Ok(out)) => Ok(out),
Ok(Err(_)) => Err("node dropped before responding".into()),
Err(_) => {
conn.pending.lock().await.remove(&req_id);
Err("node timed out".into())
}
}
}
/// Allocate a terminal session: a sid, a byte stream (WS-relay PTY output),
/// and a text stream (WebRTC answer/ICE). The PTY is NOT opened yet — the
/// browser picks the transport (WebRTC direct, or `open_pty` fallback).
pub async fn open_session(
&self,
id: NodeId,
) -> Option<(
u64,
mpsc::Receiver<Vec<u8>>,
mpsc::UnboundedReceiver<String>,
)> {
let conn = self.get(id).await?;
let sid = conn.next_id.fetch_add(1, Ordering::Relaxed);
// 256 × ~4KB PTY frames = ~1 MB per stalled session before we close it.
let (ptx, prx) = mpsc::channel(256);
let (stx, srx) = mpsc::unbounded_channel();
conn.pty_sinks.lock().await.insert(sid, ptx);
conn.signal_sinks.lock().await.insert(sid, stx);
Some((sid, prx, srx))
}
/// Open the WS-relay PTY for an allocated session (the fallback path).
/// `container` (+ `session`) targets `docker exec` into an agent container on
/// the node (the node-placed agent terminal); both `None` ⇒ the host shell.
/// `command`, when set to a non-empty argv, wins over both — spawns
/// the program directly (used by the Herdr Live Pane to attach xterm.js
/// straight to `herdr`).
// 8 params is at the target-shape ceiling: (id, sid, cols, rows) address
// the session, (container, session, command) address the target. Wrapping
// in a struct would add ceremony without collapsing dimensions.
#[allow(clippy::too_many_arguments)]
pub async fn open_pty(
&self,
id: NodeId,
sid: u64,
cols: u16,
rows: u16,
container: Option<&str>,
session: Option<&str>,
command: Option<&[String]>,
) {
if let Some(conn) = self.get(id).await {
let mut frame = json!({ "t": "pty_open", "sid": sid, "cols": cols, "rows": rows });
if let Some(cmd) = command.filter(|c| !c.is_empty()) {
frame["command"] = json!(cmd);
} else {
if let Some(c) = container {
frame["container"] = json!(c);
}
if let Some(s) = session {
frame["session"] = json!(s);
}
}
let _ = conn.tx.send(frame.to_string());
}
}
/// Relay a browser SDP offer to the daemon (it answers + trickles ICE back).
/// `container`/`session` target a node-placed agent container as in `open_pty`.
pub async fn webrtc_offer(
&self,
id: NodeId,
sid: u64,
sdp: &str,
container: Option<&str>,
session: Option<&str>,
) {
if let Some(conn) = self.get(id).await {
let mut frame = json!({ "t": "webrtc_offer", "sid": sid, "sdp": sdp });
if let Some(c) = container {
frame["container"] = json!(c);
}
if let Some(s) = session {
frame["session"] = json!(s);
}
let _ = conn.tx.send(frame.to_string());
}
}
/// Relay a browser ICE candidate to the daemon.
pub async fn webrtc_ice(
&self,
id: NodeId,
sid: u64,
candidate: &str,
sdp_mid: Option<&str>,
sdp_mline_index: Option<u16>,
) {
if let Some(conn) = self.get(id).await {
let _ = conn.tx.send(
json!({ "t": "webrtc_ice", "sid": sid, "candidate": candidate,
"sdp_mid": sdp_mid, "sdp_mline_index": sdp_mline_index })
.to_string(),
);
}
}
/// Tell the daemon to tear down a WebRTC peer for a session.
pub async fn webrtc_close(&self, id: NodeId, sid: u64) {
if let Some(conn) = self.get(id).await {
let _ = conn
.tx
.send(json!({ "t": "webrtc_close", "sid": sid }).to_string());
}
}
pub async fn terminal_input(&self, id: NodeId, sid: u64, bytes: &[u8]) {
if let Some(conn) = self.get(id).await {
let _ = conn
.tx
.send(json!({ "t": "pty_in", "sid": sid, "data": B64.encode(bytes) }).to_string());
}
}
pub async fn terminal_resize(&self, id: NodeId, sid: u64, cols: u16, rows: u16) {
if let Some(conn) = self.get(id).await {
let _ = conn.tx.send(
json!({ "t": "pty_resize", "sid": sid, "cols": cols, "rows": rows }).to_string(),
);
}
}
pub async fn terminal_close(&self, id: NodeId, sid: u64) {
if let Some(conn) = self.get(id).await {
conn.pty_sinks.lock().await.remove(&sid);
conn.signal_sinks.lock().await.remove(&sid);
let _ = conn
.tx
.send(json!({ "t": "pty_close", "sid": sid }).to_string());
let _ = conn
.tx
.send(json!({ "t": "webrtc_close", "sid": sid }).to_string());
}
}
/// Mint a single-use terminal ticket for a node (60s TTL) — the browser WS
/// handshake can't carry a bearer header.
pub async fn mint_ticket(&self, id: NodeId) -> String {
let token = format!(
"{}{}",
uuid::Uuid::now_v7().simple(),
uuid::Uuid::now_v7().simple()
);
let mut t = self.tickets.lock().await;
let now = Instant::now();
t.retain(|_, (_, exp)| *exp > now);
t.insert(token.clone(), (id, now + Duration::from_secs(60)));
token
}
/// Redeem a terminal ticket (single use).
pub async fn redeem_ticket(&self, token: &str) -> Option<NodeId> {
match self.tickets.lock().await.remove(token) {
Some((id, exp)) if exp > Instant::now() => Some(id),
_ => None,
}
}
}
#[derive(Deserialize)]
#[serde(tag = "t")]
enum Uplink {
#[serde(rename = "heartbeat")]
Heartbeat {
version: Option<String>,
tailscale_ip: Option<String>,
hostname: Option<String>,
local_ip: Option<String>,
health: HealthMsg,
},
#[serde(rename = "result")]
Result { id: u64, ok: bool, output: String },
#[serde(rename = "pty_out")]
PtyOut { sid: u64, data: String },
/// A chunk of a microVM turn's stdout/stderr, as it happens.
///
/// Keyed by RUN id rather than a session id: a mission run is the thing a
/// browser subscribes to, and unlike a PTY there is no interactive session
/// to allocate. `at` is the byte offset AFTER this chunk, so the node can
/// resume a dropped tail without replaying — the same contract `fcagent`'s
/// `tail` op exposes.
#[serde(rename = "vm_out")]
VmOut { run_id: String, at: u64, data: String },
#[serde(rename = "pty_exit")]
PtyExit { sid: u64 },
#[serde(rename = "webrtc_answer")]
WebRtcAnswer { sid: u64, sdp: String },
#[serde(rename = "webrtc_ice")]
WebRtcIce {
sid: u64,
candidate: String,
sdp_mid: Option<String>,
sdp_mline_index: Option<u16>,
},
#[serde(rename = "webrtc_failed")]
WebRtcFailed { sid: u64 },
#[serde(rename = "node_tools")]
NodeTools {
tools: std::collections::HashMap<String, String>,
},
/// What the node can HOST, as opposed to what it has installed — the
/// inputs to placement predicates. Free-form so a new predicate does not
/// need a migration; see `migrations/0065_microvm_placement.sql`.
#[serde(rename = "node_capabilities")]
NodeCapabilities { capabilities: serde_json::Value },
}
#[derive(Deserialize)]
struct HealthMsg {
cpu_pct: f64,
mem_total: i64,
mem_used: i64,
mem_pressure: f64,
swap_used: i64,
disk_total: i64,
disk_free: i64,
load1: f64,
load5: f64,
load15: f64,
container_count: i32,
}
/// Drive a daemon's control channel: register it, pump outbound command frames,
/// and apply uplink heartbeats/results until the socket closes.
pub async fn run_channel(pool: PgPool, hub: Arc<NodeHub>, node_id: NodeId, socket: WebSocket) {
let (mut ws_tx, mut ws_rx) = socket.split();
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let epoch = CONN_EPOCH.fetch_add(1, Ordering::Relaxed);
let conn = Arc::new(NodeConn {
tx,
pending: Mutex::new(HashMap::new()),
pty_sinks: Mutex::new(HashMap::new()),
signal_sinks: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(0),
epoch,
});
// Inserting overwrites any stale conn for this node — dropping the old conn's
// `tx`, so its writer's `rx.recv()` returns None and that channel tears down.
hub.conns.lock().await.insert(node_id, conn.clone());
hub.online.lock().unwrap().insert(node_id);
// One select loop carries outbound frames, inbound frames, and a keepalive
// ping. `last_inbound` tracks liveness: any frame (incl. Pong) refreshes it;
// if it goes stale past the deadline, the socket is dead and we tear down.
let mut ping_tick = tokio::time::interval(PING_EVERY);
ping_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut last_inbound = Instant::now();
loop {
tokio::select! {
frame = rx.recv() => match frame {
Some(f) => {
if ws_tx.send(Message::Text(f.into())).await.is_err() {
break;
}
}
None => break,
},
_ = ping_tick.tick() => {
if last_inbound.elapsed() > IDLE_DEADLINE {
break;
}
if ws_tx.send(Message::Ping(Vec::new().into())).await.is_err() {
break;
}
},
msg = ws_rx.next() => {
let Some(Ok(msg)) = msg else { break };
last_inbound = Instant::now();
let Message::Text(t) = msg else { continue };
match serde_json::from_str::<Uplink>(t.as_str()) {
Ok(Uplink::Heartbeat {
version,
tailscale_ip,
hostname,
local_ip,
health,
}) => {
let h = NodeHealth {
cpu_pct: health.cpu_pct,
mem_total: health.mem_total,
mem_used: health.mem_used,
mem_pressure: health.mem_pressure,
swap_used: health.swap_used,
disk_total: health.disk_total,
disk_free: health.disk_free,
load1: health.load1,
load5: health.load5,
load15: health.load15,
container_count: health.container_count,
};
let _ = nodes::heartbeat(
&pool,
node_id,
version.as_deref(),
tailscale_ip.as_deref(),
hostname.as_deref(),
local_ip.as_deref(),
&h,
)
.await;
}
Ok(Uplink::Result { id, ok, output }) => {
if let Some(s) = conn.pending.lock().await.remove(&id) {
let _ = s.send(ExecOutput { ok, output });
}
}
// A chunk of a microVM turn's output, live.
//
// Appended to the run's checkpoint rather than only fanned
// out: `PtyOut` above is deliberately ephemeral because a
// terminal has no history worth keeping, but a mission's log
// is the record of what the agent did — the Output tab has
// to still show it an hour later. Live and durable are
// different requirements and this needs both.
//
// `jsonb ||` merges into whatever else the checkpoint holds
// (`records`, written by the turn itself), so the two writers
// do not clobber each other.
Ok(Uplink::VmOut { run_id, at, data }) => {
if let (Ok(rid), Ok(bytes)) =
(uuid::Uuid::parse_str(&run_id), B64.decode(&data))
{
let text = String::from_utf8_lossy(&bytes).to_string();
if let Err(e) = sqlx::query(
"UPDATE topology_runs
SET checkpoint = COALESCE(checkpoint, '{}'::jsonb)
|| jsonb_build_object(
'log',
COALESCE(checkpoint->>'log', '') || $2::text,
'log_at', $3::bigint
),
updated_at = now()
WHERE id = $1",
)
.bind(rid)
.bind(&text)
.bind(at as i64)
.execute(&pool)
.await
{
eprintln!("fleet: appending vm_out for run {rid}: {e}");
}
}
}
Ok(Uplink::PtyOut { sid, data }) => {
if let Ok(bytes) = B64.decode(&data) {
let sink = conn.pty_sinks.lock().await.get(&sid).cloned();
if let Some(s) = sink {
// try_send so a stalled browser can't grow the
// per-session buffer without bound. On Full, the
// session is torn down: drop both sinks and tell
// the node to close its side, preventing an
// orphan PTY.
if let Err(err) = s.try_send(bytes) {
if matches!(err, mpsc::error::TrySendError::Full(_)) {
conn.pty_sinks.lock().await.remove(&sid);
conn.signal_sinks.lock().await.remove(&sid);
let _ = conn.tx.send(
json!({ "t": "pty_close", "sid": sid })
.to_string(),
);
}
}
}
}
}
Ok(Uplink::PtyExit { sid }) => {
conn.pty_sinks.lock().await.remove(&sid);
}
// WebRTC signaling from the daemon → forward to the browser
// bridge (re-tagged `type` for the browser) via signal_sinks.
Ok(Uplink::WebRtcAnswer { sid, sdp }) => {
if let Some(s) = conn.signal_sinks.lock().await.get(&sid) {
let _ = s.send(
json!({ "type": "webrtc_answer", "sdp": sdp }).to_string(),
);
}
}
Ok(Uplink::WebRtcIce {
sid,
candidate,
sdp_mid,
sdp_mline_index,
}) => {
if let Some(s) = conn.signal_sinks.lock().await.get(&sid) {
let _ = s.send(
json!({ "type": "webrtc_ice", "candidate": candidate,
"sdp_mid": sdp_mid, "sdp_mline_index": sdp_mline_index })
.to_string(),
);
}
}
Ok(Uplink::WebRtcFailed { sid }) => {
if let Some(s) = conn.signal_sinks.lock().await.get(&sid) {
let _ = s.send(json!({ "type": "webrtc_failed" }).to_string());
}
}
Ok(Uplink::NodeTools { tools }) => {
let pairs: Vec<(String, String)> = tools.into_iter().collect();
let _ = cm_db::repo::node_tools::upsert(&pool, node_id, &pairs).await;
}
Ok(Uplink::NodeCapabilities { capabilities }) => {
if let Err(e) = nodes::set_capabilities(&pool, node_id, &capabilities).await
{
// Loud: a node whose capabilities never land looks
// exactly like a node that has none, and will be
// passed over for every microVM mission forever
// while appearing perfectly healthy.
eprintln!(
"fleet: could not record capabilities for node {node_id} ({e}) — \
it will not be selected for microvm placement"
);
}
}
// An unparseable frame used to vanish here. That is the
// worst possible handling: a node op whose reply does not
// match `Uplink` never resolves its pending request, so the
// caller times out after 20s with nothing anywhere saying
// why. Caught exactly that way while wiring the vm_* ops —
// `output` was an object where the wire declares a String.
Err(e) => {
let head: String = t.as_str().chars().take(160).collect();
eprintln!(
"fleet: node {node_id} sent a frame we could not parse ({e}); \
any request it was answering will time out. Frame: {head}"
);
}
}
},
}
}
// Teardown — only clear if we're STILL the registered connection. A daemon
// that reconnected has a newer epoch; a stale channel must not flip it offline.
let mut conns = hub.conns.lock().await;
if conns.get(&node_id).map(|c| c.epoch) == Some(conn.epoch) {
conns.remove(&node_id);
hub.online.lock().unwrap().remove(&node_id);
let _ = nodes::set_status(&pool, node_id, "offline").await;
}
}
// ── RemoteDriver: run agent sandboxes on a connected node over the channel ────
/// A `SandboxDriver` that proxies container ops to a connected fleet node's
/// daemon, which drives the REAL `DockerDriver` — so the hardening is identical
/// to a local sandbox. Interactive PTY is unsupported on remote nodes for now
/// (agents don't use it; the Terminal app stays local).
pub struct RemoteDriver {
hub: Arc<NodeHub>,
node_id: NodeId,
}
impl RemoteDriver {
pub fn new(hub: Arc<NodeHub>, node_id: NodeId) -> Self {
Self { hub, node_id }
}
async fn call(&self, op: &str, args: Value) -> Result<Value, SandboxError> {
let out = self
.hub
.call(self.node_id, op, args)
.await
.map_err(SandboxError::Engine)?;
if !out.ok {
return Err(SandboxError::Engine(out.output));
}
Ok(serde_json::from_str(&out.output).unwrap_or(Value::Null))
}
}
#[async_trait::async_trait]
impl SandboxDriver for RemoteDriver {
async fn provision(&self, spec: &SandboxSpec) -> Result<SandboxHandle, SandboxError> {
let v = self.call("sb_provision", json!({ "spec": spec })).await?;
Ok(SandboxHandle {
id: v
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
name: v
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
})
}
async fn exec(&self, handle: &SandboxHandle, cmd: &[&str]) -> Result<ExecResult, SandboxError> {
let v = self
.call(
"sb_exec",
json!({ "cid": handle.id, "cname": handle.name, "cmd": cmd }),
)
.await?;
Ok(ExecResult {
exit_code: v.get("exit").and_then(Value::as_i64).unwrap_or(-1),
stdout: v
.get("stdout")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
stderr: v
.get("stderr")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
})
}
async fn attach_pty(
&self,
_h: &SandboxHandle,
_cmd: &[&str],
_c: u16,
_r: u16,
_e: &[String],
) -> Result<PtySession, SandboxError> {
Err(SandboxError::Engine(
"interactive PTY is not supported on remote nodes yet".into(),
))
}
async fn resize_pty(&self, _exec_id: &str, _c: u16, _r: u16) -> Result<(), SandboxError> {
Err(SandboxError::Engine(
"pty resize is not supported on remote nodes".into(),
))
}
async fn destroy(&self, handle: &SandboxHandle) -> Result<(), SandboxError> {
self.call(
"sb_destroy",
json!({ "cid": handle.id, "cname": handle.name }),
)
.await?;
Ok(())
}
async fn health(&self, handle: &SandboxHandle) -> Result<bool, SandboxError> {
let v = self
.call(
"sb_health",
json!({ "cid": handle.id, "cname": handle.name }),
)
.await?;
Ok(v.get("alive").and_then(Value::as_bool).unwrap_or(false))
}
async fn list_managed(&self, kind: &str) -> Result<Vec<ManagedSandbox>, SandboxError> {
let v = self.call("sb_list", json!({ "kind": kind })).await?;
Ok(v.as_array()
.map(|a| {
a.iter()
.map(|m| ManagedSandbox {
id: m
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
created_unix: m.get("created_unix").and_then(Value::as_i64).unwrap_or(0),
})
.collect()
})
.unwrap_or_default())
}
}
/// Bridges the runtime's placement to live node channels: hands out a
/// `RemoteDriver` for any currently-connected fleet node (None ⇒ offline).
pub struct HubDriverProvider {
hub: Arc<NodeHub>,
}
impl HubDriverProvider {
pub fn new(hub: Arc<NodeHub>) -> Self {
Self { hub }
}
}
impl cm_runtime::NodeDriverProvider for HubDriverProvider {
fn driver(&self, node_id: &str) -> Option<Arc<dyn SandboxDriver>> {
let nid = NodeId::from(node_id.parse::<uuid::Uuid>().ok()?);
if self.hub.is_connected(nid) {
Some(Arc::new(RemoteDriver::new(self.hub.clone(), nid)))
} else {
None
}
}
fn node_ids(&self) -> Vec<String> {
self.hub
.online_ids()
.into_iter()
.map(|id| id.to_string())
.collect()
}
}