diff --git a/docs/live-update.md b/docs/live-update.md new file mode 100644 index 0000000..253feaf --- /dev/null +++ b/docs/live-update.md @@ -0,0 +1,159 @@ +# Live-Update Feature + +Remote binary update mechanism for edge bot devices. The service sends an update instruction with an artifact URL to a connected bot over WebSocket. The bot downloads, validates, and atomically replaces its own executable, then restarts in-place. + +## End-to-End Flow + +``` +Frontend Service Bot + │ │ │ + │ POST /action/ │ │ + │ update_binary │ │ + │────────────────────────>│ │ + │ │ WS: update_binary │ + │ │ instruction │ + │ │───────────────────────>│ + │ │ │ 1. Resolve exec path + │ │ │ 2. Download binary + │ │ │ 3. Validate ELF magic + │ │ │ 4. chmod 0755 + │ │ │ 5. Atomic rename + │ │ │ + │ │ WS: response │ + │ │<───────────────────────│ + │ 200 OK │ │ + │<────────────────────────│ │ + │ │ │ 6. Wait for WS flush + │ │ │ 7. syscall.Exec + │ │ │ (process restarts) + │ │ │ + │ │ WS: reconnect │ + │ │<───────────────────────│ +``` + +## Wire Protocol + +### Instruction (Service → Bot) + +```json +{ + "instruction": "update_binary", + "message": { + "artifact_url": "https://artifacts.example.com/bot/v1.2.3/bot-linux-amd64" + } +} +``` + +### Response (Bot → Service) + +**Success:** + +```json +{ + "status": "post_update", + "message": "success, restarting..." +} +``` + +**Error:** + +```json +{ + "status": "error", + "message": "downloaded file is not a valid ELF binary" +} +``` + +## REST API Endpoints + +### `POST /action/update_binary` + +Update a single bot's binary. + +**Request:** + +```json +{ + "robot_id": "550e8400-e29b-41d4-a716-446655440000", + "artifact_url": "https://artifacts.example.com/bot/v1.2.3/bot-linux-amd64" +} +``` + +**Response:** + +```json +{ + "status": "post_update", + "message": "success, restarting..." +} +``` + +If the bot reports an instruction failure or the operation times out, the +endpoint still responds with HTTP 200 and a business error payload: + +```json +{ + "status": "error", + "message": "instruction timed out after 60 seconds" +} +``` + +### `POST /action/update_binary_all` + +Update all connected bots. Returns per-robot results with individual status and message fields. + +**Request:** + +```json +{ + "artifact_url": "https://artifacts.example.com/bot/v1.2.3/bot-linux-amd64" +} +``` + +**Response:** + +```json +{ + "status": "partial_failure", + "results": [ + { + "robot_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "post_update", + "message": "success, restarting..." + }, + { + "robot_id": "660e8400-e29b-41d4-a716-446655440001", + "status": "error", + "message": "downloaded file is not a valid ELF binary" + } + ] +} +``` + +The overall `status` is `"ok"` when every bot succeeds and `"partial_failure"` when any bot reports an error or returns an unrecognised response. + +## Safety Guarantees + +| Mechanism | Purpose | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| **Same-directory temp file** | Ensures temp file and target are on the same filesystem, which is required for `os.Rename` to be atomic | +| **ELF magic validation** | Checks first 4 bytes (`\x7fELF`) to prevent replacing the binary with an HTML error page or other invalid content | +| **Atomic rename** | `os.Rename` on the same filesystem is an atomic operation at the VFS level — the old binary is fully replaced in a single syscall | +| **Temp file cleanup** | On any error path, the temp file is removed before returning | + +## Restart Semantics + +The bot uses `syscall.Exec(execPath, os.Args, os.Environ())` to restart: + +- **In-place replacement**: The current process image is replaced with the new binary. The PID remains the same. +- **Write-completion gated**: A goroutine waits for the send-done signal from the eventloop (indicating the WebSocket response has been flushed) before calling `syscall.Exec`, instead of relying on a fixed delay. +- **Re-initialization**: The new binary runs `main()` from scratch, re-authenticates with the service, and re-establishes the WebSocket connection via the existing retry loop. +- **No rollback (v1)**: If the new binary fails to start, the bot stays down. Rollback is a future enhancement. + +## Batch Update Behavior + +When using `update_binary_all`: + +- The instruction is sent to each connected bot sequentially. +- Per-bot results are collected and returned in the response, including individual status and error messages. +- Bot restarts will drop the WebSocket connection, which is expected — the bot reconnects automatically after restart. diff --git a/packages/bot/eventloop/eventloop.go b/packages/bot/eventloop/eventloop.go index 034d61c..f0828fa 100644 --- a/packages/bot/eventloop/eventloop.go +++ b/packages/bot/eventloop/eventloop.go @@ -39,6 +39,19 @@ func serveEventloop(ctx context.Context, backend *EventloopBackend) { logger.Logger().Info("Event loop shutting down") } +func unwrapSendEnvelope(msg any) (any, chan struct{}) { + switch env := msg.(type) { + case lib.SendEnvelope: + return env.Payload, env.Done + case *lib.SendEnvelope: + if env != nil { + return env.Payload, env.Done + } + } + + return msg, nil +} + func eventloopSendJson(ctx context.Context, backend *EventloopBackend, send chan any) { for { select { @@ -49,12 +62,18 @@ func eventloopSendJson(ctx context.Context, backend *EventloopBackend, send chan if !ok { return } - logger.Logger().Debug("Sending JSON message", zap.Any("message", msg)) - err := backend.SendJson(ctx, msg) + + payload, done := unwrapSendEnvelope(msg) + + logger.Logger().Debug("Sending JSON message", zap.Any("message", payload)) + err := backend.SendJson(ctx, payload) if err != nil { logger.Logger().Error("Failed to send JSON", zap.Error(err)) return } + if done != nil { + close(done) + } } } } 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..f9c8e46 --- /dev/null +++ b/packages/bot/eventloop/instructions/update_binary.go @@ -0,0 +1,159 @@ +package instructions + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "syscall" + "time" + + "github.com/Alliance-Algorithm/rmcs-actions/packages/bot/eventloop/share" + "github.com/Alliance-Algorithm/rmcs-actions/packages/bot/lib" + "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. +// sanitizeURL returns a host/path summary with query parameters stripped to +// avoid leaking presigned URL credentials into logs. +func sanitizeURL(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "" + } + return u.Host + u.Path +} + +func UpdateBinaryAction(ctx context.Context, req UpdateBinaryRequest) UpdateBinaryResponse { + logger.Logger().Info("UpdateBinaryAction called", zap.String("artifact_url", sanitizeURL(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. + httpClient := &http.Client{Timeout: 30 * time.Second} + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, req.ArtifactUrl, nil) + if err != nil { + cleanup() + return UpdateBinaryResponse{Status: "error", Message: fmt.Sprintf("failed to create request: %v", err)} + } + resp, err := httpClient.Do(httpReq) + 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 the WebSocket response has been flushed. + // WsSendDoneCtxKey carries a channel that is closed by the eventloop + // send goroutine once wsjson.Write completes for this response. + done, _ := ctx.Value(lib.WsSendDoneCtxKey{}).(chan struct{}) + go func() { + if done != nil { + <-done + } + 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..."} +} diff --git a/packages/bot/eventloop/share/action.go b/packages/bot/eventloop/share/action.go index 160573a..ae62871 100644 --- a/packages/bot/eventloop/share/action.go +++ b/packages/bot/eventloop/share/action.go @@ -40,8 +40,14 @@ func WrapResponseAction[T any, O any](action ResponseAction[T, O]) lib.SessionAc } } - response := (action)(ctx, req) + done := make(chan struct{}) + actionCtx := context.WithValue(ctx, lib.WsSendDoneCtxKey{}, done) + + response := (action)(actionCtx, req) wrapped := NewMessage(ctx, NewResponse(response)) - ctx.Value(lib.WsWriterCtxKey{}).(chan any) <- wrapped + ctx.Value(lib.WsWriterCtxKey{}).(chan any) <- lib.SendEnvelope{ + Payload: wrapped, + Done: done, + } } } diff --git a/packages/bot/lib/context.go b/packages/bot/lib/context.go index 49482b1..f7a56f9 100644 --- a/packages/bot/lib/context.go +++ b/packages/bot/lib/context.go @@ -1,7 +1,17 @@ package lib +// SendEnvelope wraps a message sent through the WsWriter channel with +// an optional Done channel. When Done is non-nil, the eventloop send +// goroutine closes it after a successful WebSocket write, allowing the +// sender to block until the message has been flushed. +type SendEnvelope struct { + Payload any + Done chan struct{} +} + type RobotIdCtxKey struct{} type SessionIdCtxKey struct{} type WsWriterCtxKey struct{} type WsReaderCtxKey struct{} type ResponseReaderCtxKey struct{} +type WsSendDoneCtxKey struct{} diff --git a/packages/service/src/api/action.rs b/packages/service/src/api/action.rs index 5e70ffd..2d221b5 100644 --- a/packages/service/src/api/action.rs +++ b/packages/service/src/api/action.rs @@ -1,7 +1,11 @@ +use std::time::Duration; + +use futures_util::future::join_all; use poem_openapi::{ OpenApi, payload::{Json, PlainText}, }; +use tokio::time::timeout; use crate::{ api::{AnyDeserialize, ApiResult, GenericResponse}, @@ -11,6 +15,48 @@ use crate::{ pub mod fetch_network; pub mod set_robot_name; +pub mod update_binary; + +const UPDATE_BINARY_TIMEOUT: Duration = Duration::from_secs(60); + +fn parse_update_binary_response( + info: serde_json::Value, +) -> update_binary::UpdateBinaryResponse { + let status = info + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("error") + .to_string(); + let message = info + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("invalid response: missing fields") + .to_string(); + + update_binary::UpdateBinaryResponse { status, message } +} + +fn update_binary_error_response( + message: impl Into, +) -> update_binary::UpdateBinaryResponse { + update_binary::UpdateBinaryResponse { + status: "error".to_string(), + message: message.into(), + } +} + +fn update_binary_instruction_failure_response( + err: impl std::fmt::Display, +) -> update_binary::UpdateBinaryResponse { + update_binary_error_response(format!("instruction failed: {}", err)) +} + +fn update_binary_timeout_response() -> update_binary::UpdateBinaryResponse { + update_binary_error_response(format!( + "instruction timed out after {} seconds", + UPDATE_BINARY_TIMEOUT.as_secs() + )) +} pub struct ActionApi; @@ -126,4 +172,128 @@ 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 = timeout( + UPDATE_BINARY_TIMEOUT, + conn.value().send_instruction::( + Instruction::UpdateBinary { + artifact_url: request.artifact_url.clone(), + }, + ), + ) + .await; + match result { + Ok(Ok(info)) => Ok(Json(parse_update_binary_response(info))), + Ok(Err(err)) => { + log::error!( + "Failed to update binary on robot {}: {:?}", + request.robot_id, + err + ); + Ok(Json(update_binary_instruction_failure_response(err))) + } + Err(_) => { + log::error!( + "Timed out updating binary on robot {} after {} seconds", + request.robot_id, + UPDATE_BINARY_TIMEOUT.as_secs() + ); + Ok(Json(update_binary_timeout_response())) + } + } + } 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 { + let update_futures = CONNECTIONS.iter().map(|conn| { + let robot_id = conn.key().clone(); + let connection = conn.value().clone(); + let artifact_url = request.artifact_url.clone(); + + async move { + let result = timeout( + UPDATE_BINARY_TIMEOUT, + connection.send_instruction::( + Instruction::UpdateBinary { artifact_url }, + ), + ) + .await; + + (robot_id, result) + } + }); + + let mut results = Vec::new(); + let mut has_failure = false; + + for (robot_id, result) in join_all(update_futures).await { + match result { + Ok(Ok(info)) => { + let response = parse_update_binary_response(info); + if response.status != "post_update" { + has_failure = true; + } + results.push(update_binary::RobotUpdateResult { + robot_id, + status: response.status, + message: response.message, + }); + } + Ok(Err(err)) => { + has_failure = true; + log::error!( + "Failed to update binary on robot {}: {:?}", + robot_id, + err + ); + let response = + update_binary_instruction_failure_response(err); + results.push(update_binary::RobotUpdateResult { + robot_id, + status: response.status, + message: response.message, + }); + } + Err(_) => { + has_failure = true; + log::error!( + "Timed out updating binary on robot {} after {} seconds", + robot_id, + UPDATE_BINARY_TIMEOUT.as_secs() + ); + let response = update_binary_timeout_response(); + results.push(update_binary::RobotUpdateResult { + robot_id, + status: response.status, + message: response.message, + }); + } + } + } + + let overall_status = if has_failure { "partial_failure" } else { "ok" }; + + Ok(Json(update_binary::UpdateBinaryAllResponse { + status: overall_status.to_string(), + results, + })) + } } 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..df51200 --- /dev/null +++ b/packages/service/src/api/action/update_binary.rs @@ -0,0 +1,35 @@ +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, +} + +/// Per-robot result within a bulk update operation. +#[derive(Debug, Clone, Serialize, Deserialize, Object)] +pub struct RobotUpdateResult { + pub robot_id: String, + pub status: String, + pub message: String, +} + +/// Response for the update_binary_all endpoint, aggregating +/// per-robot results. +#[derive(Debug, Clone, Serialize, Deserialize, Object)] +pub struct UpdateBinaryAllResponse { + pub status: String, + pub results: Vec, +} 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() + }, + } +} 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..789288a --- /dev/null +++ b/packages/workstation/src/lib/api/action/update_binary.ts @@ -0,0 +1,87 @@ +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 const RobotUpdateResult = z.object({ + robot_id: z.string(), + status: z.string(), + message: z.string(), +}); +export type RobotUpdateResult = z.infer; + +export const ActionUpdateBinaryAllResponse = z.object({ + status: z.string(), + results: z.array(RobotUpdateResult), +}); +export type ActionUpdateBinaryAllResponse = 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) { + const detail = await response.text().catch(() => ''); + throw new Error( + `Error updating binary: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ''}`, + ); + } + + 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) { + const detail = await response.text().catch(() => ''); + throw new Error( + `Error updating all binaries: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ''}`, + ); + } + + const data = await response.json(); + return ActionUpdateBinaryAllResponse.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} +