//! `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), expected_node_id: None, } } // ── 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}" ); } }