diff --git a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs index da87f7d6..a3454afe 100644 --- a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs +++ b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs @@ -503,6 +503,49 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( return; } + // In gateway mode, handle the `release` command: build the sled binary in + // the builder container and publish it to the artifact store. + if ctx.is_gateway() + && super::super::super::release::extract_release_command( + &user_message, + &ctx.services.bot_name, + ctx.matrix_user_id.as_str(), + ) + { + slog!("[matrix-bot] Handling 'release' from {sender}"); + let response = if let Some(ref store) = ctx.gateway_projects_store { + let transport = Arc::clone(&ctx.transport); + let bot_sent = Arc::clone(&ctx.bot_sent_event_ids); + let room = room_id_str.clone(); + super::super::super::release::handle_release(store, |phase_msg| { + let transport = Arc::clone(&transport); + let bot_sent = Arc::clone(&bot_sent); + let room = room.clone(); + async move { + let html = markdown_to_html(&phase_msg); + if let Ok(msg_id) = transport.send_message(&room, &phase_msg, &html).await + && let Ok(event_id) = msg_id.parse() + { + bot_sent.lock().await.insert(event_id); + } + } + }) + .await + } else { + "Gateway projects store unavailable — cannot release.".to_string() + }; + let html = markdown_to_html(&response); + if let Ok(msg_id) = ctx + .transport + .send_message(&room_id_str, &response, &html) + .await + && let Ok(event_id) = msg_id.parse() + { + ctx.bot_sent_event_ids.lock().await.insert(event_id); + } + return; + } + // In gateway mode, handle the `upgrade []` command to upgrade a // sled's binary in-container, streaming phase markers to the room. if ctx.is_gateway() diff --git a/server/src/chat/transport/matrix/mod.rs b/server/src/chat/transport/matrix/mod.rs index dc195b04..c5922444 100644 --- a/server/src/chat/transport/matrix/mod.rs +++ b/server/src/chat/transport/matrix/mod.rs @@ -37,6 +37,8 @@ pub mod project_rebuild; pub mod projects; /// Rebuild command — triggers a server rebuild/restart via a bot command. pub mod rebuild; +/// `release` gateway chat command — build the sled binary and publish it. +pub mod release; /// Reset command — handles `!reset` bot commands to restart the server state. pub mod reset; /// rmtree command — handles `!rmtree` bot commands to remove worktrees. diff --git a/server/src/chat/transport/matrix/release.rs b/server/src/chat/transport/matrix/release.rs new file mode 100644 index 00000000..bc5d92c7 --- /dev/null +++ b/server/src/chat/transport/matrix/release.rs @@ -0,0 +1,277 @@ +//! `release` gateway chat command — build the sled binary and publish it. +//! +//! Usage (gateway mode only): `{bot} release` +//! +//! Builds a Linux binary inside the registered sled that carries the huskies +//! source tree (found by marker files, conventionally the `huskies` project), +//! then copies it into the gateway's artifact store (`~/.huskies/artifacts/`) +//! together with a `.hash` sidecar recording the source git commit. After a +//! successful `release`, `upgrade all` distributes the artifact to the fleet. +//! +//! The build uses a dedicated `CARGO_TARGET_DIR` (`target/sled-release` on the +//! bind mount) so container release builds never clobber host builds in +//! `target/release`, and the incremental cache survives container replacement. + +use crate::service::gateway::config::ProjectEntry; +use std::collections::BTreeMap; +use std::future::Future; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Cargo target dir (container path) for release builds inside the sled. +const SLED_TARGET_DIR: &str = "/workspace/target/sled-release"; + +/// Parse a `release` command from a raw message body. +/// +/// Returns `true` when the stripped message is exactly `release`. +pub fn extract_release_command(message: &str, bot_name: &str, bot_user_id: &str) -> bool { + let stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id); + let trimmed = stripped + .trim() + .trim_start_matches(|c: char| !c.is_alphanumeric()); + trimmed.eq_ignore_ascii_case("release") +} + +/// Find the registered project that carries the huskies source tree. +/// +/// A project qualifies when its `host_path` contains both a workspace +/// `Cargo.toml` and a `server/` directory. Returns `(name, host_path)` for +/// the first match in name order. +pub fn find_builder_project( + projects: &BTreeMap, +) -> Option<(String, PathBuf)> { + for (name, entry) in projects { + let Some(ref host_path) = entry.host_path else { + continue; + }; + let root = PathBuf::from(host_path); + if root.join("Cargo.toml").exists() && root.join("server").is_dir() { + return Some((name.clone(), root)); + } + } + None +} + +/// Build the sled binary in the builder container and publish it to the +/// artifact store. +/// +/// Streams progress via `send_phase`. On success the artifact and its +/// `.hash` sidecar are in place and the reply names the commit; on failure +/// the reply carries the build error tail and nothing is published. +pub async fn handle_release( + projects_store: &Arc>>, + send_phase: F, +) -> String +where + F: Fn(String) -> Fut, + Fut: Future, +{ + // ── Locate the builder sled ────────────────────────────────────────────── + let (builder_name, source_root) = { + let projects = projects_store.read().await; + match find_builder_project(&projects) { + Some(found) => found, + None => { + return "No registered project carries the huskies source tree \ + (looked for `Cargo.toml` + `server/` under each project's \ + host_path). Cannot build a release." + .to_string(); + } + } + }; + let container_name = format!("huskies-{builder_name}"); + + // ── Record the source commit before building ──────────────────────────── + let git_hash = match tokio::process::Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .current_dir(&source_root) + .output() + .await + { + Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(), + _ => { + return format!( + "Cannot read git HEAD in `{}` — is it a git checkout?", + source_root.display() + ); + } + }; + + // ── Build inside the sled container ────────────────────────────────────── + send_phase(format!( + "[1/2] building {git_hash} in `{container_name}`\u{2026} (a few minutes)" + )) + .await; + + let build = tokio::process::Command::new("docker") + .args([ + "exec", + "-w", + "/workspace", + "-e", + &format!("CARGO_TARGET_DIR={SLED_TARGET_DIR}"), + &container_name, + "cargo", + "build", + "--release", + "-p", + "huskies", + ]) + .output() + .await; + + let build = match build { + Ok(out) => out, + Err(e) => return format!("Release failed at **[1/2] build**: docker exec failed: {e}"), + }; + if !build.status.success() { + let stderr = String::from_utf8_lossy(&build.stderr); + let tail: Vec<&str> = stderr.lines().rev().take(30).collect(); + let tail: Vec<&str> = tail.into_iter().rev().collect(); + return format!( + "Release failed at **[1/2] build** (commit {git_hash}):\n```\n{}\n```", + tail.join("\n") + ); + } + + // ── Publish to the artifact store ──────────────────────────────────────── + send_phase("[2/2] publishing artifact\u{2026}".to_string()).await; + + // The container's /workspace is the host source_root bind mount, so the + // built binary is directly readable on the host. + let built = source_root + .join("target/sled-release/release") + .join("huskies"); + if !built.exists() { + return format!( + "Release failed at **[2/2] publish**: build succeeded but no binary at `{}`.", + built.display() + ); + } + + let artifacts = crate::http::artifacts_dir(); + if let Err(e) = std::fs::create_dir_all(&artifacts) { + return format!( + "Release failed at **[2/2] publish**: cannot create `{}`: {e}", + artifacts.display() + ); + } + + let artifact_path = artifacts.join(crate::http::SLED_ARTIFACT_NAME); + // Write to a sibling tmp file then rename so a concurrent download from + // /api/artifacts never sees a half-written binary. + let tmp_path = artifacts.join(".publish.tmp"); + if let Err(e) = std::fs::copy(&built, &tmp_path) { + return format!("Release failed at **[2/2] publish**: copy failed: {e}"); + } + if let Err(e) = std::fs::rename(&tmp_path, &artifact_path) { + return format!("Release failed at **[2/2] publish**: rename failed: {e}"); + } + + let hash_path = artifacts.join(format!("{}.hash", crate::http::SLED_ARTIFACT_NAME)); + if let Err(e) = std::fs::write(&hash_path, &git_hash) { + return format!( + "Artifact published but writing the hash sidecar failed: {e}. \ + `upgrade all` will skip convergence verification." + ); + } + + let size_mb = std::fs::metadata(&artifact_path) + .map(|m| m.len() / (1024 * 1024)) + .unwrap_or(0); + + format!( + "Release **{git_hash}** published ({size_mb} MB at `{}`).\n\ + Say `upgrade all` to roll it out to the fleet.", + artifact_path.display() + ) +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(host_path: Option<&str>) -> ProjectEntry { + ProjectEntry { + url: Some("http://127.0.0.1:3101".into()), + auth_token: None, + ssh_port: None, + host_path: host_path.map(String::from), + } + } + + // ── extract_release_command ─────────────────────────────────────────────── + + #[test] + fn extract_release_basic() { + assert!(extract_release_command( + "Timmy release", + "Timmy", + "@timmy:home" + )); + assert!(extract_release_command( + "@timmy: RELEASE", + "Timmy", + "@timmy:home" + )); + } + + #[test] + fn extract_release_rejects_other_commands() { + assert!(!extract_release_command( + "Timmy status", + "Timmy", + "@timmy:home" + )); + assert!(!extract_release_command( + "Timmy release the hounds", + "Timmy", + "@timmy:home" + )); + } + + // ── find_builder_project ───────────────────────────────────────────────── + + #[test] + fn find_builder_matches_huskies_source_markers() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Cargo.toml"), "[workspace]").unwrap(); + std::fs::create_dir_all(dir.path().join("server")).unwrap(); + + let mut projects = BTreeMap::new(); + projects.insert("other".to_string(), entry(Some("/nonexistent/xyz"))); + projects.insert( + "huskies".to_string(), + entry(Some(dir.path().to_str().unwrap())), + ); + + let found = find_builder_project(&projects); + assert_eq!(found.map(|(n, _)| n), Some("huskies".to_string())); + } + + #[test] + fn find_builder_none_when_no_source_tree() { + let dir = tempfile::tempdir().unwrap(); // no Cargo.toml, no server/ + let mut projects = BTreeMap::new(); + projects.insert("app".to_string(), entry(Some(dir.path().to_str().unwrap()))); + projects.insert("no-path".to_string(), entry(None)); + + assert!(find_builder_project(&projects).is_none()); + } + + // ── handle_release validation ──────────────────────────────────────────── + + #[tokio::test] + async fn release_without_builder_project_reports_error() { + let store: Arc>> = + Arc::new(RwLock::new(BTreeMap::new())); + let msg = handle_release(&store, |_m| async {}).await; + assert!( + msg.contains("No registered project"), + "should explain no builder found: {msg}" + ); + } +}