Fleet tools: one-click per-node update (Phase 2)
ci / gates (push) Successful in 5s
ci / rust (push) Failing after 7s
ci / frontend (push) Successful in 23s
ci / e2e (push) Has been skipped

The ↑ badge on each tool card is now a button: confirm → POST
/api/nodes/{id}/tools/{tool}/update → daemon runs the tool's own updater + re-probes.

- daemon: tool_update op (spawned task so the 170s update can't stall the WS loop;
  re-probes + re-sends node_tools after). Fixed command allow-list (no arbitrary
  shell): claude/glm → `claude update`; kimi → `uv tool upgrade kimi-cli`; ollama →
  brew upgrade (mac) / install.sh (linux); else unsupported. 4KB output cap.
- cm-api: call_timeout/request_timeout (long ops); POST .../tools/{tool}/update
  (workspace-scoped, allow-list) → {ok,output}.
- frontend: ↑latest becomes an Update button → confirm → spinner → refresh/err.

Note: claude/kimi/glm are user-space (no sudo); ollama on Linux uses install.sh
(needs sudo — works on passwordless nodes, returns an error otherwise; surfaced in UI).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-27 06:25:30 -07:00
co-authored by Claude Opus 4.8
parent 9263418fcb
commit ed339c2121
5 changed files with 177 additions and 14 deletions
+90
View File
@@ -255,6 +255,73 @@ fn tool_version(bin: &std::path::Path) -> Option<String> {
/// First `\d+\.\d+(\.\d+)?` run in `s` (e.g. "29.1.3" from "Docker version /// First `\d+\.\d+(\.\d+)?` run in `s` (e.g. "29.1.3" from "Docker version
/// 29.1.3, build …"; stops before a trailing `-0ubuntu…`). /// 29.1.3, build …"; stops before a trailing `-0ubuntu…`).
/// Run the FIXED update command for a tool (no arbitrary shell — the key maps to a
/// hardcoded command). Returns (success, combined output). Async so it never blocks
/// the read loop; ~170s cap.
async fn update_tool(tool: &str) -> (bool, String) {
let home = std::env::var("HOME").unwrap_or_default();
let dirs = [
format!("{home}/.local/bin"),
"/opt/homebrew/bin".to_string(),
"/usr/local/bin".to_string(),
"/usr/bin".to_string(),
"/bin".to_string(),
format!("{home}/.cargo/bin"),
];
let find = |name: &str| {
dirs.iter()
.map(|d| std::path::Path::new(d).join(name))
.find(|p| p.exists())
};
let mut cmd = match tool {
"claude" | "glm" => match find("claude") {
Some(p) => {
let mut c = tokio::process::Command::new(p);
c.arg("update");
c
}
None => return (false, "claude not found".into()),
},
"kimi" => match find("uv") {
Some(p) => {
let mut c = tokio::process::Command::new(p);
c.args(["tool", "upgrade", "kimi-cli"]);
c
}
None => return (false, "uv not found".into()),
},
"ollama" => {
if cfg!(target_os = "macos") {
match find("brew") {
Some(p) => {
let mut c = tokio::process::Command::new(p);
c.args(["upgrade", "ollama"]);
c
}
None => return (false, "brew not found".into()),
}
} else {
let mut c = tokio::process::Command::new("sh");
c.args(["-c", "curl -fsSL https://ollama.com/install.sh | sh"]);
c
}
}
_ => return (false, "unsupported tool".into()),
};
match tokio::time::timeout(Duration::from_secs(170), cmd.output()).await {
Ok(Ok(o)) => {
let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&o.stderr));
if s.len() > 4096 {
s.truncate(4096);
}
(o.status.success(), s)
}
Ok(Err(e)) => (false, format!("spawn error: {e}")),
Err(_) => (false, "update timed out (170s)".into()),
}
}
fn extract_semver(s: &str) -> Option<String> { fn extract_semver(s: &str) -> Option<String> {
let c: Vec<char> = s.chars().collect(); let c: Vec<char> = s.chars().collect();
let mut i = 0; let mut i = 0;
@@ -359,6 +426,29 @@ async fn handle_frame(
); );
} }
} }
// One-click dev-tool update. Runs a FIXED per-tool command (no arbitrary
// shell), then re-probes so the new version reports. Spawned so the ~170s
// command never stalls the read loop (heartbeats keep flowing).
"tool_update" => {
if let Some(id) = v.get("id").and_then(Value::as_u64) {
let tool = v
.get("tool")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let out = out.clone();
tokio::spawn(async move {
let (ok, output) = update_tool(&tool).await;
let _ = out.send(
json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string(),
);
let tools = tokio::task::spawn_blocking(probe_tools)
.await
.unwrap_or_else(|_| json!({}));
let _ = out.send(json!({ "t": "node_tools", "tools": tools }).to_string());
});
}
}
// Agent-sandbox container ops: drive the REAL DockerDriver so the // Agent-sandbox container ops: drive the REAL DockerDriver so the
// hardening (cap-drop ALL, seccomp, no-net, read-only, non-root) is // hardening (cap-drop ALL, seccomp, no-net, read-only, non-root) is
// byte-identical to the gateway's local sandboxes. // byte-identical to the gateway's local sandboxes.
+36 -9
View File
@@ -118,22 +118,49 @@ impl NodeHub {
self.online.lock().map(|s| s.contains(&id)).unwrap_or(false) self.online.lock().map(|s| s.contains(&id)).unwrap_or(false)
} }
/// Send a typed op with JSON args and await its result (output is op-specific). /// 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> { pub async fn call(&self, id: NodeId, op: &str, args: Value) -> Result<ExecOutput, String> {
self.request(id, |req_id| { self.call_timeout(id, op, args, 20).await
let mut o = args.as_object().cloned().unwrap_or_default(); }
o.insert("t".to_owned(), Value::String(op.to_owned()));
o.insert("id".to_owned(), Value::from(req_id)); /// Like `call` but with a custom result timeout — for long ops (e.g. tool
Value::Object(o).to_string() /// 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 .await
} }
/// Send a typed request frame and await the node's matching result. /// Send a typed request frame and await the node's matching result (20s).
async fn request( async fn request(
&self, &self,
id: NodeId, id: NodeId,
frame: impl FnOnce(u64) -> String, 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> { ) -> Result<ExecOutput, String> {
let conn = self.get(id).await.ok_or("node is not connected")?; let conn = self.get(id).await.ok_or("node is not connected")?;
let req_id = conn.next_id.fetch_add(1, Ordering::Relaxed); let req_id = conn.next_id.fetch_add(1, Ordering::Relaxed);
@@ -142,7 +169,7 @@ impl NodeHub {
conn.tx conn.tx
.send(frame(req_id)) .send(frame(req_id))
.map_err(|_| "node channel closed".to_string())?; .map_err(|_| "node channel closed".to_string())?;
match tokio::time::timeout(std::time::Duration::from_secs(20), rx).await { match tokio::time::timeout(dur, rx).await {
Ok(Ok(out)) => Ok(out), Ok(Ok(out)) => Ok(out),
Ok(Err(_)) => Err("node dropped before responding".into()), Ok(Err(_)) => Err("node dropped before responding".into()),
Err(_) => { Err(_) => {
+4
View File
@@ -140,6 +140,10 @@ pub fn router(state: AppState) -> Router {
get(routes::beszel::node_metrics_get), get(routes::beszel::node_metrics_get),
) )
.route("/api/nodes/{id}/tools", get(routes::nodes::tools)) .route("/api/nodes/{id}/tools", get(routes::nodes::tools))
.route(
"/api/nodes/{id}/tools/{tool}/update",
post(routes::nodes::tool_update),
)
.route("/api/nodes/{id}", delete(routes::nodes::remove)) .route("/api/nodes/{id}", delete(routes::nodes::remove))
.route( .route(
"/api/fleet/beszel", "/api/fleet/beszel",
+25
View File
@@ -219,6 +219,31 @@ pub async fn tools(
Ok(Json(json!({ "tools": out }))) Ok(Json(json!({ "tools": out })))
} }
/// `POST /api/nodes/{id}/tools/{tool}/update` — run the daemon's fixed update for a
/// tool (claude/glm/kimi/ollama), then it re-probes so the version refreshes.
pub async fn tool_update(
State(state): State<AppState>,
Authed(user): Authed,
Path((id, tool)): Path<(Uuid, String)>,
) -> Result<Json<Value>, ApiError> {
let node_id = NodeId::from(id);
nodes::get(&state.pool, node_id, user.workspace_id)
.await?
.ok_or(ApiError::NotFound)?;
if !["claude", "glm", "kimi", "ollama"].contains(&tool.as_str()) {
return Ok(Json(json!({ "ok": false, "output": "tool not updatable" })));
}
// ~180s: tool updates (npm/uv/brew/installer) legitimately run long.
match state
.node_hub
.call_timeout(node_id, "tool_update", json!({ "tool": tool }), 180)
.await
{
Ok(out) => Ok(Json(json!({ "ok": out.ok, "output": out.output }))),
Err(e) => Ok(Json(json!({ "ok": false, "output": e }))),
}
}
/// `POST /api/nodes/{id}/terminal/ticket` — mint a single-use terminal ticket /// `POST /api/nodes/{id}/terminal/ticket` — mint a single-use terminal ticket
/// (the browser WS handshake can't carry a bearer header). /// (the browser WS handshake can't carry a bearer header).
pub async fn terminal_ticket( pub async fn terminal_ticket(
@@ -125,11 +125,26 @@ function Mini({ label, value }: { label: string; value: string }) {
interface NodeTool { name: string; key: string; installed: string; latest: string | null; updateAvailable: boolean } interface NodeTool { name: string; key: string; installed: string; latest: string | null; updateAvailable: boolean }
/** Installed dev-tool versions for a node (Docker / Claude Code / Kimi / GLM / /** Installed dev-tool versions for a node (Docker / Claude Code / Kimi / GLM /
* Ollama), with an "update available" badge when the nightly check finds a newer * Ollama). When the nightly check finds a newer release, the badge becomes an
* release. Read-only (Phase 1); the one-click update lands next to the badge later. */ * "↑ <latest>" button that runs the daemon's fixed per-tool update + re-probes.
* Docker is display-only. */
function NodeTools({ nodeId, online }: { nodeId: string; online: boolean }) { function NodeTools({ nodeId, online }: { nodeId: string; online: boolean }) {
const { data } = useFetchJson<{ tools: NodeTool[] }>(online ? `/api/nodes/${nodeId}/tools` : null); const { data, refresh } = useFetchJson<{ tools: NodeTool[] }>(online ? `/api/nodes/${nodeId}/tools` : null);
const [busy, setBusy] = useState<string | null>(null);
const tools = data?.tools ?? []; const tools = data?.tools ?? [];
const update = (t: NodeTool) => {
if (busy || !confirm(`Update ${t.name} → ${t.latest} on this node? It runs the tool's own updater and replaces the binary.`)) return;
setBusy(t.key);
fetch(`/api/nodes/${nodeId}/tools/${t.key}/update`, { method: "POST" })
.then((r) => r.json())
.then((d: { ok?: boolean; output?: string }) => {
if (!d.ok) alert(`${t.name} update failed:\n\n${(d.output ?? "unknown error").slice(0, 800)}`);
refresh();
setTimeout(refresh, 3500); // let the daemon's post-update re-probe land
})
.catch((e: Error) => alert(`${t.name} update error: ${e.message}`))
.finally(() => setBusy(null));
};
if (!online || tools.length === 0) return null; if (!online || tools.length === 0) return null;
return ( return (
<div style={{ marginTop: 8, display: "flex", flexDirection: "column", gap: 5 }}> <div style={{ marginTop: 8, display: "flex", flexDirection: "column", gap: 5 }}>
@@ -137,8 +152,10 @@ function NodeTools({ nodeId, online }: { nodeId: string; online: boolean }) {
<div key={t.key} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 10px", borderRadius: 8, background: "#070708", border: `1px solid ${t.updateAvailable ? "rgba(232,196,106,.3)" : "rgba(255,255,255,.06)"}` }}> <div key={t.key} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 10px", borderRadius: 8, background: "#070708", border: `1px solid ${t.updateAvailable ? "rgba(232,196,106,.3)" : "rgba(255,255,255,.06)"}` }}>
<span style={{ fontFamily: mono, fontSize: 10.5, color: "#9a9aa2", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.name}</span> <span style={{ fontFamily: mono, fontSize: 10.5, color: "#9a9aa2", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.name}</span>
<span style={{ fontFamily: mono, fontSize: 10.5, color: "#cfcfd5" }}>{t.installed}</span> <span style={{ fontFamily: mono, fontSize: 10.5, color: "#cfcfd5" }}>{t.installed}</span>
{t.updateAvailable ? ( {busy === t.key ? (
<span title={`update available → ${t.latest}`} style={{ fontFamily: mono, fontSize: 9, color: "#e8b465", background: "rgba(232,196,106,.12)", border: "1px solid rgba(232,196,106,.3)", borderRadius: 5, padding: "1px 6px" }}>↑ {t.latest}</span> <span style={{ fontFamily: mono, fontSize: 9, color: "#5ec8d8", background: "rgba(94,200,216,.1)", border: "1px solid rgba(94,200,216,.3)", borderRadius: 5, padding: "1px 6px" }}>updating…</span>
) : t.updateAvailable ? (
<button type="button" onClick={() => update(t)} disabled={!!busy} title={`Update to ${t.latest}`} style={{ fontFamily: mono, fontSize: 9, color: "#e8b465", background: "rgba(232,196,106,.12)", border: "1px solid rgba(232,196,106,.3)", borderRadius: 5, padding: "1px 6px", cursor: busy ? "default" : "pointer" }}>↑ {t.latest}</button>
) : null} ) : null}
</div> </div>
))} ))}