//! `upgrade [|all]` gateway chat command — streaming sled binary upgrade. //! //! Usage (gateway mode only): //! - `{bot} upgrade ` — upgrade the named sled's binary in-container. //! - `{bot} upgrade all` — upgrade every registered sled in sequence. //! - `{bot} upgrade` — list registered projects (shows what can be targeted). //! //! The binary comes from the gateway's own artifact store //! (`~/.huskies/artifacts/`, published by the `release` command) — sleds never //! download from anywhere but their gateway. Agents running in a sled are //! killed by the restart; the pipeline's retry machinery picks the work up //! again, same as any other agent death. //! //! The gateway orchestrates each 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 exits cleanly; Docker restarts it with the new binary. //! 4. `[4/4] reconnected to gateway` — sled's `/health` probe is responding again. //! //! After reconnection the gateway polls `/api/version` and verifies the sled's //! reported git hash matches the published artifact's hash (when a `.hash` //! sidecar file exists). //! //! 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 crate::service::gateway::{IdentityCheck, check_identity, probe_identity}; use std::collections::BTreeMap; use std::future::Future; use std::path::Path; use std::sync::{Arc, OnceLock}; use std::time::Duration; use tokio::sync::{Mutex, RwLock}; /// Probe `/identity` on `sled_url`, verify it against the `expected_node_id` /// recorded for `project`, and capture a first-contact identity. /// /// Fails open (returns `Ok`) when the probe is unreachable or the responder /// has no signature (legacy sled) — the existing `/health`-based liveness /// check remains the primary reachability gate. Only a confirmed identity /// mismatch or an invalid signature is treated as a hard failure, since both /// mean a verifiably different — or untrustworthy — container answered. async fn verify_sled_identity( project: &str, sled_url: &str, projects_store: &Arc>>, config_dir: &Path, client: &reqwest::Client, ) -> Result<(), String> { let container_name = format!("huskies-{project}"); let nonce = crate::node_identity::generate_challenge(); let Some(response) = probe_identity(client, sled_url, &nonce).await else { return Ok(()); }; let expected = { let projects = projects_store.read().await; projects .get(project) .and_then(|e| e.expected_node_id.clone()) }; match check_identity(expected.as_deref(), &nonce, &response) { IdentityCheck::Match | IdentityCheck::MissingSignature => Ok(()), IdentityCheck::FirstContact { node_id } => { { let mut projects = projects_store.write().await; if let Some(entry) = projects.get_mut(project) { entry.expected_node_id = Some(node_id); } } let snapshot = projects_store.read().await.clone(); crate::service::gateway::io::save_config(&snapshot, config_dir).await; Ok(()) } IdentityCheck::Mismatch { responder_node_id } => Err(format!( "**identity mismatch** for `{container_name}` at `{sled_url}`: expected node_id \ `{}`, but the container that answered identified as `{responder_node_id}`. \ Refusing to proceed — this may not be the sled you expect.", expected.unwrap_or_default() )), IdentityCheck::InvalidSignature => Err(format!( "**identity verification failed** for `{container_name}` at `{sled_url}`: the \ `/identity` response signature did not verify. Refusing to proceed — the \ container's identity cannot be trusted." )), } } // ── Serial lock ──────────────────────────────────────────────────────────────── static UPGRADE_LOCK: OnceLock> = 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 ` — upgrade the named sled. Upgrade { /// The project/sled name to upgrade. project: String, }, /// `upgrade all` — upgrade every registered sled in sequence. UpgradeAll, /// `upgrade` with no argument — list available projects. ListProjects, } /// Parse an `upgrade []` 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 { 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() { return Some(UpgradeCommand::ListProjects); } let target = rest.split_whitespace().next().unwrap_or(rest); if target.eq_ignore_ascii_case("all") { Some(UpgradeCommand::UpgradeAll) } else { Some(UpgradeCommand::Upgrade { project: target.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 `. pub async fn handle_upgrade_list_projects( projects_store: &Arc>>, ) -> 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::>() .join("\n"); format!("Registered projects (use `upgrade ` to upgrade one):\n{list}") } /// Resolve the artifact source URL and expected git hash for an upgrade. /// /// The URL points at the gateway's own artifact endpoint via /// `host.docker.internal` (resolvable from inside sled containers). The /// expected hash comes from the `.hash` sidecar written by `release`, when /// present. `HUSKIES_GATEWAY_BINARY_URL` overrides the URL (no hash check). /// /// Returns `Err` with a user-facing message when no artifact has been /// published yet. pub(crate) fn resolve_artifact_source( gateway_port: Option, ) -> Result<(String, Option), String> { if let Ok(url) = std::env::var("HUSKIES_GATEWAY_BINARY_URL") { return Ok((url, None)); } let artifact_name = crate::http::SLED_ARTIFACT_NAME; let artifact_path = crate::http::artifacts_dir().join(artifact_name); if !artifact_path.exists() { return Err(format!( "No published artifact at `{}`. Run `release` first to build and publish one.", artifact_path.display() )); } let expected_hash = std::fs::read_to_string(crate::http::artifacts_dir().join(format!("{artifact_name}.hash"))) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); let url = format!( "http://host.docker.internal:{}/api/artifacts/{artifact_name}", gateway_port.unwrap_or(3000) ); Ok((url, expected_hash)) } /// Upgrade every registered sled in sequence, streaming per-sled phase markers. /// /// Returns a summary listing the outcome for each sled. pub async fn handle_upgrade_all( projects_store: &Arc>>, gateway_port: Option, config_dir: &Path, send_phase: F, ) -> String where F: Fn(String) -> Fut, Fut: Future, { let names: Vec = { let projects = projects_store.read().await; projects.keys().cloned().collect() }; if names.is_empty() { return "No projects are currently registered with the gateway.".to_string(); } // Fail fast before touching any sled if there is nothing to distribute. if let Err(e) = resolve_artifact_source(gateway_port) { return e; } let mut results: Vec = Vec::with_capacity(names.len()); for name in &names { let outcome = handle_sled_upgrade(name, projects_store, gateway_port, config_dir, |msg| { send_phase(format!("**{name}** {msg}")) }) .await; results.push(format!("- {name}: {outcome}")); } format!( "Upgrade sweep over {} sled(s) complete:\n{}", names.len(), results.join("\n") ) } /// 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. /// /// Agents running in the sled are killed by the restart; the pipeline's /// retry machinery re-queues their work. pub async fn handle_sled_upgrade( project: &str, projects_store: &Arc>>, gateway_port: Option, config_dir: &Path, send_phase: F, ) -> String where F: Fn(String) -> Fut, Fut: Future, { // ── 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::>() .join(", ") ); } } }; // ── Resolve binary source ──────────────────────────────────────────────── let (source_url, expected_hash) = match resolve_artifact_source(gateway_port) { Ok(v) => v, Err(e) => return e, }; run_sled_upgrade( project, &sled_url, &source_url, expected_hash, projects_store, config_dir, send_phase, ) .await } /// Run the four-phase upgrade against a sled whose source URL is already /// resolved. Split from [`handle_sled_upgrade`] so tests can drive the wire /// behaviour without a published artifact on the host. #[allow(clippy::too_many_arguments)] pub(crate) async fn run_sled_upgrade( project: &str, sled_url: &str, source_url: &str, expected_hash: Option, projects_store: &Arc>>, config_dir: &Path, send_phase: F, ) -> String where F: Fn(String) -> Fut, Fut: Future, { let container_name = format!("huskies-{project}"); // ── Acquire serial lock ────────────────────────────────────────────────── let _lock = upgrade_lock().lock().await; let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() .unwrap_or_default(); // ── Verify identity before triggering ──────────────────────────────────── if let Err(e) = verify_sled_identity(project, sled_url, projects_store, config_dir, &client).await { return e; } // ── 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; // ── Verify identity during convergence ─────────────────────────────────── if let Err(e) = verify_sled_identity(project, sled_url, projects_store, config_dir, &client).await { return format!("upgraded and reconnected, but {e}"); } // ── Verify convergence ─────────────────────────────────────────────────── match fetch_sled_version(&client, sled_url).await { Some((version, git_hash)) => match expected_hash { Some(expected) if git_hash == expected => { format!("upgraded to v{version} ({git_hash}) — matches published artifact") } Some(expected) => format!( "**upgrade did not converge**: sled reports {git_hash}, published artifact \ is {expected}. The sled is healthy but running the wrong binary — check \ `docker logs {container_name}`." ), None => format!("upgraded to v{version} ({git_hash})"), }, None => format!( "upgraded and healthy, but `/api/version` is unavailable — the sled is \ probably still on a pre-version-endpoint binary. Check `docker logs \ {container_name}`." ), } } // ── 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. pub(crate) 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 `(version, git_hash)` from the sled's `/api/version` endpoint. /// /// Returns `None` when the endpoint is unreachable or malformed — e.g. a sled /// still running a binary that predates the endpoint. pub(crate) async fn fetch_sled_version( client: &reqwest::Client, sled_url: &str, ) -> Option<(String, String)> { let url = format!("{}/api/version", sled_url.trim_end_matches('/')); let val: serde_json::Value = client.get(&url).send().await.ok()?.json().await.ok()?; let version = val.get("version").and_then(|v| v.as_str())?.to_string(); let git_hash = val.get("git_hash").and_then(|v| v.as_str())?.to_string(); Some((version, git_hash)) } // ── 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>> = 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, expected_node_id: None, }, ); map.insert( "beta".to_string(), ProjectEntry { url: Some("http://localhost:3002".into()), auth_token: None, ssh_port: None, host_path: None, expected_node_id: 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>> = Arc::new(RwLock::new(BTreeMap::new())); let phases: std::sync::Mutex> = std::sync::Mutex::new(vec![]); let config_dir = tempfile::tempdir().unwrap(); let result = handle_sled_upgrade( "nonexistent", &store, Some(3000), config_dir.path(), |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, expected_node_id: None, }, ); let store = Arc::new(RwLock::new(map)); let config_dir = tempfile::tempdir().unwrap(); let result = handle_sled_upgrade( "myapp", &store, Some(3000), config_dir.path(), |_msg| async {}, ) .await; assert!( result.contains("not found"), "project with no URL should say not found: {result}" ); } #[test] fn extract_upgrade_all() { let cmd = extract_upgrade_command("Timmy upgrade all", "Timmy", "@timmy:home"); assert_eq!(cmd, Some(UpgradeCommand::UpgradeAll)); let cmd = extract_upgrade_command("@timmy upgrade ALL", "Timmy", "@timmy:home"); assert_eq!(cmd, Some(UpgradeCommand::UpgradeAll)); } #[tokio::test] async fn upgrade_all_empty_store_reports_no_projects() { let store: Arc>> = Arc::new(RwLock::new(BTreeMap::new())); let config_dir = tempfile::tempdir().unwrap(); let msg = handle_upgrade_all(&store, Some(3000), config_dir.path(), |_msg| async {}).await; assert!( msg.contains("No projects"), "empty store should say no projects: {msg}" ); } #[tokio::test] async fn upgrade_unreachable_sled_reports_failure() { let phases: std::sync::Mutex> = std::sync::Mutex::new(vec![]); let store: Arc>> = Arc::new(RwLock::new(BTreeMap::new())); let config_dir = tempfile::tempdir().unwrap(); let result = run_sled_upgrade( "myapp", "http://127.0.0.1:1", // port 1 is never listening "http://127.0.0.1:1/api/artifacts/huskies-linux-arm64", None, &store, config_dir.path(), |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"); } // ── verify_sled_identity ────────────────────────────────────────────────── /// Spawn a one-shot TCP listener that answers a single `GET /// /identity?nonce=` request with a JSON body signed by `kp` over /// whatever nonce the caller actually sent — mirrors the real /// `/identity` handler without needing a full HTTP server. fn spawn_identity_responder( listener: tokio::net::TcpListener, kp: bft_json_crdt::keypair::Ed25519KeyPair, ) { tokio::spawn(async move { if let Ok((mut stream, _)) = listener.accept().await { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let mut buf = [0u8; 4096]; let n = stream.read(&mut buf).await.unwrap_or(0); let req = String::from_utf8_lossy(&buf[..n]); let nonce = req .lines() .next() .unwrap_or("") .split("nonce=") .nth(1) .and_then(|s| s.split_whitespace().next()) .unwrap_or("") .to_string(); let pubkey = crate::node_identity::public_key_hex(&kp); let sig = crate::node_identity::sign_challenge(&kp, &nonce); let body = serde_json::json!({ "node_id": pubkey, "pubkey": pubkey, "signature": sig, }) .to_string(); let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", body.len(), body ); let _ = stream.write_all(response.as_bytes()).await; } }); } #[tokio::test] async fn verify_sled_identity_first_contact_captures_and_persists() { let kp = bft_json_crdt::keypair::make_keypair(); let pubkey = crate::node_identity::public_key_hex(&kp); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); spawn_identity_responder(listener, kp); let sled_url = format!("http://127.0.0.1:{port}"); let mut map = BTreeMap::new(); map.insert( "myapp".to_string(), ProjectEntry { url: Some(sled_url.clone()), auth_token: None, ssh_port: None, host_path: None, expected_node_id: None, }, ); let store = Arc::new(RwLock::new(map)); let config_dir = tempfile::tempdir().unwrap(); let client = reqwest::Client::new(); let result = verify_sled_identity("myapp", &sled_url, &store, config_dir.path(), &client).await; assert!(result.is_ok(), "first contact should succeed: {result:?}"); let captured = store .read() .await .get("myapp") .and_then(|e| e.expected_node_id.clone()); assert_eq!( captured, Some(pubkey.clone()), "expected_node_id should be captured on first contact" ); let toml_content = tokio::fs::read_to_string(config_dir.path().join("projects.toml")).await; assert!( toml_content.unwrap_or_default().contains(&pubkey), "captured node_id should be persisted to projects.toml" ); } #[tokio::test] async fn verify_sled_identity_match_succeeds() { let kp = bft_json_crdt::keypair::make_keypair(); let pubkey = crate::node_identity::public_key_hex(&kp); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); spawn_identity_responder(listener, kp); let sled_url = format!("http://127.0.0.1:{port}"); let mut map = BTreeMap::new(); map.insert( "myapp".to_string(), ProjectEntry { url: Some(sled_url.clone()), auth_token: None, ssh_port: None, host_path: None, expected_node_id: Some(pubkey), }, ); let store = Arc::new(RwLock::new(map)); let config_dir = tempfile::tempdir().unwrap(); let client = reqwest::Client::new(); let result = verify_sled_identity("myapp", &sled_url, &store, config_dir.path(), &client).await; assert!( result.is_ok(), "matching identity should not fail the upgrade: {result:?}" ); } #[tokio::test] async fn verify_sled_identity_mismatch_fails_loudly() { let kp = bft_json_crdt::keypair::make_keypair(); let responder_pubkey = crate::node_identity::public_key_hex(&kp); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); spawn_identity_responder(listener, kp); let sled_url = format!("http://127.0.0.1:{port}"); let mut map = BTreeMap::new(); map.insert( "myapp".to_string(), ProjectEntry { url: Some(sled_url.clone()), auth_token: None, ssh_port: None, host_path: None, expected_node_id: Some("ab".repeat(32)), }, ); let store = Arc::new(RwLock::new(map)); let config_dir = tempfile::tempdir().unwrap(); let client = reqwest::Client::new(); let result = verify_sled_identity("myapp", &sled_url, &store, config_dir.path(), &client).await; let err = result.expect_err("mismatched identity must fail loudly"); assert!( err.contains("identity mismatch"), "error should name the failure class: {err}" ); assert!( err.contains("huskies-myapp"), "error should name the container: {err}" ); assert!( err.contains(&responder_pubkey), "error should name the node_id that actually answered: {err}" ); } }