Files
huskies/server/src/chat/transport/matrix/health.rs
T

956 lines
35 KiB
Rust
Raw Normal View History

//! `health` chat command — surface gateway, sled, matrix, creds, and build-hash status.
//!
//! Runs one check per subsystem concurrently (each with a 5-second timeout) and
//! returns a compact report: one line per subsystem with PASS / WARN / FAIL and a
//! remediation hint on every non-PASS row. Output is capped at 20 lines; when
//! more lines would be produced, the oldest WARN rows are dropped first.
use crate::chat::transport::matrix::bot::context::BotContext;
use std::collections::BTreeMap;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::time::timeout;
// ── Status ─────────────────────────────────────────────────────────────────────
/// Health status for a single subsystem.
#[derive(Debug, Clone, PartialEq)]
enum Status {
/// Subsystem is operating normally.
Pass,
/// Subsystem is degraded but not fully broken.
Warn,
/// Subsystem has failed and needs intervention.
Fail,
}
// ── HealthLine ─────────────────────────────────────────────────────────────────
/// One output row from the health check.
#[derive(Debug, Clone)]
struct HealthLine {
subsystem: String,
status: Status,
/// Short description of why the check is non-PASS.
detail: Option<String>,
/// Remediation hint shown after " — " on WARN/FAIL rows.
hint: Option<String>,
}
impl HealthLine {
fn pass(subsystem: impl Into<String>) -> Self {
Self {
subsystem: subsystem.into(),
status: Status::Pass,
detail: None,
hint: None,
}
}
fn warn(
subsystem: impl Into<String>,
detail: impl Into<String>,
hint: impl Into<String>,
) -> Self {
Self {
subsystem: subsystem.into(),
status: Status::Warn,
detail: Some(detail.into()),
hint: Some(hint.into()),
}
}
fn fail(
subsystem: impl Into<String>,
detail: impl Into<String>,
hint: impl Into<String>,
) -> Self {
Self {
subsystem: subsystem.into(),
status: Status::Fail,
detail: Some(detail.into()),
hint: Some(hint.into()),
}
}
/// Append `relay=X` information to this line's detail field.
fn with_relay(mut self, relay: &str) -> Self {
let relay_text = format!("relay={relay}");
self.detail = Some(match self.detail {
Some(d) => format!("{d} | {relay_text}"),
None => relay_text,
});
self
}
/// Format as a single Markdown-friendly line.
fn format(&self) -> String {
let status = match self.status {
Status::Pass => "PASS",
Status::Warn => "WARN",
Status::Fail => "FAIL",
};
match (&self.detail, &self.hint) {
(Some(d), Some(h)) => format!("{} {}: {}{}", self.subsystem, status, d, h),
(Some(d), None) => format!("{} {}: {}", self.subsystem, status, d),
(None, None) => format!("{} {}", self.subsystem, status),
(None, Some(h)) => format!("{} {}: — {}", self.subsystem, status, h),
}
}
}
// ── Truncation ────────────────────────────────────────────────────────────────
/// Maximum number of output lines before truncation.
const MAX_LINES: usize = 20;
/// Truncate to ≤ MAX_LINES by removing the oldest (first in order) WARN rows.
fn truncate_lines(mut lines: Vec<HealthLine>) -> Vec<HealthLine> {
while lines.len() > MAX_LINES {
if let Some(pos) = lines.iter().position(|l| l.status == Status::Warn) {
lines.remove(pos);
} else {
break;
}
}
lines
}
// ── Individual checks ────────────────────────────────────────────────────────
/// Check the permission registry — PASS when at least one responder (e.g. the
/// Matrix permission listener) is registered, FAIL when none is (listener has
/// died or was never started).
fn check_perm_rx(ctx: &BotContext) -> HealthLine {
if ctx.services.permission_registry.is_empty() {
HealthLine::fail("perm_rx", "no responder registered", "restart bot")
} else {
HealthLine::pass("perm_rx")
}
}
/// Check the Matrix sync loop by measuring the age of the last received event.
///
/// WARN after 60 s of silence, FAIL after 120 s. The timestamp is updated by
/// `on_room_message` on every incoming event so receiving the health command
/// itself resets the clock.
fn check_matrix_sync(ctx: &BotContext) -> HealthLine {
let last_ms = ctx.last_matrix_event_ms.load(Ordering::Relaxed);
let age_secs = (chrono::Utc::now().timestamp_millis() - last_ms).max(0) / 1000;
if age_secs < 60 {
HealthLine::pass("matrix-sync")
} else if age_secs < 120 {
HealthLine::warn(
"matrix-sync",
format!("no events in {age_secs}s"),
"check sync loop — may be a quiet room",
)
} else {
HealthLine::fail(
"matrix-sync",
format!("no events in {age_secs}s"),
"sync loop may be dead — restart bot",
)
}
}
/// Check LLM credentials (`~/.claude/.credentials.json`).
///
/// FAIL if the file is missing or unreadable, FAIL if the access token is
/// expired, WARN if it expires within the next 7 days.
fn check_creds() -> HealthLine {
match crate::llm::oauth::read_credentials() {
Err(e) => HealthLine::fail("creds", e, "run `claude login`"),
Ok(creds) => {
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let expires_at = creds.claude_ai_oauth.expires_at;
if expires_at < now_secs {
HealthLine::fail("creds", "token expired", "run `claude login` to refresh")
} else {
let days_left = (expires_at - now_secs) / 86400;
if days_left < 7 {
HealthLine::warn(
"creds",
format!("token expires in {days_left}d"),
"run `claude login` to refresh",
)
} else {
HealthLine::pass("creds")
}
}
}
}
}
/// Compare the compile-time build hash against the current HEAD of the workspace.
///
/// WARN when master has advanced past the running binary's commit (a rebuild is
/// available but not urgent). PASS when hashes match or HEAD cannot be read.
async fn check_build_hash(project_root: &std::path::Path) -> HealthLine {
let running = option_env!("BUILD_GIT_HASH").unwrap_or("unknown");
// Read current HEAD from git (non-blocking, run in a spawn_blocking call).
let repo_root = project_root.to_path_buf();
let head = tokio::task::spawn_blocking(move || {
std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(&repo_root)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
})
.await
.unwrap_or(None);
match head {
None => HealthLine::pass("build-hash"),
Some(ref head_hash) => {
if running == "unknown" || head_hash == running {
HealthLine::pass("build-hash")
} else {
HealthLine::warn(
"build-hash",
format!("running {running}, HEAD is {head_hash}"),
"run `rebuild` to update",
)
}
}
}
}
/// Check each registered sled's `/health` endpoint with a 5-second timeout.
///
/// Returns one [`HealthLine`] per sled. PASS when the sled responds with HTTP
/// 2xx *and* its `/identity` probe matches the recorded `expected_node_id`;
/// FAIL when `/health` times out or errors, OR when the identity probe
/// reveals a mismatch or invalid signature — even if `/health` itself
/// reported ok, since a swapped-in container can still serve a healthy
/// `/health` response. Each line includes a `relay=X` indicator derived from
/// the gateway CRDT event log.
async fn check_sleds(
store: &tokio::sync::RwLock<BTreeMap<String, crate::service::gateway::config::ProjectEntry>>,
config_dir: &std::path::Path,
) -> Vec<HealthLine> {
let entries: Vec<(String, Option<String>)> = store
.read()
.await
.iter()
.map(|(n, e)| (n.clone(), e.url.clone()))
.collect();
if entries.is_empty() {
return vec![HealthLine::warn(
"sled",
"no sleds registered",
"add projects to projects.toml",
)];
}
let event_entries = crate::crdt_state::read_all_event_log_entries();
let now_secs = chrono::Utc::now().timestamp() as f64;
let client = reqwest::Client::new();
let mut lines = Vec::new();
for (name, url_opt) in entries {
let subsystem = format!("sled:{name}");
let relay = {
let latest = event_entries
.iter()
.filter(|e| e.sled_id == name)
.map(|e| e.timestamp)
.fold(f64::NEG_INFINITY, f64::max);
if latest == f64::NEG_INFINITY {
"never"
} else if now_secs - latest <= crate::service::gateway::RELAY_MAX_AGE_SECS {
"ok"
} else {
"silent"
}
};
let line = match url_opt {
None => HealthLine::warn(subsystem, "no URL configured", "set url in projects.toml")
.with_relay(relay),
Some(url) => {
let health_url = format!("{}/health", url.trim_end_matches('/'));
let result = timeout(Duration::from_secs(5), client.get(&health_url).send()).await;
let health_line = match result {
Err(_) => HealthLine::fail(
subsystem.clone(),
"timed out",
"check container is running",
),
Ok(Err(e)) => HealthLine::fail(
subsystem.clone(),
format!("unreachable: {}", short_error(&e.to_string())),
"check container is running",
),
Ok(Ok(resp)) if resp.status().is_success() => {
HealthLine::pass(subsystem.clone())
}
Ok(Ok(resp)) => HealthLine::fail(
subsystem.clone(),
format!("HTTP {}", resp.status().as_u16()),
"check container logs",
),
};
match check_sled_identity(&client, &name, &url, store, config_dir).await {
Some(identity_fail) => identity_fail.with_relay(relay),
None => health_line.with_relay(relay),
}
}
};
lines.push(line);
}
lines
}
/// Probe `{url}/identity`, verify the response against the `expected_node_id`
/// recorded for `name`, and capture a first-contact identity.
///
/// Returns `Some(HealthLine::fail(...))` on a confirmed mismatch or invalid
/// signature — this overrides an otherwise-passing `/health` result. Returns
/// `None` when the identity is unverifiable (unreachable probe or a legacy
/// sled with no signature) or matches, in which case the `/health` result
/// stands on its own.
async fn check_sled_identity(
client: &reqwest::Client,
name: &str,
url: &str,
store: &tokio::sync::RwLock<BTreeMap<String, crate::service::gateway::config::ProjectEntry>>,
config_dir: &std::path::Path,
) -> Option<HealthLine> {
let subsystem = format!("sled:{name}");
let nonce = crate::node_identity::generate_challenge();
let response = timeout(
Duration::from_secs(5),
crate::service::gateway::probe_identity(client, url, &nonce),
)
.await
.ok()
.flatten()?;
let expected = store
.read()
.await
.get(name)
.and_then(|e| e.expected_node_id.clone());
match crate::service::gateway::check_identity(expected.as_deref(), &nonce, &response) {
crate::service::gateway::IdentityCheck::Match
| crate::service::gateway::IdentityCheck::MissingSignature => None,
crate::service::gateway::IdentityCheck::FirstContact { node_id } => {
{
let mut projects = store.write().await;
if let Some(entry) = projects.get_mut(name) {
entry.expected_node_id = Some(node_id);
}
}
let snapshot = store.read().await.clone();
crate::service::gateway::io::save_config(&snapshot, config_dir).await;
None
}
crate::service::gateway::IdentityCheck::Mismatch { responder_node_id } => {
Some(HealthLine::fail(
subsystem,
format!(
"identity mismatch: expected `{}`, container answered as `{responder_node_id}`",
expected.unwrap_or_default()
),
"verify the correct container is running — possible swap",
))
}
crate::service::gateway::IdentityCheck::InvalidSignature => Some(HealthLine::fail(
subsystem,
"identity signature invalid",
"container's identity cannot be verified — check logs",
)),
}
}
/// Check the gateway process: pidfile validity and (on macOS) binary codesign.
///
/// PASS when our PID is recorded in the pidfile. On macOS, also verifies that
/// `~/bin/huskies-bin` has a valid ad-hoc signature; FAIL with a `script/local-release`
/// hint when it does not.
fn check_gateway_process() -> HealthLine {
// Verify that the pidfile records our PID (i.e. this IS the live gateway).
let pidfile_ok = check_pidfile_matches_self();
// On macOS, verify the installed binary is codesigned.
#[cfg(target_os = "macos")]
{
if !check_codesign_macos() {
return HealthLine::fail(
"gateway-process",
"codesign invalid",
"run `script/local-release`",
);
}
}
if !pidfile_ok {
return HealthLine::warn(
"gateway-process",
"pidfile missing or stale",
"restart gateway with --gateway flag",
);
}
HealthLine::pass("gateway-process")
}
/// Return `true` when `$HOME/.huskies/gateway.pid` exists and contains our PID.
fn check_pidfile_matches_self() -> bool {
let home = homedir::my_home().ok().flatten();
let home = match home {
Some(h) => h,
None => return false,
};
let path = home.join(".huskies").join("gateway.pid");
let content = std::fs::read_to_string(&path).unwrap_or_default();
content.trim().parse::<u32>().unwrap_or(0) == std::process::id()
}
/// On macOS, return `true` when `~/bin/huskies-bin` passes `codesign --verify`.
///
/// Falls back to the current executable when `~/bin/huskies-bin` does not exist.
/// Returns `true` (assume ok) if the `codesign` tool is unavailable.
#[cfg(target_os = "macos")]
fn check_codesign_macos() -> bool {
let target = if let Ok(home) = std::env::var("HOME") {
let installed = std::path::PathBuf::from(home)
.join("bin")
.join("huskies-bin");
if installed.exists() {
installed
} else {
match std::env::current_exe() {
Ok(p) => p,
Err(_) => return true,
}
}
} else {
match std::env::current_exe() {
Ok(p) => p,
Err(_) => return true,
}
};
std::process::Command::new("codesign")
.args(["--verify", "--quiet", target.to_str().unwrap_or("")])
.output()
.map(|o| o.status.success())
.unwrap_or(true)
}
// ── Entry point ────────────────────────────────────────────────────────────────
/// Run all health checks and return a formatted Markdown report (≤ 20 lines).
///
/// Gateway-specific checks (gateway-process, per-sled probes) are included
/// only when running in gateway mode. All other checks run in every mode.
pub async fn run_health_check(ctx: &BotContext) -> String {
let mut lines: Vec<HealthLine> = Vec::new();
// Gateway-only checks
if ctx.is_gateway() {
lines.push(check_gateway_process());
if let Some(ref store) = ctx.gateway_projects_store {
lines.extend(check_sleds(store, &ctx.services.project_root).await);
}
}
// Shared checks — run concurrently where possible.
let perm_line = check_perm_rx(ctx);
let sync_line = check_matrix_sync(ctx);
let creds_line = check_creds();
let hash_line = check_build_hash(&ctx.services.project_root).await;
lines.push(perm_line);
lines.push(sync_line);
lines.push(creds_line);
lines.push(hash_line);
lines.push(check_disk_space());
let lines = truncate_lines(lines);
lines
.iter()
.map(|l| l.format())
.collect::<Vec<_>>()
.join("\n")
}
/// Report current free disk space on `/workspace` (story 1200 AC5).
fn check_disk_space() -> HealthLine {
match crate::service::disk_watch::io::free_space_bytes(std::path::Path::new("/workspace")) {
Ok(free_bytes) => {
let free_gb = free_bytes as f64 / 1_000_000_000.0;
HealthLine {
subsystem: "disk".to_string(),
status: Status::Pass,
detail: Some(format!("{free_gb:.1}GB free")),
hint: None,
}
}
Err(_) => HealthLine::warn(
"disk",
"unable to read free space",
"check /workspace mount",
),
}
}
// ── Utilities ────────────────────────────────────────────────────────────────
/// Shorten a long error string to the first 60 characters for compact display.
fn short_error(s: &str) -> String {
s.chars().take(60).collect()
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// -- HealthLine formatting ------------------------------------------------
#[test]
fn pass_line_formats_without_detail() {
let line = HealthLine::pass("perm_rx");
assert_eq!(line.format(), "perm_rx PASS");
}
#[test]
fn fail_line_formats_with_detail_and_hint() {
let line = HealthLine::fail(
"gateway-process",
"codesign invalid",
"run script/local-release",
);
assert_eq!(
line.format(),
"gateway-process FAIL: codesign invalid — run script/local-release"
);
}
#[test]
fn warn_line_formats_with_detail_and_hint() {
let line = HealthLine::warn("build-hash", "running abc, HEAD is def", "run rebuild");
assert_eq!(
line.format(),
"build-hash WARN: running abc, HEAD is def — run rebuild"
);
}
// -- Truncation -----------------------------------------------------------
#[test]
fn truncate_drops_oldest_warn_first() {
let mut lines: Vec<HealthLine> = (0..22)
.map(|i| {
if i % 3 == 0 {
HealthLine::fail(format!("sled:{i}"), "down", "fix it")
} else {
HealthLine::warn(format!("check:{i}"), "slow", "investigate")
}
})
.collect();
// Manually insert a known WARN at position 0 and a FAIL at position 1
lines.insert(0, HealthLine::warn("oldest-warn", "stale", "restart"));
lines.insert(1, HealthLine::fail("important-fail", "broken", "fix"));
let result = truncate_lines(lines.clone());
assert!(
result.len() <= MAX_LINES,
"output must be ≤ {MAX_LINES} lines"
);
// FAILs must be preserved.
let fail_count = result.iter().filter(|l| l.status == Status::Fail).count();
let orig_fail_count = lines.iter().filter(|l| l.status == Status::Fail).count();
assert_eq!(
fail_count,
orig_fail_count.min(MAX_LINES),
"all FAIL lines must be kept when they fit"
);
}
#[test]
fn truncate_noop_when_under_limit() {
let lines: Vec<HealthLine> = (0..5).map(|i| HealthLine::pass(format!("s{i}"))).collect();
let result = truncate_lines(lines.clone());
assert_eq!(result.len(), 5);
}
#[test]
fn truncate_stops_at_fails_when_no_warns_left() {
// 25 FAIL lines — nothing to drop; output is clamped at MAX_LINES.
let lines: Vec<HealthLine> = (0..25)
.map(|i| HealthLine::fail(format!("s{i}"), "broken", "fix"))
.collect();
let result = truncate_lines(lines);
// When only FAILs are present, truncation stops because no WARNs can be removed.
assert_eq!(result.len(), 25, "FAILs are never dropped by truncation");
}
// -- perm_rx check --------------------------------------------------------
#[tokio::test]
async fn perm_rx_pass_when_locked() {
use crate::service::permission_router::{PendingPermReplies, ResponderRegistry};
use crate::services::Services;
use std::sync::Arc;
let registry = ResponderRegistry::new();
// Register a responder to simulate the permission listener being active.
let _guard_and_rx = registry.register();
let services = Arc::new(Services {
project_root: std::path::PathBuf::from("/tmp"),
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
bot_name: "test".to_string(),
bot_user_id: "@bot:test".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
permission_registry: registry,
pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120,
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
});
// Build a minimal BotContext just to pass services.
let ctx = make_test_ctx(services);
let line = check_perm_rx(&ctx);
assert_eq!(
line.status,
Status::Pass,
"perm_rx should PASS when a responder is registered"
);
}
#[tokio::test]
async fn perm_rx_fail_when_unlocked() {
use crate::service::permission_router::{PendingPermReplies, ResponderRegistry};
use crate::services::Services;
use std::sync::Arc;
// No responder registered.
let services = Arc::new(Services {
project_root: std::path::PathBuf::from("/tmp"),
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
bot_name: "test".to_string(),
bot_user_id: "@bot:test".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
permission_registry: ResponderRegistry::new(),
pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120,
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
});
let ctx = make_test_ctx(services);
let line = check_perm_rx(&ctx);
assert_eq!(
line.status,
Status::Fail,
"perm_rx should FAIL when no responder is registered"
);
}
// -- matrix-sync check ----------------------------------------------------
#[tokio::test]
async fn matrix_sync_pass_when_recent() {
let services = crate::services::Services::new_test(
std::path::PathBuf::from("/tmp"),
"bot".to_string(),
);
let ctx = make_test_ctx(services);
// Set last event to just now.
ctx.last_matrix_event_ms
.store(chrono::Utc::now().timestamp_millis(), Ordering::Relaxed);
let line = check_matrix_sync(&ctx);
assert_eq!(line.status, Status::Pass);
}
#[tokio::test]
async fn matrix_sync_fail_when_stale() {
let services = crate::services::Services::new_test(
std::path::PathBuf::from("/tmp"),
"bot".to_string(),
);
let ctx = make_test_ctx(services);
// Simulate 200 seconds of silence.
let old_ms = chrono::Utc::now().timestamp_millis() - 200_000;
ctx.last_matrix_event_ms.store(old_ms, Ordering::Relaxed);
let line = check_matrix_sync(&ctx);
assert_eq!(line.status, Status::Fail);
assert!(
line.detail.as_deref().unwrap_or("").contains("200s")
|| line.detail.as_deref().unwrap_or("").contains("s"),
"detail should mention age in seconds"
);
}
// -- creds check ----------------------------------------------------------
#[test]
fn creds_fail_when_file_missing() {
// In the test environment there is unlikely to be a ~/.claude/.credentials.json
// with a valid non-expired token, so we just confirm the function returns a
// HealthLine without panicking.
let line = check_creds();
// We don't assert a specific status — the check should not panic.
let _ = line.format();
}
// -- build_hash check -----------------------------------------------------
#[tokio::test]
async fn build_hash_pass_when_git_unavailable() {
// In a test environment without a git repo at /tmp/nonexistent, the check
// should gracefully return PASS rather than panicking.
let line = check_build_hash(std::path::Path::new("/tmp/nonexistent")).await;
// Should either PASS or produce a sensible result — must not panic.
let _ = line.format();
}
// -- health command registration ------------------------------------------
#[test]
fn health_command_registered_in_commands() {
let cmds = crate::chat::commands::commands();
assert!(
cmds.iter().any(|c| c.name == "health"),
"health must be registered in commands()"
);
}
#[test]
fn health_command_has_description() {
let cmds = crate::chat::commands::commands();
let cmd = cmds.iter().find(|c| c.name == "health").unwrap();
assert!(!cmd.description.is_empty());
}
// -- check_sled_identity ---------------------------------------------------
/// Spawn a one-shot TCP listener that answers a single `GET
/// /identity?nonce=<hex>` request with a JSON body signed by `kp` over
/// whatever nonce the caller actually sent.
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 check_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(),
crate::service::gateway::config::ProjectEntry {
url: Some(sled_url.clone()),
auth_token: None,
ssh_port: None,
host_path: None,
expected_node_id: None,
},
);
let store = tokio::sync::RwLock::new(map);
let config_dir = tempfile::tempdir().unwrap();
let client = reqwest::Client::new();
let result =
check_sled_identity(&client, "myapp", &sled_url, &store, config_dir.path()).await;
assert!(
result.is_none(),
"first contact must not fail the health line: {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 check_sled_identity_match_returns_none() {
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(),
crate::service::gateway::config::ProjectEntry {
url: Some(sled_url.clone()),
auth_token: None,
ssh_port: None,
host_path: None,
expected_node_id: Some(pubkey),
},
);
let store = tokio::sync::RwLock::new(map);
let config_dir = tempfile::tempdir().unwrap();
let client = reqwest::Client::new();
let result =
check_sled_identity(&client, "myapp", &sled_url, &store, config_dir.path()).await;
assert!(result.is_none(), "matching identity must PASS: {result:?}");
}
#[tokio::test]
async fn check_sled_identity_mismatch_forces_fail_line() {
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(),
crate::service::gateway::config::ProjectEntry {
url: Some(sled_url.clone()),
auth_token: None,
ssh_port: None,
host_path: None,
expected_node_id: Some("ab".repeat(32)),
},
);
let store = tokio::sync::RwLock::new(map);
let config_dir = tempfile::tempdir().unwrap();
let client = reqwest::Client::new();
let result =
check_sled_identity(&client, "myapp", &sled_url, &store, config_dir.path()).await;
let line = result.expect("identity mismatch must force a FAIL health line");
assert_eq!(
line.status,
Status::Fail,
"identity mismatch must FAIL even though /health would report ok"
);
assert!(
line.detail
.as_deref()
.unwrap_or("")
.contains(&responder_pubkey),
"detail should name the node_id that actually answered: {line:?}"
);
}
// -- Helper ---------------------------------------------------------------
/// Build a minimal `BotContext` for testing purposes.
fn make_test_ctx(services: std::sync::Arc<crate::services::Services>) -> BotContext {
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::atomic::AtomicI64;
use tokio::sync::Mutex as TokioMutex;
BotContext {
services,
matrix_user_id: "@bot:example.com".parse().unwrap(),
target_room_ids: vec![],
allowed_users: vec![],
history: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
history_size: 20,
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
htop_sessions: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
"test-phone".to_string(),
"test-token".to_string(),
"pipeline_notification".to_string(),
)),
timer_store: Arc::new(crate::service::timer::TimerStore::load(
std::path::PathBuf::from("/tmp/timers-health.json"),
)),
gateway_active_project: None,
gateway_projects_store: None,
gateway_channels_store: None,
handled_incoming_event_ids: Arc::new(TokioMutex::new(
crate::chat::transport::matrix::bot::context::SeenEventIds::new(
crate::chat::transport::matrix::bot::context::SEEN_EVENT_IDS_CAP,
),
)),
gateway_port: None,
last_matrix_event_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
2026-06-29 12:42:45 +01:00
model: None,
compact_seed_max_bytes: 8_000,
cache_read_suggest_threshold: 50_000,
compact_suggest_cooldown_secs: 3_600,
digging_in_threshold_secs: 15,
}
}
}