fix(quadlet): HealthCmd falls back to bash /dev/tcp before exit 0

The generated HealthCmd reported healthy (exit 0) whenever the image
shipped neither wget nor curl — btcpay-server sat 'healthy' in podman
ps while completely non-functional (live-testing report 2026-07-10).
Try a bash /dev/tcp connect to the health URL's host:port before
giving up; only images with none of wget/curl/bash still skip Podman
health (dropping exit 0 entirely would restart-loop those). Podman's
health timeout bounds the connect attempt.

Note: the rendered unit text changes, so every http-healthcheck app
gets a one-time unit rewrite + restart on first reconcile after this
binary lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-10 09:45:06 -04:00
parent 3a5c5db187
commit b7be296953

View File

@ -538,13 +538,18 @@ fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<Hea
format!("{url}{path}") format!("{url}{path}")
}; };
let helper_timeout = health_timeout_seconds(&hc.timeout); let helper_timeout = health_timeout_seconds(&hc.timeout);
let (tcp_host, tcp_port) = health_url_host_port(&final_url);
// Images vary wildly: SearXNG ships wget but no curl, while some // Images vary wildly: SearXNG ships wget but no curl, while some
// Node images ship neither. Use whichever probe helper exists and // images (btcpay's dotnet base) ship neither but do have bash —
// skip Podman health if the image has none; host-side lifecycle // fall through to a bash /dev/tcp connect probe there so the
// probes still verify reachability. // container can't report healthy while its port is dead
// (btcpay-server, 2026-07-10). Podman's own health timeout bounds
// the connect attempt. Only images with none of the three skip
// Podman health entirely; host-side lifecycle probes still verify
// reachability for those.
format!( format!(
"if command -v wget >/dev/null 2>&1; then wget -q -T {1} -O /dev/null {0}; elif command -v curl >/dev/null 2>&1; then curl -fsS -m {1} {0}; else exit 0; fi", "if command -v wget >/dev/null 2>&1; then wget -q -T {1} -O /dev/null {0}; elif command -v curl >/dev/null 2>&1; then curl -fsS -m {1} {0}; elif command -v bash >/dev/null 2>&1; then bash -c 'exec 3<>/dev/tcp/{2}/{3}'; else exit 0; fi",
final_url, helper_timeout final_url, helper_timeout, tcp_host, tcp_port
) )
} }
"cmd" => hc.endpoint.as_deref()?.to_string(), "cmd" => hc.endpoint.as_deref()?.to_string(),
@ -558,6 +563,24 @@ fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<Hea
}) })
} }
/// Host and port of an http(s) health URL, for the bash /dev/tcp fallback
/// probe. Defaults to port 80/443 by scheme when the authority carries none.
fn health_url_host_port(url: &str) -> (String, u16) {
let (default_port, rest) = if let Some(rest) = url.strip_prefix("https://") {
(443, rest)
} else {
(80, url.strip_prefix("http://").unwrap_or(url))
};
let authority = rest.split('/').next().unwrap_or(rest);
match authority.rsplit_once(':') {
Some((host, port)) => match port.parse::<u16>() {
Ok(port) => (host.to_string(), port),
Err(_) => (authority.to_string(), default_port),
},
None => (authority.to_string(), default_port),
}
}
fn health_timeout_seconds(raw: &str) -> u64 { fn health_timeout_seconds(raw: &str) -> u64 {
let trimmed = raw.trim(); let trimmed = raw.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@ -1459,7 +1482,22 @@ app:
let h = translate_health_check(&http).expect("http must translate"); let h = translate_health_check(&http).expect("http must translate");
assert_eq!( assert_eq!(
h.cmd, h.cmd,
"if command -v wget >/dev/null 2>&1; then wget -q -T 3 -O /dev/null http://localhost:8080/health; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 3 http://localhost:8080/health; else exit 0; fi" "if command -v wget >/dev/null 2>&1; then wget -q -T 3 -O /dev/null http://localhost:8080/health; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 3 http://localhost:8080/health; elif command -v bash >/dev/null 2>&1; then bash -c 'exec 3<>/dev/tcp/localhost/8080'; else exit 0; fi"
);
// The /dev/tcp fallback must target the URL's host:port, with
// scheme-default ports when the authority carries none.
assert_eq!(
health_url_host_port("http://localhost:8080/health"),
("localhost".to_string(), 8080)
);
assert_eq!(
health_url_host_port("http://127.0.0.1/"),
("127.0.0.1".to_string(), 80)
);
assert_eq!(
health_url_host_port("https://localhost/status"),
("localhost".to_string(), 443)
); );
let cmdck = HealthCheck { let cmdck = HealthCheck {