use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use super::config::HttpEndpoint;
use super::exec::{exec_in_container, exec_probe_in_container};
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
pub struct ResolvedEndpoint {
pub port: i64,
pub path: String,
}
pub fn resolve(endpoint: Option<&HttpEndpoint>) -> Option<ResolvedEndpoint> {
let endpoint = endpoint?;
let port = endpoint.port?;
let path = endpoint.path.clone()?;
let path = if path.starts_with('/') {
path
} else {
format!("/{path}")
};
Some(ResolvedEndpoint { port, path })
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct NodeStatus {
pub reachable: bool,
pub is_primary: Option<bool>,
pub healthy: Option<bool>,
}
async fn probe_status(instance_id: &str, endpoint: &ResolvedEndpoint) -> Result<u16> {
let command = format!(
"curl -s -o /dev/null --max-time 4 -w '%{{http_code}}' localhost:{}{}",
endpoint.port, endpoint.path
);
let output = exec_probe_in_container(instance_id, &command, PROBE_TIMEOUT)
.await
.context("Probing the node failed")?;
output
.trim()
.parse::<u16>()
.with_context(|| format!("Unexpected response from the node: {}", output.trim()))
}
fn interpret_role(status: u16) -> Option<bool> {
match status {
200 => Some(true),
503 => Some(false),
_ => None,
}
}
pub async fn probe_nodes(
instance_ids: &BTreeMap<String, String>,
health: Option<&HttpEndpoint>,
role: Option<&HttpEndpoint>,
) -> BTreeMap<String, NodeStatus> {
let health = resolve(health);
let role = resolve(role);
let probes = instance_ids.iter().map(|(service_id, instance_id)| {
let health = health.as_ref();
let role = role.as_ref();
async move {
let mut status = NodeStatus::default();
if let Some(endpoint) = health
&& let Ok(code) = probe_status(instance_id, endpoint).await
{
status.reachable = true;
status.healthy = Some((200..300).contains(&code));
}
if let Some(endpoint) = role
&& let Ok(code) = probe_status(instance_id, endpoint).await
{
status.reachable = true;
status.is_primary = interpret_role(code);
}
(service_id.clone(), status)
}
});
futures::future::join_all(probes)
.await
.into_iter()
.collect()
}
pub async fn request_switchover(instance_id: &str, endpoint: &ResolvedEndpoint) -> Result<String> {
let command = format!(
"curl -s --max-time 8 -w '\\nHTTP_STATUS:%{{http_code}}' -X POST localhost:{}{}",
endpoint.port, endpoint.path
);
let output = tokio::time::timeout(
Duration::from_secs(10),
exec_in_container(instance_id, &command),
)
.await
.context("Timed out requesting switchover")??;
parse_switchover_response(&output)
}
fn parse_switchover_response(output: &str) -> Result<String> {
let (body, status) = match output.rsplit_once("HTTP_STATUS:") {
Some((body, status)) => (body.trim().to_string(), status.trim().parse::<u16>().ok()),
None => (output.trim().to_string(), None),
};
match status {
Some(200..=299) => Ok(body),
Some(code) => bail!("The node refused the switchover ({code}): {body}"),
None => bail!("The switchover request returned an unexpected response: {body}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_requires_both_halves_and_normalizes_the_path() {
let resolved = resolve(Some(&HttpEndpoint {
port: Some(8080),
path: Some("/role".to_string()),
}))
.unwrap();
assert_eq!(resolved.port, 8080);
assert_eq!(resolved.path, "/role");
let resolved = resolve(Some(&HttpEndpoint {
port: Some(8080),
path: Some("role".to_string()),
}))
.unwrap();
assert_eq!(resolved.path, "/role");
assert!(resolve(None).is_none());
assert!(
resolve(Some(&HttpEndpoint {
port: Some(8080),
path: None
}))
.is_none()
);
assert!(
resolve(Some(&HttpEndpoint {
port: None,
path: Some("/role".to_string())
}))
.is_none()
);
}
#[test]
fn role_contract_treats_anything_unrecognized_as_unknown() {
assert_eq!(interpret_role(200), Some(true));
assert_eq!(interpret_role(503), Some(false));
assert_eq!(interpret_role(500), None);
assert_eq!(interpret_role(404), None);
}
#[test]
fn switchover_accepts_2xx_and_surfaces_a_refusal_verbatim() {
assert_eq!(
parse_switchover_response("accepted\nHTTP_STATUS:200").unwrap(),
"accepted"
);
let err =
parse_switchover_response("cannot promote: candidate is not in sync\nHTTP_STATUS:409")
.unwrap_err()
.to_string();
assert!(err.contains("409"));
assert!(err.contains("candidate is not in sync"));
let err = parse_switchover_response("curl: (7) connection refused")
.unwrap_err()
.to_string();
assert!(err.contains("unexpected response"));
}
}