279 lines
10 KiB
Rust
279 lines
10 KiB
Rust
//! `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),
|
|
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<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}"
|
|
);
|
|
}
|
|
}
|