From 0a55b021e40320ec64bcd795894dcccab518c0d5 Mon Sep 17 00:00:00 2001 From: Embers-of-the-Fire Date: Mon, 16 Mar 2026 19:00:38 +0800 Subject: [PATCH 01/16] feat(bot): add update_binary instruction handler for live self-update Add a new instruction handler that enables remote binary updates on edge devices. The handler downloads a binary from a provided artifact URL, validates ELF magic bytes, atomically replaces the current executable via same-filesystem rename, and schedules a restart using syscall.Exec. Co-Authored-By: Claude Opus 4.6 --- .../eventloop/instructions/instructions.go | 1 + .../eventloop/instructions/update_binary.go | 136 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 packages/bot/eventloop/instructions/update_binary.go diff --git a/packages/bot/eventloop/instructions/instructions.go b/packages/bot/eventloop/instructions/instructions.go index 63f483a..9068a7b 100644 --- a/packages/bot/eventloop/instructions/instructions.go +++ b/packages/bot/eventloop/instructions/instructions.go @@ -18,6 +18,7 @@ type InstructionHandler struct { var instructionHandlers = []InstructionHandler{ SyncRobotNameHandler, FetchNetworkHandler, + UpdateBinaryHandler, } var InstructionHandlers = func() map[string]InstructionHandler { diff --git a/packages/bot/eventloop/instructions/update_binary.go b/packages/bot/eventloop/instructions/update_binary.go new file mode 100644 index 0000000..7dc4ebe --- /dev/null +++ b/packages/bot/eventloop/instructions/update_binary.go @@ -0,0 +1,136 @@ +package instructions + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "syscall" + "time" + + "github.com/Alliance-Algorithm/rmcs-actions/packages/bot/eventloop/share" + "github.com/Alliance-Algorithm/rmcs-actions/packages/bot/logger" + "go.uber.org/zap" +) + +const InstructionUpdateBinary = "update_binary" + +// UpdateBinaryRequest is the request payload sent from the service. +type UpdateBinaryRequest struct { + ArtifactUrl string `json:"artifact_url"` +} + +// UpdateBinaryResponse is the response payload sent back to the service. +type UpdateBinaryResponse struct { + Status string `json:"status"` + Message string `json:"message"` +} + +// UpdateBinaryHandler registers the update_binary instruction using the +// ResponseAction pattern. +var UpdateBinaryHandler = InstructionHandler{ + Instruction: InstructionUpdateBinary, + Action: share.WrapResponseAction(UpdateBinaryAction), +} + +// elfMagic is the first 4 bytes of any valid ELF binary. +var elfMagic = []byte{0x7f, 'E', 'L', 'F'} + +// UpdateBinaryAction downloads a new binary from the given artifact URL, +// validates it as an ELF executable, atomically replaces the current +// executable, and schedules a restart via syscall.Exec. +func UpdateBinaryAction(ctx context.Context, req UpdateBinaryRequest) UpdateBinaryResponse { + logger.Logger().Info("UpdateBinaryAction called", zap.String("artifact_url", req.ArtifactUrl)) + + execPath, err := os.Executable() + if err != nil { + return UpdateBinaryResponse{Status: "error", Message: fmt.Sprintf("failed to get executable path: %v", err)} + } + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return UpdateBinaryResponse{Status: "error", Message: fmt.Sprintf("failed to resolve symlinks: %v", err)} + } + + execDir := filepath.Dir(execPath) + + // Create temp file in the same directory to ensure same-filesystem for + // atomic rename. + tmpFile, err := os.CreateTemp(execDir, ".update_binary_*") + if err != nil { + return UpdateBinaryResponse{Status: "error", Message: fmt.Sprintf("failed to create temp file: %v", err)} + } + tmpPath := tmpFile.Name() + + // Cleanup helper — removes the temp file on any error path. + cleanup := func() { + tmpFile.Close() + os.Remove(tmpPath) + } + + // Download the binary. + resp, err := http.Get(req.ArtifactUrl) + if err != nil { + cleanup() + return UpdateBinaryResponse{Status: "error", Message: fmt.Sprintf("failed to download binary: %v", err)} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + cleanup() + return UpdateBinaryResponse{Status: "error", Message: fmt.Sprintf("download returned status %d", resp.StatusCode)} + } + + _, err = io.Copy(tmpFile, resp.Body) + if err != nil { + cleanup() + return UpdateBinaryResponse{Status: "error", Message: fmt.Sprintf("failed to write binary: %v", err)} + } + tmpFile.Close() + + // Validate ELF magic bytes. + header := make([]byte, 4) + f, err := os.Open(tmpPath) + if err != nil { + os.Remove(tmpPath) + return UpdateBinaryResponse{Status: "error", Message: fmt.Sprintf("failed to open temp file for validation: %v", err)} + } + _, err = io.ReadFull(f, header) + f.Close() + if err != nil { + os.Remove(tmpPath) + return UpdateBinaryResponse{Status: "error", Message: fmt.Sprintf("failed to read header: %v", err)} + } + for i := 0; i < 4; i++ { + if header[i] != elfMagic[i] { + os.Remove(tmpPath) + return UpdateBinaryResponse{Status: "error", Message: "downloaded file is not a valid ELF binary"} + } + } + + // Set executable permissions. + if err := os.Chmod(tmpPath, 0755); err != nil { + os.Remove(tmpPath) + return UpdateBinaryResponse{Status: "error", Message: fmt.Sprintf("failed to chmod: %v", err)} + } + + // Atomic replace via same-filesystem rename. + if err := os.Rename(tmpPath, execPath); err != nil { + os.Remove(tmpPath) + return UpdateBinaryResponse{Status: "error", Message: fmt.Sprintf("failed to replace binary: %v", err)} + } + + logger.Logger().Info("Binary replaced successfully, scheduling restart", zap.String("path", execPath)) + + // Schedule restart after a short delay so the WebSocket response can flush. + go func() { + time.Sleep(1 * time.Second) + logger.Logger().Info("Restarting via syscall.Exec", zap.String("path", execPath)) + if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil { + logger.Logger().Error("Failed to exec new binary", zap.Error(err)) + } + }() + + return UpdateBinaryResponse{Status: "post_update", Message: "success, restarting..."} +} From 8f06ea84d547f6c179f748402bc9e4e2ce0acd3f Mon Sep 17 00:00:00 2001 From: Embers-of-the-Fire Date: Mon, 16 Mar 2026 19:06:28 +0800 Subject: [PATCH 02/16] feat(service): add update_binary instruction and API endpoints Add UpdateBinary variant to the Instruction and InstructionContent enums with PingPong-based session handling. Expose POST /action/update_binary for single-bot updates and POST /action/update_binary_all for fleet-wide binary updates. Co-Authored-By: Claude Opus 4.6 --- packages/service/src/api/action.rs | 90 +++++++++++++++++++ .../service/src/api/action/update_binary.rs | 19 ++++ packages/service/src/service/instructions.rs | 17 ++++ .../src/service/instructions/update_binary.rs | 37 ++++++++ 4 files changed, 163 insertions(+) create mode 100644 packages/service/src/api/action/update_binary.rs create mode 100644 packages/service/src/service/instructions/update_binary.rs diff --git a/packages/service/src/api/action.rs b/packages/service/src/api/action.rs index 5e70ffd..e523af1 100644 --- a/packages/service/src/api/action.rs +++ b/packages/service/src/api/action.rs @@ -11,6 +11,7 @@ use crate::{ pub mod fetch_network; pub mod set_robot_name; +pub mod update_binary; pub struct ActionApi; @@ -126,4 +127,93 @@ impl ActionApi { } Ok(Json(fetch_network::FetchNetworkResponse {})) } + + #[oai(path = "/action/update_binary", method = "post")] + async fn update_binary( + &self, + request: Json, + ) -> ApiResult { + if let Some(conn) = CONNECTIONS.get(&request.robot_id) { + let result = conn + .value() + .send_instruction::(Instruction::UpdateBinary { + artifact_url: request.artifact_url.clone(), + }) + .await; + match result { + Ok(info) => { + let status = info + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let message = info + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + Ok(Json(update_binary::UpdateBinaryResponse { + status, + message, + })) + } + Err(err) => { + log::error!( + "Failed to update binary on robot {}: {:?}", + request.robot_id, + err + ); + Err(GenericResponse::BadRequest(PlainText( + "failed to update binary".to_string(), + ))) + } + } + } else { + log::info!( + "No connection found for robot_id: {}", + request.robot_id + ); + Err(GenericResponse::BadRequest(PlainText( + "robot not connected".to_string(), + ))) + } + } + + #[oai(path = "/action/update_binary_all", method = "post")] + async fn update_binary_all( + &self, + request: Json, + ) -> ApiResult { + for conn in CONNECTIONS.iter() { + let robot_id = conn.key().clone(); + let result = conn + .value() + .send_instruction::( + Instruction::UpdateBinary { + artifact_url: request.artifact_url.clone(), + }, + ) + .await; + match result { + Ok(_) => { + log::info!( + "Update binary instruction sent to robot {}", + robot_id + ); + } + Err(err) => { + log::error!( + "Failed to update binary on robot {}: {:?}", + robot_id, + err + ); + } + } + } + Ok(Json(update_binary::UpdateBinaryResponse { + status: "ok".to_string(), + message: "update instruction sent to all connected robots" + .to_string(), + })) + } } diff --git a/packages/service/src/api/action/update_binary.rs b/packages/service/src/api/action/update_binary.rs new file mode 100644 index 0000000..36e425d --- /dev/null +++ b/packages/service/src/api/action/update_binary.rs @@ -0,0 +1,19 @@ +use poem_openapi::Object; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Object)] +pub struct UpdateBinaryRequest { + pub robot_id: String, + pub artifact_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Object)] +pub struct UpdateBinaryResponse { + pub status: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Object)] +pub struct UpdateBinaryAllRequest { + pub artifact_url: String, +} diff --git a/packages/service/src/service/instructions.rs b/packages/service/src/service/instructions.rs index 6392160..9006635 100644 --- a/packages/service/src/service/instructions.rs +++ b/packages/service/src/service/instructions.rs @@ -9,6 +9,7 @@ use crate::service::{ pub mod fetch_network; pub mod sync_robot_name; +pub mod update_binary; pub struct InstructionSession { pub action: Action, @@ -39,6 +40,7 @@ where pub enum Instruction { SyncRobotName { robot_name: String }, FetchNetwork {}, + UpdateBinary { artifact_url: String }, } impl Instruction { @@ -65,6 +67,14 @@ impl Instruction { fetch_network::fetch_network(resp_tx), on_complete, ), + Instruction::UpdateBinary { artifact_url } => { + create_instruction_session::<()>( + session_id, + output_receiver, + update_binary::update_binary(resp_tx, artifact_url), + on_complete, + ) + } } } } @@ -78,6 +88,8 @@ pub enum InstructionContent { FetchNetwork {}, #[serde(rename = "update_metadata")] UpdateMetadata {}, + #[serde(rename = "update_binary")] + UpdateBinary { message: UpdateBinaryMessage }, #[serde(untagged)] Unknown(serde_json::Value), } @@ -86,3 +98,8 @@ pub enum InstructionContent { pub struct SyncRobotNameMessage { pub robot_name: String, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateBinaryMessage { + pub artifact_url: String, +} diff --git a/packages/service/src/service/instructions/update_binary.rs b/packages/service/src/service/instructions/update_binary.rs new file mode 100644 index 0000000..b6e2e7d --- /dev/null +++ b/packages/service/src/service/instructions/update_binary.rs @@ -0,0 +1,37 @@ +use futures_util::FutureExt; +use tokio::sync::oneshot; +use uuid::Uuid; + +use crate::service::{ + action::{InitAction, PingPong}, + instructions::{InstructionContent, UpdateBinaryMessage}, + message::Message, +}; + +pub fn update_binary( + resp_tx: oneshot::Sender, + artifact_url: String, +) -> impl InitAction<(), Message> { + PingPong { + constructor: move |session_id: Uuid| { + async move { + Message::new_instruction_with_uuid( + session_id, + InstructionContent::UpdateBinary { + message: UpdateBinaryMessage { artifact_url }, + }, + ) + } + .boxed() + }, + reader: + move |_: Uuid, resp_rx: oneshot::Receiver| { + async move { + if let Ok(response) = resp_rx.await { + resp_tx.send(response).ok(); + } + } + .boxed() + }, + } +} From 7cd2f04f543362e7893e1bbfe093ac0737ce6fb6 Mon Sep 17 00:00:00 2001 From: Embers-of-the-Fire Date: Mon, 16 Mar 2026 19:10:49 +0800 Subject: [PATCH 03/16] feat(workstation): add binary update management UI Add a dashboard page for remotely updating bot binaries. Includes API client with zod-validated request/response schemas, a page loader that fetches online robots, and a form UI with single-bot and fleet-wide update actions. Adds an Update entry to the dashboard sidebar. Co-Authored-By: Claude Opus 4.6 --- .../src/lib/api/action/update_binary.ts | 68 +++++++++++ .../routes/dashboard/SidebarContent.svelte | 6 + .../src/routes/dashboard/update/+page.svelte | 114 ++++++++++++++++++ .../src/routes/dashboard/update/+page.ts | 13 ++ 4 files changed, 201 insertions(+) create mode 100644 packages/workstation/src/lib/api/action/update_binary.ts create mode 100644 packages/workstation/src/routes/dashboard/update/+page.svelte create mode 100644 packages/workstation/src/routes/dashboard/update/+page.ts diff --git a/packages/workstation/src/lib/api/action/update_binary.ts b/packages/workstation/src/lib/api/action/update_binary.ts new file mode 100644 index 0000000..0969c6e --- /dev/null +++ b/packages/workstation/src/lib/api/action/update_binary.ts @@ -0,0 +1,68 @@ +import * as z from 'zod'; +import { getEndpoint } from '$lib/api/api'; + +export const ACTION_UPDATE_BINARY_ENDPOINT = '/action/update_binary'; +export const ACTION_UPDATE_BINARY_ALL_ENDPOINT = '/action/update_binary_all'; + +export const ActionUpdateBinaryRequest = z.object({ + robot_id: z.string().min(1), + artifact_url: z.string().url(), +}); +export type ActionUpdateBinaryRequest = z.infer; + +export const ActionUpdateBinaryAllRequest = z.object({ + artifact_url: z.string().url(), +}); +export type ActionUpdateBinaryAllRequest = z.infer; + +export const ActionUpdateBinaryResponse = z.object({ + status: z.string(), + message: z.string(), +}); +export type ActionUpdateBinaryResponse = z.infer; + +export async function actionUpdateBinary( + trackedFetch: typeof fetch, + request: ActionUpdateBinaryRequest, +): Promise { + const body = ActionUpdateBinaryRequest.parse(request); + + const response = await trackedFetch(getEndpoint(ACTION_UPDATE_BINARY_ENDPOINT), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + throw new Error(`Error updating binary: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return ActionUpdateBinaryResponse.parse(data); +} + +export async function actionUpdateBinaryAll( + trackedFetch: typeof fetch, + request: ActionUpdateBinaryAllRequest, +): Promise { + const body = ActionUpdateBinaryAllRequest.parse(request); + + const response = await trackedFetch(getEndpoint(ACTION_UPDATE_BINARY_ALL_ENDPOINT), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(60000), + }); + + if (!response.ok) { + throw new Error(`Error updating all binaries: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return ActionUpdateBinaryResponse.parse(data); +} diff --git a/packages/workstation/src/routes/dashboard/SidebarContent.svelte b/packages/workstation/src/routes/dashboard/SidebarContent.svelte index c693aa5..580d828 100644 --- a/packages/workstation/src/routes/dashboard/SidebarContent.svelte +++ b/packages/workstation/src/routes/dashboard/SidebarContent.svelte @@ -8,6 +8,7 @@ UserSolid, ArrowRightToBracketOutline, AdjustmentsHorizontalSolid, + DownloadSolid, } from 'flowbite-svelte-icons'; import { page } from '$app/state'; @@ -34,6 +35,11 @@ {/snippet} + + {#snippet icon()} + + {/snippet} +