use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use super::config::HttpEndpoint;
use super::database_engines::NodeHttpClient;
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>,
}
fn status_probe_command(client: NodeHttpClient, endpoint: &ResolvedEndpoint) -> String {
match client {
NodeHttpClient::Curl => format!(
"curl -s -o /dev/null --max-time 4 -w '%{{http_code}}' localhost:{}{}",
endpoint.port, endpoint.path
),
NodeHttpClient::Wget => format!(
r#"wget -q -S -O /dev/null --timeout=4 --tries=1 --no-config http://localhost:{}{} 2>&1 || {{ rc=$?; [ "$rc" -eq 6 ] || [ "$rc" -eq 8 ] || exit "$rc"; }}"#,
endpoint.port, endpoint.path
),
}
}
fn parse_status_probe(client: NodeHttpClient, output: &str) -> Result<u16> {
let parsed = match client {
NodeHttpClient::Curl => output.trim().parse::<u16>().ok(),
NodeHttpClient::Wget => output
.lines()
.rev()
.map(str::trim)
.find(|line| line.starts_with("HTTP/"))
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|code| code.parse::<u16>().ok()),
};
parsed.with_context(|| format!("Unexpected response from the node: {}", output.trim()))
}
async fn probe_status(
client: NodeHttpClient,
instance_id: &str,
endpoint: &ResolvedEndpoint,
) -> Result<u16> {
let command = status_probe_command(client, endpoint);
let output = exec_probe_in_container(instance_id, &command, PROBE_TIMEOUT)
.await
.context("Probing the node failed")?;
parse_status_probe(client, &output)
}
fn interpret_role(status: u16) -> Option<bool> {
match status {
200 => Some(true),
503 => Some(false),
_ => None,
}
}
pub async fn probe_nodes(
client: NodeHttpClient,
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(client, instance_id, endpoint).await
{
status.reachable = true;
status.healthy = Some((200..300).contains(&code));
}
if let Some(endpoint) = role
&& let Ok(code) = probe_status(client, 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()
}
const HEALTH_API_AUTH_PRELUDE: &str = concat!(
r#"HEALTH_API_PW="${HEALTH_API_PASSWORD:-}"; "#,
r#"HEALTH_API_USER="${HEALTH_API_USERNAME:-railway}"; "#,
r#"curl_cfg_quote() { s=$1; q=; nl=$(printf '\nx'); nl=${nl%x}; "#,
r#"while [ -n "$s" ]; do r=${s#?}; c=${s%"$r"}; s=$r; "#,
r#"case $c in \\) q="$q\\\\";; \") q="$q\\\"";; "$nl") q="$q\\n";; *) q="$q$c";; esac; done; "#,
r#"printf '%s' "$q"; }; "#,
r#"if [ -n "$HEALTH_API_PW" ]; then HEALTH_API_CFG="user = \"$(curl_cfg_quote "$HEALTH_API_USER:$HEALTH_API_PW")\""; set -- -K -; else HEALTH_API_CFG=; set --; fi; "#,
);
const WGET_HEALTH_API_AUTH_PRELUDE: &str = concat!(
r#"ha_trim() { v=$1; v=${v#"${v%%[![:space:]]*}"}; v=${v%"${v##*[![:space:]]}"}; printf '%s' "$v"; }; "#,
r#"HEALTH_API_PW=$(ha_trim "${HEALTH_API_PASSWORD:-}"); "#,
r#"HEALTH_API_USER=$(ha_trim "${HEALTH_API_USERNAME:-}"); [ -n "$HEALTH_API_USER" ] || HEALTH_API_USER=railway; "#,
r#"if [ -n "$HEALTH_API_PW" ]; then nl=$(printf '\nx'); nl=${nl%x}; cr=$(printf '\r'); "#,
r#"case "$HEALTH_API_USER$HEALTH_API_PW" in *"$nl"*|*"$cr"*) echo 'HEALTH_API_USERNAME/HEALTH_API_PASSWORD contains a line break, which wget cannot send; refusing the request' >&2; exit 64;; esac; "#,
r#"HEALTH_API_RC=$(mktemp) || exit 1; trap 'rm -f "$HEALTH_API_RC"' EXIT; trap 'exit 129' HUP INT TERM; "#,
r#"printf 'auth_no_challenge = on\nhttp_user = %s\nhttp_password = %s\n' "$HEALTH_API_USER" "$HEALTH_API_PW" > "$HEALTH_API_RC"; "#,
r#"set -- --config="$HEALTH_API_RC"; else set -- --no-config; fi; "#,
);
const WGET_STATUS_TRAILER: &str = concat!(
r#"{ s=; o=; while IFS= read -r l; do l=${l#"${l%%[! ]*}"}; "#,
r#"case $l in HTTP/[0-9]*) s=${l#* }; s=${s%% *};; *) o="$o$l ";; esac; done; "#,
r#"if [ -n "$s" ]; then printf '\nHTTP_STATUS:%s' "$s"; else printf '%s' "$o"; fi; }"#,
);
fn switchover_command(client: NodeHttpClient, endpoint: &ResolvedEndpoint) -> String {
match client {
NodeHttpClient::Curl => format!(
r#"{prelude}printf '%s\n' "$HEALTH_API_CFG" | curl -s --max-time 8 -w '\nHTTP_STATUS:%{{http_code}}' "$@" -X POST localhost:{port}{path}"#,
prelude = HEALTH_API_AUTH_PRELUDE,
port = endpoint.port,
path = endpoint.path,
),
NodeHttpClient::Wget => format!(
r#"{prelude}{{ wget -q -S -O - --content-on-error --timeout=8 --tries=1 "$@" --method=POST http://localhost:{port}{path} 2>&1 >&3 | {trailer}; }} 3>&1"#,
prelude = WGET_HEALTH_API_AUTH_PRELUDE,
trailer = WGET_STATUS_TRAILER,
port = endpoint.port,
path = endpoint.path,
),
}
}
pub async fn request_switchover(
client: NodeHttpClient,
instance_id: &str,
endpoint: &ResolvedEndpoint,
) -> Result<String> {
let command = switchover_command(client, endpoint);
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"));
}
fn endpoint() -> ResolvedEndpoint {
resolve(Some(&HttpEndpoint {
port: Some(8080),
path: Some("/switchover".to_string()),
}))
.unwrap()
}
#[cfg(unix)]
struct CurlCall {
argv: Vec<String>,
config: Option<String>,
}
#[cfg(unix)]
fn curl_call_for(env: &[(&str, &str)]) -> CurlCall {
use std::io::Write;
let dir = std::env::temp_dir().join(format!(
"cli-health-api-auth-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let shim = dir.join("curl");
std::fs::write(
&shim,
concat!(
"#!/bin/sh\n",
"printf '%s\\n' \"$@\"\n",
"prev=\n",
"for a in \"$@\"; do\n",
" if [ \"$prev\" = -K ] && [ \"$a\" = - ]; then printf '%s\\n' '@@CONFIG@@'; cat; fi\n",
" prev=$a\n",
"done\n",
),
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut child = std::process::Command::new("sh")
.arg("-s")
.env(
"PATH",
format!(
"{}:{}",
dir.display(),
std::env::var("PATH").unwrap_or_default()
),
)
.env_remove("HEALTH_API_PASSWORD")
.env_remove("HEALTH_API_USERNAME")
.envs(env.iter().copied())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(switchover_command(NodeHttpClient::Curl, &endpoint()).as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
let (argv, config) = match stdout.split_once("@@CONFIG@@\n") {
Some((argv, config)) => (argv.to_string(), Some(config.to_string())),
None => (stdout, None),
};
CurlCall {
argv: argv.lines().map(str::to_string).collect(),
config,
}
}
#[cfg(unix)]
fn curl_config_value(line: &str) -> String {
let (_, quoted) = line.split_once('"').expect("a double-quoted value");
let mut out = String::new();
let mut chars = quoted.chars();
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' => match chars.next() {
Some('t') => out.push('\t'),
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('v') => out.push('\u{0B}'),
Some(other) => out.push(other),
None => break,
},
c => out.push(c),
}
}
out
}
#[test]
fn switchover_command_resolves_the_credential_inside_the_container() {
let cmd = switchover_command(NodeHttpClient::Curl, &endpoint());
assert!(cmd.starts_with(HEALTH_API_AUTH_PRELUDE));
assert!(cmd.contains("${HEALTH_API_PASSWORD:-}"));
assert!(cmd.contains("${HEALTH_API_USERNAME:-railway}"));
assert!(cmd.contains(
r#"HEALTH_API_CFG="user = \"$(curl_cfg_quote "$HEALTH_API_USER:$HEALTH_API_PW")\""; set -- -K -"#
));
assert!(cmd.contains("else HEALTH_API_CFG=; set --; fi"));
assert!(
!cmd.contains(" -u "),
"the credential must never be a curl argument"
);
assert!(cmd.contains(r#"printf '%s\n' "$HEALTH_API_CFG" | curl "#));
assert!(cmd.ends_with(r#""$@" -X POST localhost:8080/switchover"#));
}
#[cfg(unix)]
#[test]
fn an_open_node_gets_no_credential_flag_at_all() {
let call = curl_call_for(&[]);
assert!(call.config.is_none(), "{:?}", call.config);
assert!(
!call.argv.iter().any(|a| a == "-K" || a == "-u"),
"{:?}",
call.argv
);
assert_eq!(call.argv.last().unwrap(), "localhost:8080/switchover");
}
#[cfg(unix)]
#[test]
fn a_password_alone_authenticates_as_the_default_user() {
let call = curl_call_for(&[("HEALTH_API_PASSWORD", "s3cret")]);
assert_eq!(call.config.as_deref(), Some("user = \"railway:s3cret\"\n"));
assert!(
call.argv.iter().all(|a| !a.contains("s3cret")),
"{:?}",
call.argv
);
assert!(!call.argv.iter().any(|a| a == "-u"), "{:?}", call.argv);
let k = call
.argv
.iter()
.position(|a| a == "-K")
.expect("curl told to read its config from stdin");
assert_eq!(call.argv[k + 1], "-");
assert!(call.argv.iter().position(|a| a == "-X").unwrap() > k);
}
#[cfg(unix)]
#[test]
fn an_explicit_username_wins_over_the_default() {
let call = curl_call_for(&[
("HEALTH_API_PASSWORD", "s3cret"),
("HEALTH_API_USERNAME", "ops"),
]);
assert_eq!(call.config.as_deref(), Some("user = \"ops:s3cret\"\n"));
}
#[cfg(unix)]
#[test]
fn the_credential_is_escaped_for_curls_config_parser() {
let password = "p\"a\\s$s' w#rd\nnext\ttab";
let call = curl_call_for(&[("HEALTH_API_PASSWORD", password)]);
let config = call.config.expect("config document piped to curl");
assert_eq!(
config,
"user = \"railway:p\\\"a\\\\s$s' w#rd\\nnext\ttab\"\n"
);
assert_eq!(curl_config_value(&config), format!("railway:{password}"));
assert!(
call.argv.iter().all(|a| !a.contains("w#rd")),
"{:?}",
call.argv
);
}
#[test]
fn the_curl_dialect_renders_exactly_what_it_did_before() {
let role = resolve(Some(&HttpEndpoint {
port: Some(8080),
path: Some("/role".to_string()),
}))
.unwrap();
assert_eq!(
status_probe_command(NodeHttpClient::Curl, &role),
"curl -s -o /dev/null --max-time 4 -w '%{http_code}' localhost:8080/role"
);
assert_eq!(
switchover_command(NodeHttpClient::Curl, &endpoint()),
format!(
r#"{HEALTH_API_AUTH_PRELUDE}printf '%s\n' "$HEALTH_API_CFG" | curl -s --max-time 8 -w '\nHTTP_STATUS:%{{http_code}}' "$@" -X POST localhost:8080/switchover"#
)
);
assert_eq!(
parse_status_probe(NodeHttpClient::Curl, "503").unwrap(),
503
);
}
#[test]
fn the_wget_dialect_never_names_curl() {
let role = resolve(Some(&HttpEndpoint {
port: Some(8080),
path: Some("/role".to_string()),
}))
.unwrap();
let probe = status_probe_command(NodeHttpClient::Wget, &role);
assert!(probe.starts_with("wget -q -S -O /dev/null --timeout=4 --tries=1 --no-config "));
assert!(probe.contains("http://localhost:8080/role"));
let switchover = switchover_command(NodeHttpClient::Wget, &endpoint());
assert!(switchover.starts_with(WGET_HEALTH_API_AUTH_PRELUDE));
for cmd in [&probe, &switchover] {
assert!(!cmd.contains("curl"), "{cmd}");
assert!(
!cmd.contains("--password") && !cmd.contains("--user"),
"{cmd}"
);
}
}
#[test]
fn wget_status_is_read_off_the_last_response_line() {
let head = " HTTP/1.1 503 Service Unavailable\n content-type: application/json\n content-length: 21\n";
assert_eq!(parse_status_probe(NodeHttpClient::Wget, head).unwrap(), 503);
let redirected =
" HTTP/1.1 301 Moved Permanently\n Location: /role/\n HTTP/1.1 200 OK\n";
assert_eq!(
parse_status_probe(NodeHttpClient::Wget, redirected).unwrap(),
200
);
let via = " HTTP/1.1 200 OK\n Via: HTTP/1.1 edge\n";
assert_eq!(parse_status_probe(NodeHttpClient::Wget, via).unwrap(), 200);
assert!(parse_status_probe(NodeHttpClient::Wget, "").is_err());
assert!(parse_status_probe(NodeHttpClient::Wget, "sh: 1: wget: not found").is_err());
}
#[cfg(unix)]
struct FakeNode {
dir: std::path::PathBuf,
}
#[cfg(unix)]
impl FakeNode {
fn new(tag: &str, wget_script: Option<&str>) -> Self {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!(
"cli-fake-node-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("bin")).unwrap();
for tool in ["mktemp", "rm"] {
let real = ["/usr/bin", "/bin"]
.iter()
.map(|d| std::path::Path::new(d).join(tool))
.find(|p| p.exists())
.unwrap();
std::os::unix::fs::symlink(real, dir.join("bin").join(tool)).unwrap();
}
if let Some(script) = wget_script {
let shim = dir.join("bin").join("wget");
std::fs::write(&shim, script).unwrap();
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
}
Self { dir }
}
fn run(&self, command: &str, env: &[(&str, &str)]) -> std::process::Output {
use std::io::Write;
let mut child = std::process::Command::new("/bin/sh")
.arg("-s")
.env_clear()
.env("PATH", self.dir.join("bin"))
.env("TMPDIR", &self.dir)
.env("SHIM_LOG", self.dir.join("wget.log"))
.envs(env.iter().copied())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(command.as_bytes())
.unwrap();
child.wait_with_output().unwrap()
}
fn log(&self) -> String {
std::fs::read_to_string(self.dir.join("wget.log")).unwrap_or_default()
}
fn leftovers(&self) -> Vec<String> {
std::fs::read_dir(&self.dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|n| n != "bin" && n != "wget.log")
.collect()
}
}
#[cfg(unix)]
impl Drop for FakeNode {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[cfg(unix)]
fn wget_shim(status_line: &str, body: &str, exit: u8) -> String {
format!(
concat!(
"#!/bin/sh\n",
"for a in \"$@\"; do\n",
" printf 'ARG:%s\\n' \"$a\" >> \"$SHIM_LOG\"\n",
" case $a in --config=*) f=${{a#--config=}};\n",
" perm=$(/bin/ls -l \"$f\"); printf 'PERM:%.10s\\n' \"$perm\" >> \"$SHIM_LOG\";\n",
" while IFS= read -r line; do printf 'CFG:%s\\n' \"$line\" >> \"$SHIM_LOG\"; done < \"$f\";;\n",
" esac\n",
"done\n",
"printf ' %s\\n Content-Type: text/plain\\n' '{status}' >&2\n",
"printf '%s' '{body}'\n",
"exit {exit}\n",
),
status = status_line,
body = body,
exit = exit,
)
}
#[cfg(unix)]
#[test]
fn the_redis_declaration_probes_with_a_client_its_image_ships() {
use crate::controllers::database_engines::{REDIS, SwitchoverMechanism};
let SwitchoverMechanism::DeclaredHttp { http_client } = REDIS.ha.unwrap().switchover else {
panic!("redis-ha speaks the declared per-node contract");
};
let node = FakeNode::new(
"redis-probe",
Some(&wget_shim("HTTP/1.1 503 Service Unavailable", "", 8)),
);
let role = resolve(Some(&HttpEndpoint {
port: Some(8080),
path: Some("/role".to_string()),
}))
.unwrap();
let out = node.run(&status_probe_command(http_client, &role), &[]);
assert!(
out.status.success(),
"exit {:?}: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let code = parse_status_probe(http_client, &String::from_utf8_lossy(&out.stdout)).unwrap();
assert_eq!(interpret_role(code), Some(false));
assert!(node.log().contains("ARG:http://localhost:8080/role"));
}
#[cfg(unix)]
#[test]
fn a_wget_transport_failure_still_fails_the_exec() {
let node = FakeNode::new("wget-refused", Some("#!/bin/sh\nexit 4\n"));
let out = node.run(
&status_probe_command(NodeHttpClient::Wget, &endpoint()),
&[],
);
assert_eq!(out.status.code(), Some(4));
}
#[cfg(unix)]
#[test]
fn the_redis_switchover_runs_on_an_image_that_ships_only_wget() {
use crate::controllers::database_engines::{REDIS, SwitchoverMechanism};
let SwitchoverMechanism::DeclaredHttp { http_client } = REDIS.ha.unwrap().switchover else {
panic!("redis-ha speaks the declared per-node contract");
};
let node = FakeNode::new(
"redis-switchover",
Some(&wget_shim("HTTP/1.1 202 Accepted", "accepted", 0)),
);
let out = node.run(&switchover_command(http_client, &endpoint()), &[]);
assert!(
out.status.success(),
"exit {:?}: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
parse_switchover_response(&String::from_utf8_lossy(&out.stdout)).unwrap(),
"accepted"
);
let log = node.log();
assert!(log.contains("ARG:--no-config"), "{log}");
assert!(!log.contains("CFG:"), "{log}");
assert!(log.contains("ARG:--method=POST"), "{log}");
assert!(
log.contains("ARG:http://localhost:8080/switchover"),
"{log}"
);
}
#[cfg(unix)]
#[test]
fn a_wget_refusal_surfaces_the_coordinators_body() {
let node = FakeNode::new(
"wget-refusal",
Some(&wget_shim(
"HTTP/1.1 409 Conflict",
"candidate is not in sync",
8,
)),
);
let out = node.run(
&switchover_command(NodeHttpClient::Wget, &endpoint()),
&[("HEALTH_API_PASSWORD", "s3cret")],
);
assert!(out.status.success());
let err = parse_switchover_response(&String::from_utf8_lossy(&out.stdout))
.unwrap_err()
.to_string();
assert!(err.contains("409"), "{err}");
assert!(err.contains("candidate is not in sync"), "{err}");
}
#[cfg(unix)]
#[test]
fn the_wget_credential_travels_in_a_private_file_never_in_argv() {
let password = "p\"a\\s$s' w#rd=x";
let node = FakeNode::new(
"wget-credential",
Some(&wget_shim("HTTP/1.1 202 Accepted", "ok", 0)),
);
let out = node.run(
&switchover_command(NodeHttpClient::Wget, &endpoint()),
&[("HEALTH_API_PASSWORD", &format!(" {password}\n"))],
);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let log = node.log();
let args: Vec<&str> = log.lines().filter_map(|l| l.strip_prefix("ARG:")).collect();
assert!(
args.iter()
.all(|a| !a.contains("w#rd") && !a.contains("s3cret")),
"{args:?}"
);
assert!(args.iter().any(|a| a.starts_with("--config=")), "{args:?}");
assert!(log.contains("PERM:-rw-------"), "{log}");
let cfg: Vec<&str> = log.lines().filter_map(|l| l.strip_prefix("CFG:")).collect();
assert_eq!(
cfg,
vec![
"auth_no_challenge = on",
"http_user = railway",
&format!("http_password = {password}"),
]
);
assert!(node.leftovers().is_empty(), "{:?}", node.leftovers());
}
#[cfg(unix)]
#[test]
fn the_wget_credential_honours_an_explicit_username() {
let node = FakeNode::new(
"wget-username",
Some(&wget_shim("HTTP/1.1 202 Accepted", "ok", 0)),
);
let out = node.run(
&switchover_command(NodeHttpClient::Wget, &endpoint()),
&[
("HEALTH_API_PASSWORD", "s3cret"),
("HEALTH_API_USERNAME", " ops "),
],
);
assert!(out.status.success());
let log = node.log();
assert!(log.contains("CFG:http_user = ops\n"), "{log}");
assert!(log.contains("CFG:http_password = s3cret\n"), "{log}");
}
#[cfg(unix)]
#[test]
fn a_credential_wget_cannot_carry_is_refused_before_any_request() {
let node = FakeNode::new(
"wget-linebreak",
Some(&wget_shim("HTTP/1.1 202 Accepted", "ok", 0)),
);
let out = node.run(
&switchover_command(NodeHttpClient::Wget, &endpoint()),
&[("HEALTH_API_PASSWORD", "first\nsecond")],
);
assert_eq!(out.status.code(), Some(64));
assert!(String::from_utf8_lossy(&out.stderr).contains("line break"));
assert!(node.log().is_empty(), "wget must not run: {}", node.log());
assert!(node.leftovers().is_empty(), "{:?}", node.leftovers());
}
}