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:
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user