Add release chat command — build sled binary and publish artifact

Finds the registered project carrying the huskies source tree, runs
`cargo build --release` inside its container (dedicated
CARGO_TARGET_DIR=target/sled-release so container builds stop
clobbering host target/release), then atomically publishes the binary
to ~/.huskies/artifacts/ with a .hash sidecar for convergence checks.

Full fleet redeploy is now chat-only: `release` then `upgrade all` —
no laptop access needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fHdm92yjvguPi2LiXfLB9
This commit is contained in:
Timmy
2026-07-15 16:27:32 +01:00
co-authored by Claude Fable 5
parent 18ba57a7b0
commit e8253a06b7
3 changed files with 322 additions and 0 deletions
@@ -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 [<project>]` command to upgrade a
// sled's binary in-container, streaming phase markers to the room.
if ctx.is_gateway()
+2
View File
@@ -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.
+277
View File
@@ -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<String, ProjectEntry>,
) -> 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<F, Fut>(
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
send_phase: F,
) -> String
where
F: Fn(String) -> Fut,
Fut: Future<Output = ()>,
{
// ── 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<RwLock<BTreeMap<String, ProjectEntry>>> =
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}"
);
}
}