2026-07-15 16:22:11 +01:00
|
|
|
//! `upgrade [<project>|all]` gateway chat command — streaming sled binary upgrade.
|
2026-05-19 18:34:44 +00:00
|
|
|
//!
|
|
|
|
|
//! Usage (gateway mode only):
|
|
|
|
|
//! - `{bot} upgrade <project>` — upgrade the named sled's binary in-container.
|
2026-07-15 16:22:11 +01:00
|
|
|
//! - `{bot} upgrade all` — upgrade every registered sled in sequence.
|
2026-05-19 18:34:44 +00:00
|
|
|
//! - `{bot} upgrade` — list registered projects (shows what can be targeted).
|
|
|
|
|
//!
|
2026-07-15 16:22:11 +01:00
|
|
|
//! The binary comes from the gateway's own artifact store
|
|
|
|
|
//! (`~/.huskies/artifacts/`, published by the `release` command) — sleds never
|
2026-07-15 16:54:55 +01:00
|
|
|
//! 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.
|
2026-07-15 16:22:11 +01:00
|
|
|
//!
|
|
|
|
|
//! The gateway orchestrates each upgrade in four phases, streaming a marker to
|
2026-05-19 18:34:44 +00:00
|
|
|
//! 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.
|
2026-07-15 16:22:11 +01:00
|
|
|
//! 3. `[3/4] restarting sled` — sled exits cleanly; Docker restarts it with the new binary.
|
2026-05-19 18:34:44 +00:00
|
|
|
//! 4. `[4/4] reconnected to gateway` — sled's `/health` probe is responding again.
|
|
|
|
|
//!
|
2026-07-15 16:22:11 +01:00
|
|
|
//! 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).
|
|
|
|
|
//!
|
2026-05-19 18:34:44 +00:00
|
|
|
//! 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;
|
2026-07-16 13:20:24 +00:00
|
|
|
use crate::service::gateway::{IdentityCheck, check_identity, probe_identity};
|
2026-05-19 18:34:44 +00:00
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
use std::future::Future;
|
2026-07-16 13:20:24 +00:00
|
|
|
use std::path::Path;
|
2026-05-19 18:34:44 +00:00
|
|
|
use std::sync::{Arc, OnceLock};
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
use tokio::sync::{Mutex, RwLock};
|
|
|
|
|
|
2026-07-16 13:20:24 +00:00
|
|
|
/// 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<RwLock<BTreeMap<String, ProjectEntry>>>,
|
|
|
|
|
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."
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:34:44 +00:00
|
|
|
// ── 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,
|
|
|
|
|
},
|
2026-07-15 16:22:11 +01:00
|
|
|
/// `upgrade all` — upgrade every registered sled in sequence.
|
|
|
|
|
UpgradeAll,
|
2026-05-19 18:34:44 +00:00
|
|
|
/// `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() {
|
2026-07-15 16:22:11 +01:00
|
|
|
return Some(UpgradeCommand::ListProjects);
|
|
|
|
|
}
|
|
|
|
|
let target = rest.split_whitespace().next().unwrap_or(rest);
|
|
|
|
|
if target.eq_ignore_ascii_case("all") {
|
|
|
|
|
Some(UpgradeCommand::UpgradeAll)
|
2026-05-19 18:34:44 +00:00
|
|
|
} else {
|
|
|
|
|
Some(UpgradeCommand::Upgrade {
|
2026-07-15 16:22:11 +01:00
|
|
|
project: target.to_string(),
|
2026-05-19 18:34:44 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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}")
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 16:22:11 +01:00
|
|
|
/// 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.
|
|
|
|
|
fn resolve_artifact_source(gateway_port: Option<u16>) -> Result<(String, Option<String>), 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<F, Fut>(
|
|
|
|
|
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
|
|
|
|
gateway_port: Option<u16>,
|
2026-07-16 13:20:24 +00:00
|
|
|
config_dir: &Path,
|
2026-07-15 16:22:11 +01:00
|
|
|
send_phase: F,
|
|
|
|
|
) -> String
|
|
|
|
|
where
|
|
|
|
|
F: Fn(String) -> Fut,
|
|
|
|
|
Fut: Future<Output = ()>,
|
|
|
|
|
{
|
|
|
|
|
let names: Vec<String> = {
|
|
|
|
|
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<String> = Vec::with_capacity(names.len());
|
|
|
|
|
for name in &names {
|
2026-07-16 13:20:24 +00:00
|
|
|
let outcome = handle_sled_upgrade(name, projects_store, gateway_port, config_dir, |msg| {
|
2026-07-15 16:22:11 +01:00
|
|
|
send_phase(format!("**{name}** {msg}"))
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
results.push(format!("- {name}: {outcome}"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
format!(
|
|
|
|
|
"Upgrade sweep over {} sled(s) complete:\n{}",
|
|
|
|
|
names.len(),
|
|
|
|
|
results.join("\n")
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:34:44 +00:00
|
|
|
/// 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.
|
|
|
|
|
///
|
2026-07-15 16:54:55 +01:00
|
|
|
/// Agents running in the sled are killed by the restart; the pipeline's
|
|
|
|
|
/// retry machinery re-queues their work.
|
2026-05-19 18:34:44 +00:00
|
|
|
pub async fn handle_sled_upgrade<F, Fut>(
|
|
|
|
|
project: &str,
|
|
|
|
|
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
|
|
|
|
gateway_port: Option<u16>,
|
2026-07-16 13:20:24 +00:00
|
|
|
config_dir: &Path,
|
2026-05-19 18:34:44 +00:00
|
|
|
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(", ")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-15 16:22:11 +01:00
|
|
|
// ── Resolve binary source ────────────────────────────────────────────────
|
|
|
|
|
let (source_url, expected_hash) = match resolve_artifact_source(gateway_port) {
|
|
|
|
|
Ok(v) => v,
|
|
|
|
|
Err(e) => return e,
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-16 13:20:24 +00:00
|
|
|
run_sled_upgrade(
|
|
|
|
|
project,
|
|
|
|
|
&sled_url,
|
|
|
|
|
&source_url,
|
|
|
|
|
expected_hash,
|
|
|
|
|
projects_store,
|
|
|
|
|
config_dir,
|
|
|
|
|
send_phase,
|
|
|
|
|
)
|
|
|
|
|
.await
|
2026-07-15 16:22:11 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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.
|
2026-07-16 13:20:24 +00:00
|
|
|
#[allow(clippy::too_many_arguments)]
|
2026-07-15 16:22:11 +01:00
|
|
|
async fn run_sled_upgrade<F, Fut>(
|
|
|
|
|
project: &str,
|
|
|
|
|
sled_url: &str,
|
|
|
|
|
source_url: &str,
|
|
|
|
|
expected_hash: Option<String>,
|
2026-07-16 13:20:24 +00:00
|
|
|
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
|
|
|
|
config_dir: &Path,
|
2026-07-15 16:22:11 +01:00
|
|
|
send_phase: F,
|
|
|
|
|
) -> String
|
|
|
|
|
where
|
|
|
|
|
F: Fn(String) -> Fut,
|
|
|
|
|
Fut: Future<Output = ()>,
|
|
|
|
|
{
|
|
|
|
|
let container_name = format!("huskies-{project}");
|
2026-05-19 18:34:44 +00:00
|
|
|
|
|
|
|
|
// ── Acquire serial lock ──────────────────────────────────────────────────
|
|
|
|
|
let _lock = upgrade_lock().lock().await;
|
|
|
|
|
|
|
|
|
|
let client = reqwest::Client::builder()
|
|
|
|
|
.timeout(Duration::from_secs(30))
|
|
|
|
|
.build()
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
2026-07-16 13:20:24 +00:00
|
|
|
// ── Verify identity before triggering ────────────────────────────────────
|
|
|
|
|
if let Err(e) =
|
|
|
|
|
verify_sled_identity(project, sled_url, projects_store, config_dir, &client).await
|
|
|
|
|
{
|
|
|
|
|
return e;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:34:44 +00:00
|
|
|
// ── 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;
|
|
|
|
|
|
2026-07-16 13:20:24 +00:00
|
|
|
// ── 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}");
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 16:22:11 +01:00
|
|
|
// ── 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}`."
|
|
|
|
|
),
|
|
|
|
|
}
|
2026-05-19 18:34:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 16:22:11 +01:00
|
|
|
/// Fetch `(version, git_hash)` from the sled's `/api/version` endpoint.
|
2026-05-19 18:34:44 +00:00
|
|
|
///
|
2026-07-15 16:22:11 +01:00
|
|
|
/// Returns `None` when the endpoint is unreachable or malformed — e.g. a sled
|
|
|
|
|
/// still running a binary that predates the endpoint.
|
|
|
|
|
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))
|
2026-05-19 18:34:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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,
|
2026-07-16 13:20:24 +00:00
|
|
|
expected_node_id: None,
|
2026-05-19 18:34:44 +00:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
map.insert(
|
|
|
|
|
"beta".to_string(),
|
|
|
|
|
ProjectEntry {
|
|
|
|
|
url: Some("http://localhost:3002".into()),
|
|
|
|
|
auth_token: None,
|
|
|
|
|
ssh_port: None,
|
|
|
|
|
host_path: None,
|
2026-07-16 13:20:24 +00:00
|
|
|
expected_node_id: None,
|
2026-05-19 18:34:44 +00:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
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![]);
|
2026-07-16 13:20:24 +00:00
|
|
|
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 {}
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-05-19 18:34:44 +00:00
|
|
|
.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,
|
2026-07-16 13:20:24 +00:00
|
|
|
expected_node_id: None,
|
2026-05-19 18:34:44 +00:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
let store = Arc::new(RwLock::new(map));
|
2026-07-16 13:20:24 +00:00
|
|
|
let config_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let result = handle_sled_upgrade(
|
|
|
|
|
"myapp",
|
|
|
|
|
&store,
|
|
|
|
|
Some(3000),
|
|
|
|
|
config_dir.path(),
|
|
|
|
|
|_msg| async {},
|
|
|
|
|
)
|
|
|
|
|
.await;
|
2026-05-19 18:34:44 +00:00
|
|
|
assert!(
|
|
|
|
|
result.contains("not found"),
|
|
|
|
|
"project with no URL should say not found: {result}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 16:22:11 +01:00
|
|
|
#[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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:34:44 +00:00
|
|
|
#[tokio::test]
|
2026-07-15 16:22:11 +01:00
|
|
|
async fn upgrade_all_empty_store_reports_no_projects() {
|
|
|
|
|
let store: Arc<RwLock<BTreeMap<String, ProjectEntry>>> =
|
|
|
|
|
Arc::new(RwLock::new(BTreeMap::new()));
|
2026-07-16 13:20:24 +00:00
|
|
|
let config_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let msg = handle_upgrade_all(&store, Some(3000), config_dir.path(), |_msg| async {}).await;
|
2026-07-15 16:22:11 +01:00
|
|
|
assert!(
|
|
|
|
|
msg.contains("No projects"),
|
|
|
|
|
"empty store should say no projects: {msg}"
|
2026-05-19 18:34:44 +00:00
|
|
|
);
|
2026-07-15 16:22:11 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn upgrade_unreachable_sled_reports_failure() {
|
2026-05-19 18:34:44 +00:00
|
|
|
let phases: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(vec![]);
|
2026-07-16 13:20:24 +00:00
|
|
|
let store: Arc<RwLock<BTreeMap<String, ProjectEntry>>> =
|
|
|
|
|
Arc::new(RwLock::new(BTreeMap::new()));
|
|
|
|
|
let config_dir = tempfile::tempdir().unwrap();
|
2026-07-15 16:22:11 +01:00
|
|
|
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,
|
2026-07-16 13:20:24 +00:00
|
|
|
&store,
|
|
|
|
|
config_dir.path(),
|
2026-07-15 16:22:11 +01:00
|
|
|
|msg| {
|
|
|
|
|
phases.lock().unwrap().push(msg);
|
|
|
|
|
async {}
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-05-19 18:34:44 +00:00
|
|
|
.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");
|
|
|
|
|
}
|
2026-07-16 13:20:24 +00:00
|
|
|
|
|
|
|
|
// ── verify_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 — 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}"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-19 18:34:44 +00:00
|
|
|
}
|