Fleet tools: one-click per-node update (Phase 2)
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:
co-authored by
Claude Opus 4.8
parent
9263418fcb
commit
ed339c2121
@@ -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.
|
||||||
|
|||||||
@@ -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(_) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user