//! In-container binary self-update — fetch a new `huskies` binary, atomically //! replace the on-disk executable, drain CRDT persistence, and re-exec. use crate::slog; use std::path::{Path, PathBuf}; // ── Binary fetch ───────────────────────────────────────────────────────────── /// Download a binary from `source_url` and atomically replace `target_path`. /// /// Writes to a sibling `.tmp` file first, then renames so the replacement is /// atomic on the same filesystem. Sets the execute bit before renaming so the /// file is runnable the moment it appears at the target location. pub async fn fetch_and_replace_binary(source_url: &str, target_path: &Path) -> Result<(), String> { slog!("[upgrade] Fetching binary from {source_url}"); let resp = reqwest::get(source_url) .await .map_err(|e| format!("Failed to fetch binary from {source_url}: {e}"))?; if !resp.status().is_success() { return Err(format!( "Binary fetch returned HTTP {}: {source_url}", resp.status() )); } let bytes = resp .bytes() .await .map_err(|e| format!("Failed to read binary response body: {e}"))?; if bytes.is_empty() { return Err("Binary fetch returned an empty body".to_string()); } // Write to a sibling temp file so the rename is atomic on the same FS. let tmp_path = sibling_tmp_path(target_path)?; std::fs::write(&tmp_path, &bytes) .map_err(|e| format!("Failed to write tmp binary to {}: {e}", tmp_path.display()))?; set_executable(&tmp_path)?; std::fs::rename(&tmp_path, target_path).map_err(|e| { format!( "Failed to rename {} → {}: {e}", tmp_path.display(), target_path.display() ) })?; slog!( "[upgrade] Binary replaced at {} ({} bytes)", target_path.display(), bytes.len() ); Ok(()) } // ── Full server upgrade (called from the running process) ───────────────── /// Fetch a new binary, atomically replace the current executable, drain CRDT /// persistence, and exit so Docker restarts the container with the new binary /// (the entrypoint launches `huskies` from PATH, which resolves to the /// replaced file). /// /// On failure it returns `Err(message)` so the caller can report the error /// while keeping the original server running. pub async fn upgrade_and_reexec(source_url: &str, project_root: &Path) -> Result { let target = resolve_target_path(); fetch_and_replace_binary(source_url, &target).await?; crate::rebuild::drain_and_exit(project_root, "upgrade").await } /// Verify the upgrade target can actually be replaced, without downloading /// anything: create and remove the same sibling tmp file the real swap uses. /// /// Called by the `/api/upgrade` handler BEFORE returning 202 so that a sled /// that cannot write its own binary (e.g. a container from an old image where /// the install dir is root-owned) fails the upgrade loudly and immediately /// instead of accepting the request and failing in the background. pub fn preflight_target_writable() -> Result<(), String> { let target = resolve_target_path(); let tmp = sibling_tmp_path(&target)?; std::fs::write(&tmp, b"").map_err(|e| { format!( "Upgrade target `{}` is not replaceable by this process: {e}. \ The container likely predates the /opt/huskies/bin layout — \ run `project-rebuild` on it once to migrate.", target.display() ) })?; let _ = std::fs::remove_file(&tmp); Ok(()) } // ── Helpers ─────────────────────────────────────────────────────────────── /// Resolve the path to replace with the new binary. /// /// Inside project containers the binary is installed at /// `/opt/huskies/bin/huskies` (a huskies-owned directory, so the atomic /// tmp-write + rename works without root) and `/usr/local/bin/huskies` is a /// symlink to it. That path is preferred over `current_exe()` because /// `current_exe()` can point at a stale location — e.g. /// `/workspace/target/release/huskies` after a historical in-container /// rebuild — which the entrypoint would never launch after a restart. /// /// Outside containers (no `/opt/huskies`), falls back to `current_exe()`. pub fn resolve_target_path() -> PathBuf { let container_path = PathBuf::from("/opt/huskies/bin/huskies"); if container_path.exists() { return container_path; } std::env::current_exe().unwrap_or_else(|_| PathBuf::from("/usr/local/bin/huskies")) } fn sibling_tmp_path(target: &Path) -> Result { let parent = target .parent() .ok_or_else(|| format!("Cannot determine parent dir of {}", target.display()))?; Ok(parent.join(".huskies_upgrade.tmp")) } #[cfg(unix)] fn set_executable(path: &Path) -> Result<(), String> { use std::os::unix::fs::PermissionsExt; let meta = std::fs::metadata(path).map_err(|e| format!("Cannot stat {}: {e}", path.display()))?; let mut perms = meta.permissions(); perms.set_mode(0o755); std::fs::set_permissions(path, perms) .map_err(|e| format!("Cannot chmod {}: {e}", path.display())) } #[cfg(not(unix))] fn set_executable(_path: &Path) -> Result<(), String> { Ok(()) } // ── Tests ───────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; /// Start a tiny HTTP server in the background that serves `content` at `/`. async fn serve_bytes(content: Vec) -> (u16, tokio::task::JoinHandle<()>) { use std::sync::Arc; let content = Arc::new(content); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let handle = tokio::spawn(async move { loop { let Ok((mut stream, _)) = listener.accept().await else { break; }; let content = Arc::clone(&content); tokio::spawn(async move { use tokio::io::AsyncWriteExt; // Drain the HTTP request (ignore it). let mut buf = [0u8; 4096]; let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await; // Write a minimal HTTP/1.1 200 response. let header = format!( "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/octet-stream\r\n\r\n", content.len() ); let _ = stream.write_all(header.as_bytes()).await; let _ = stream.write_all(&content).await; }); } }); (port, handle) } #[tokio::test] async fn fetch_and_replace_binary_downloads_and_replaces() { let dir = tempfile::tempdir().unwrap(); let target = dir.path().join("huskies"); std::fs::write(&target, b"old binary").unwrap(); let content = b"new binary content v0.99.0".to_vec(); let (port, _srv) = serve_bytes(content.clone()).await; let url = format!("http://127.0.0.1:{port}/huskies"); fetch_and_replace_binary(&url, &target).await.unwrap(); let on_disk = std::fs::read(&target).unwrap(); assert_eq!( on_disk, content, "target must contain the downloaded content" ); } #[tokio::test] async fn fetch_and_replace_binary_sets_executable_bit() { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let target = dir.path().join("huskies"); std::fs::write(&target, b"old").unwrap(); let (port, _srv) = serve_bytes(b"#!/bin/sh\nexit 0".to_vec()).await; let url = format!("http://127.0.0.1:{port}/huskies"); fetch_and_replace_binary(&url, &target).await.unwrap(); let mode = std::fs::metadata(&target).unwrap().permissions().mode(); assert!(mode & 0o111 != 0, "binary must be executable after upgrade"); } } #[tokio::test] async fn fetch_and_replace_binary_empty_body_is_error() { let dir = tempfile::tempdir().unwrap(); let target = dir.path().join("huskies"); std::fs::write(&target, b"old").unwrap(); let (port, _srv) = serve_bytes(vec![]).await; let url = format!("http://127.0.0.1:{port}/huskies"); let err = fetch_and_replace_binary(&url, &target).await.unwrap_err(); assert!( err.contains("empty"), "expected empty-body error, got: {err}" ); // Original must be untouched (rename never happened). let on_disk = std::fs::read(&target).unwrap(); assert_eq!(on_disk, b"old"); } #[tokio::test] async fn fetch_and_replace_binary_unreachable_url_is_error() { let dir = tempfile::tempdir().unwrap(); let target = dir.path().join("huskies"); let err = fetch_and_replace_binary("http://127.0.0.1:1/huskies", &target) .await .unwrap_err(); assert!(!err.is_empty(), "expected a non-empty error"); } #[tokio::test] async fn persisted_ops_count_does_not_decrease_after_flush() { // Initialise an in-process CRDT, flush it, and verify the persisted // count is stable (AC 5 — no ops lost across upgrade). crate::crdt_state::init_for_test(); let before = crate::crdt_state::dump_crdt_state(None).persisted_ops_count; crate::crdt_state::flush_persistence(std::time::Duration::from_millis(200)).await; let after = crate::crdt_state::dump_crdt_state(None).persisted_ops_count; assert!( after >= before, "persisted_ops_count must not decrease after flush: before={before} after={after}" ); } }