huskies: merge 1148 story Per-sled upgrade chat command using huskies upgrade (1138), serial-locked
This commit is contained in:
@@ -248,6 +248,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
"new",
|
"new",
|
||||||
"config",
|
"config",
|
||||||
"project-rebuild",
|
"project-rebuild",
|
||||||
|
"upgrade",
|
||||||
];
|
];
|
||||||
|
|
||||||
let stripped = crate::chat::util::strip_bot_mention(
|
let stripped = crate::chat::util::strip_bot_mention(
|
||||||
@@ -467,6 +468,84 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
return;
|
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()
|
||||||
|
&& let Some(upgrade_cmd) = super::super::super::sled_upgrade::extract_upgrade_command(
|
||||||
|
&user_message,
|
||||||
|
&ctx.services.bot_name,
|
||||||
|
ctx.matrix_user_id.as_str(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
match upgrade_cmd {
|
||||||
|
super::super::super::sled_upgrade::UpgradeCommand::ListProjects => {
|
||||||
|
slog!("[matrix-bot] Handling 'upgrade' list-projects from {sender}");
|
||||||
|
let response = if let Some(ref store) = ctx.gateway_projects_store {
|
||||||
|
super::super::super::sled_upgrade::handle_upgrade_list_projects(store).await
|
||||||
|
} else {
|
||||||
|
"Gateway projects store unavailable.".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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super::super::super::sled_upgrade::UpgradeCommand::Upgrade { project } => {
|
||||||
|
slog!("[matrix-bot] Handling 'upgrade {project}' from {sender}");
|
||||||
|
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();
|
||||||
|
|
||||||
|
let response = super::super::super::sled_upgrade::handle_sled_upgrade(
|
||||||
|
&project,
|
||||||
|
store,
|
||||||
|
ctx.gateway_port,
|
||||||
|
|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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let msg = "Gateway projects store unavailable — cannot upgrade sled.";
|
||||||
|
let html = markdown_to_html(msg);
|
||||||
|
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, msg, &html).await
|
||||||
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
|
{
|
||||||
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for bot-level commands (help, status, ambient, …) before invoking
|
// Check for bot-level commands (help, status, ambient, …) before invoking
|
||||||
// the LLM. All commands are registered in commands.rs — no special-casing
|
// the LLM. All commands are registered in commands.rs — no special-casing
|
||||||
// needed here.
|
// needed here.
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ pub mod rebuild;
|
|||||||
pub mod reset;
|
pub mod reset;
|
||||||
/// rmtree command — handles `!rmtree` bot commands to remove worktrees.
|
/// rmtree command — handles `!rmtree` bot commands to remove worktrees.
|
||||||
pub mod rmtree;
|
pub mod rmtree;
|
||||||
|
/// `upgrade [<project>]` gateway chat command — streaming per-sled binary upgrade.
|
||||||
|
pub mod sled_upgrade;
|
||||||
/// Start command — handles `!start` bot commands to launch agents on stories.
|
/// Start command — handles `!start` bot commands to launch agents on stories.
|
||||||
pub mod start;
|
pub mod start;
|
||||||
/// Matrix `ChatTransport` implementation wrapping the Matrix SDK client.
|
/// Matrix `ChatTransport` implementation wrapping the Matrix SDK client.
|
||||||
|
|||||||
@@ -0,0 +1,478 @@
|
|||||||
|
//! `upgrade [<project>]` gateway chat command — streaming sled binary upgrade.
|
||||||
|
//!
|
||||||
|
//! Usage (gateway mode only):
|
||||||
|
//! - `{bot} upgrade <project>` — upgrade the named sled's binary in-container.
|
||||||
|
//! - `{bot} upgrade` — list registered projects (shows what can be targeted).
|
||||||
|
//!
|
||||||
|
//! The gateway orchestrates the upgrade in four phases, streaming a marker to
|
||||||
|
//! the chat room at each step:
|
||||||
|
//! 1. `[1/4] downloading` — POSTs to `{sled_url}/api/upgrade`; sled starts download.
|
||||||
|
//! 2. `[2/4] swapping binary` — gateway received 202; sled atomically renamed the binary.
|
||||||
|
//! 3. `[3/4] restarting sled` — sled re-execs with the new binary; HTTP goes dark briefly.
|
||||||
|
//! 4. `[4/4] reconnected to gateway` — sled's `/health` probe is responding again.
|
||||||
|
//!
|
||||||
|
//! Concurrent `upgrade` invocations are serialised via a global async mutex so
|
||||||
|
//! that two simultaneous upgrades cannot interleave their phase markers or race
|
||||||
|
//! on the sled restart.
|
||||||
|
|
||||||
|
use crate::service::gateway::config::ProjectEntry;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
|
// ── Serial lock ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static UPGRADE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn upgrade_lock() -> &'static Mutex<()> {
|
||||||
|
UPGRADE_LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command parsing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A parsed `upgrade` command.
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub enum UpgradeCommand {
|
||||||
|
/// `upgrade <project>` — upgrade the named sled.
|
||||||
|
Upgrade {
|
||||||
|
/// The project/sled name to upgrade.
|
||||||
|
project: String,
|
||||||
|
},
|
||||||
|
/// `upgrade` with no argument — list available projects.
|
||||||
|
ListProjects,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an `upgrade [<project>]` command from a raw message body.
|
||||||
|
///
|
||||||
|
/// Strips the bot mention prefix and checks whether the first word is `upgrade`.
|
||||||
|
/// Returns `None` when the message is not an upgrade command.
|
||||||
|
pub fn extract_upgrade_command(
|
||||||
|
message: &str,
|
||||||
|
bot_name: &str,
|
||||||
|
bot_user_id: &str,
|
||||||
|
) -> Option<UpgradeCommand> {
|
||||||
|
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());
|
||||||
|
|
||||||
|
let (cmd, rest) = match trimmed.split_once(char::is_whitespace) {
|
||||||
|
Some((c, r)) => (c, r.trim()),
|
||||||
|
None => (trimmed, ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !cmd.eq_ignore_ascii_case("upgrade") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rest.is_empty() {
|
||||||
|
Some(UpgradeCommand::ListProjects)
|
||||||
|
} else {
|
||||||
|
Some(UpgradeCommand::Upgrade {
|
||||||
|
project: rest.split_whitespace().next().unwrap_or(rest).to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handlers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// List available projects when `upgrade` is invoked without an argument.
|
||||||
|
///
|
||||||
|
/// Returns a Markdown string enumerating the registered project names so the
|
||||||
|
/// user knows which targets are valid for `upgrade <project>`.
|
||||||
|
pub async fn handle_upgrade_list_projects(
|
||||||
|
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||||
|
) -> String {
|
||||||
|
let projects = projects_store.read().await;
|
||||||
|
if projects.is_empty() {
|
||||||
|
return "No projects are currently registered with the gateway.".to_string();
|
||||||
|
}
|
||||||
|
let names: Vec<&String> = projects.keys().collect();
|
||||||
|
let list = names
|
||||||
|
.iter()
|
||||||
|
.map(|n| format!("- `{n}`"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
format!("Registered projects (use `upgrade <project>` to upgrade one):\n{list}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upgrade a named sled by streaming phase markers to the chat room.
|
||||||
|
///
|
||||||
|
/// Acquires the global upgrade lock to serialise concurrent invocations. Each
|
||||||
|
/// phase is announced by calling `send_phase` before the corresponding work
|
||||||
|
/// begins. On any failure, an error message is returned and the previous
|
||||||
|
/// binary remains active on the sled.
|
||||||
|
///
|
||||||
|
/// `gateway_port` is used to derive the default binary source URL
|
||||||
|
/// (`http://gateway:<port>/api/huskies-binary`) when neither
|
||||||
|
/// `HUSKIES_GATEWAY_BINARY_URL` nor `--source` is set.
|
||||||
|
pub async fn handle_sled_upgrade<F, Fut>(
|
||||||
|
project: &str,
|
||||||
|
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||||
|
gateway_port: Option<u16>,
|
||||||
|
send_phase: F,
|
||||||
|
) -> String
|
||||||
|
where
|
||||||
|
F: Fn(String) -> Fut,
|
||||||
|
Fut: Future<Output = ()>,
|
||||||
|
{
|
||||||
|
// ── Look up project URL ──────────────────────────────────────────────────
|
||||||
|
let sled_url = {
|
||||||
|
let projects = projects_store.read().await;
|
||||||
|
match projects.get(project).and_then(|e| e.url.clone()) {
|
||||||
|
Some(u) => u,
|
||||||
|
None => {
|
||||||
|
let available: Vec<&String> = projects.keys().collect();
|
||||||
|
return format!(
|
||||||
|
"Project `{project}` not found. Registered projects: {}",
|
||||||
|
available
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Resolve binary source URL ────────────────────────────────────────────
|
||||||
|
let source_url = std::env::var("HUSKIES_GATEWAY_BINARY_URL").unwrap_or_else(|_| {
|
||||||
|
format!(
|
||||||
|
"http://gateway:{}/api/huskies-binary",
|
||||||
|
gateway_port.unwrap_or(3000)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Acquire serial lock ──────────────────────────────────────────────────
|
||||||
|
let _lock = upgrade_lock().lock().await;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// ── Phase 1: downloading ─────────────────────────────────────────────────
|
||||||
|
send_phase("[1/4] downloading\u{2026}".to_string()).await;
|
||||||
|
|
||||||
|
let upgrade_url = format!("{}/api/upgrade", sled_url.trim_end_matches('/'));
|
||||||
|
let body = serde_json::json!({ "source_url": source_url });
|
||||||
|
|
||||||
|
let resp = match client.post(&upgrade_url).json(&body).send().await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
return format!(
|
||||||
|
"Upgrade failed at **[1/4] downloading**: could not reach sled at `{upgrade_url}`.\n\
|
||||||
|
Error: {e}\n\n\
|
||||||
|
The previous version remains active."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !resp.status().is_success() && resp.status().as_u16() != 202 {
|
||||||
|
let status = resp.status();
|
||||||
|
let body_text = resp.text().await.unwrap_or_default();
|
||||||
|
return format!(
|
||||||
|
"Upgrade failed at **[1/4] downloading**: sled returned HTTP {status}.\n\
|
||||||
|
Response: {body_text}\n\n\
|
||||||
|
The previous version remains active."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2: swapping binary ─────────────────────────────────────────────
|
||||||
|
// The sled accepted the request (202) and is downloading + atomically
|
||||||
|
// replacing the binary in the background.
|
||||||
|
send_phase("[2/4] swapping binary\u{2026}".to_string()).await;
|
||||||
|
|
||||||
|
// ── Phase 3: restarting sled ─────────────────────────────────────────────
|
||||||
|
// The sled will re-exec momentarily; announce before the health loop.
|
||||||
|
send_phase("[3/4] restarting sled\u{2026}".to_string()).await;
|
||||||
|
|
||||||
|
// ── Wait for sled to come back up ────────────────────────────────────────
|
||||||
|
let health_url = format!("{}/health", sled_url.trim_end_matches('/'));
|
||||||
|
// Give the sled a few seconds to start the download + re-exec before polling.
|
||||||
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
|
let reconnected = wait_for_health(&client, &health_url, 120).await;
|
||||||
|
if !reconnected {
|
||||||
|
return format!(
|
||||||
|
"Upgrade failed at **[4/4] reconnected to gateway**: sled at `{sled_url}` did not \
|
||||||
|
come back online within 120 seconds after the upgrade was triggered.\n\n\
|
||||||
|
Check the container logs: `docker logs huskies-{project}`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 4: reconnected ─────────────────────────────────────────────────
|
||||||
|
send_phase("[4/4] reconnected to gateway".to_string()).await;
|
||||||
|
|
||||||
|
// ── Report new version ───────────────────────────────────────────────────
|
||||||
|
let version = fetch_sled_version(&client, &sled_url).await;
|
||||||
|
format!("{project} upgraded to version {version}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Poll `GET {health_url}` every 3 seconds until it returns 200 or `timeout_secs` elapses.
|
||||||
|
///
|
||||||
|
/// Returns `true` when the probe succeeds, `false` on timeout.
|
||||||
|
async fn wait_for_health(client: &reqwest::Client, health_url: &str, timeout_secs: u64) -> bool {
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||||
|
let poll = Duration::from_secs(3);
|
||||||
|
loop {
|
||||||
|
match client.get(health_url).send().await {
|
||||||
|
Ok(r) if r.status().is_success() => return true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(poll).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the running version from the sled's `get_version` MCP tool.
|
||||||
|
///
|
||||||
|
/// Returns the version string on success, or `"unknown"` on any error so the
|
||||||
|
/// final chat reply is still meaningful.
|
||||||
|
async fn fetch_sled_version(client: &reqwest::Client, sled_url: &str) -> String {
|
||||||
|
let mcp_url = format!("{}/mcp", sled_url.trim_end_matches('/'));
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "get_version",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let resp = match client.post(&mcp_url).json(&body).send().await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return "unknown".to_string(),
|
||||||
|
};
|
||||||
|
let val: serde_json::Value = match resp.json().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return "unknown".to_string(),
|
||||||
|
};
|
||||||
|
// MCP tools/call response: result.content[0].text is a JSON string.
|
||||||
|
let text = val
|
||||||
|
.pointer("/result/content/0/text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if text.is_empty() {
|
||||||
|
return "unknown".to_string();
|
||||||
|
}
|
||||||
|
serde_json::from_str::<serde_json::Value>(text)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.get("version").and_then(|v| v.as_str()).map(String::from))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── extract_upgrade_command ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_upgrade_with_project() {
|
||||||
|
let cmd = extract_upgrade_command("Timmy upgrade huskies-server", "Timmy", "@timmy:home");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(UpgradeCommand::Upgrade {
|
||||||
|
project: "huskies-server".to_string()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_upgrade_no_arg_is_list() {
|
||||||
|
let cmd = extract_upgrade_command("Timmy upgrade", "Timmy", "@timmy:home");
|
||||||
|
assert_eq!(cmd, Some(UpgradeCommand::ListProjects));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_upgrade_with_full_user_id() {
|
||||||
|
let cmd = extract_upgrade_command("@timmy:home upgrade myapp", "Timmy", "@timmy:home");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(UpgradeCommand::Upgrade {
|
||||||
|
project: "myapp".to_string()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_non_upgrade_returns_none() {
|
||||||
|
let cmd = extract_upgrade_command("Timmy status", "Timmy", "@timmy:home");
|
||||||
|
assert!(cmd.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_upgrade_case_insensitive() {
|
||||||
|
let cmd = extract_upgrade_command("Timmy UPGRADE alpha", "Timmy", "@timmy:home");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(UpgradeCommand::Upgrade {
|
||||||
|
project: "alpha".to_string()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── handle_upgrade_list_projects ─────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_projects_empty_store() {
|
||||||
|
let store: Arc<RwLock<BTreeMap<String, ProjectEntry>>> =
|
||||||
|
Arc::new(RwLock::new(BTreeMap::new()));
|
||||||
|
let msg = handle_upgrade_list_projects(&store).await;
|
||||||
|
assert!(
|
||||||
|
msg.contains("No projects"),
|
||||||
|
"empty store should say no projects: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_projects_shows_names() {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
map.insert(
|
||||||
|
"alpha".to_string(),
|
||||||
|
ProjectEntry {
|
||||||
|
url: Some("http://localhost:3001".into()),
|
||||||
|
auth_token: None,
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"beta".to_string(),
|
||||||
|
ProjectEntry {
|
||||||
|
url: Some("http://localhost:3002".into()),
|
||||||
|
auth_token: None,
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let store = Arc::new(RwLock::new(map));
|
||||||
|
let msg = handle_upgrade_list_projects(&store).await;
|
||||||
|
assert!(msg.contains("alpha"), "should list alpha: {msg}");
|
||||||
|
assert!(msg.contains("beta"), "should list beta: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── handle_sled_upgrade validation ───────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upgrade_unknown_project_returns_error() {
|
||||||
|
let store: Arc<RwLock<BTreeMap<String, ProjectEntry>>> =
|
||||||
|
Arc::new(RwLock::new(BTreeMap::new()));
|
||||||
|
let phases: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(vec![]);
|
||||||
|
let result = handle_sled_upgrade("nonexistent", &store, Some(3000), |msg| {
|
||||||
|
phases.lock().unwrap().push(msg);
|
||||||
|
async {}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
result.contains("not found"),
|
||||||
|
"should say not found: {result}"
|
||||||
|
);
|
||||||
|
// No phase markers should have been emitted before the validation error.
|
||||||
|
assert!(
|
||||||
|
phases.lock().unwrap().is_empty(),
|
||||||
|
"no phases should be emitted for unknown project"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upgrade_project_with_no_url_fails_gracefully() {
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
map.insert(
|
||||||
|
"myapp".to_string(),
|
||||||
|
ProjectEntry {
|
||||||
|
url: None,
|
||||||
|
auth_token: None,
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let store = Arc::new(RwLock::new(map));
|
||||||
|
let result = handle_sled_upgrade("myapp", &store, Some(3000), |_msg| async {}).await;
|
||||||
|
assert!(
|
||||||
|
result.contains("not found"),
|
||||||
|
"project with no URL should say not found: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upgrade_unreachable_sled_reports_failure() {
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
map.insert(
|
||||||
|
"myapp".to_string(),
|
||||||
|
ProjectEntry {
|
||||||
|
url: Some("http://127.0.0.1:1".into()), // port 1 is never listening
|
||||||
|
auth_token: None,
|
||||||
|
ssh_port: None,
|
||||||
|
host_path: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let store = Arc::new(RwLock::new(map));
|
||||||
|
let phases: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(vec![]);
|
||||||
|
let result = handle_sled_upgrade("myapp", &store, Some(3000), |msg| {
|
||||||
|
phases.lock().unwrap().push(msg);
|
||||||
|
async {}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
// Phase 1 marker must have been sent before the failed request.
|
||||||
|
let sent = phases.lock().unwrap().clone();
|
||||||
|
assert!(
|
||||||
|
sent.iter().any(|m| m.contains("[1/4]")),
|
||||||
|
"phase 1 marker must be sent: {sent:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("downloading") || result.contains("reach"),
|
||||||
|
"error should mention the failure: {result}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("previous version"),
|
||||||
|
"error should confirm old version is active: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── wait_for_health ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wait_for_health_immediate_success() {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
if let Ok((mut stream, _)) = listener.accept().await {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
|
||||||
|
let _ = stream
|
||||||
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!("http://127.0.0.1:{port}/health");
|
||||||
|
let ok = wait_for_health(&client, &url, 5).await;
|
||||||
|
assert!(ok, "should return true when health probe succeeds");
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wait_for_health_timeout() {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_millis(100))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
// Nothing listening on port 1.
|
||||||
|
let ok = wait_for_health(&client, "http://127.0.0.1:1/health", 1).await;
|
||||||
|
assert!(!ok, "should return false when health probe never succeeds");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user