use std::net::TcpStream;
use std::path::PathBuf;
use std::time::Duration;
use crate::connector::{ServiceInfo, ServiceLifecycle, ServiceStatus};
pub(super) fn binary_on_path(binary: &str) -> bool {
which::which(binary).is_ok()
}
pub(super) fn read_addr_file(path: &std::path::Path) -> Option<String> {
let raw = std::fs::read_to_string(path).ok()?;
let trimmed = raw.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
pub(super) fn tcp_probe(addr: &str) -> bool {
let Ok(socket_addr) = addr.parse() else {
return false;
};
TcpStream::connect_timeout(&socket_addr, Duration::from_millis(300))
.map(|s| {
drop(s);
true
})
.unwrap_or(false)
}
pub(super) fn fetch_health_version(addr: &str) -> Option<String> {
use std::io::{Read, Write};
let mut stream =
TcpStream::connect_timeout(&addr.parse().ok()?, Duration::from_millis(800)).ok()?;
stream
.set_read_timeout(Some(Duration::from_millis(800)))
.ok()?;
let request = format!("GET /health HTTP/1.0\r\nHost: {addr}\r\n\r\n");
stream.write_all(request.as_bytes()).ok()?;
let mut buf = Vec::new();
stream.read_to_end(&mut buf).ok()?;
let raw = String::from_utf8_lossy(&buf);
let body_start = raw.find("\r\n\r\n").map(|i| i + 4)?;
let body = &raw[body_start..];
let json: serde_json::Value = serde_json::from_str(body).ok()?;
json.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
const VERSION_TIMEOUT: Duration = Duration::from_secs(2);
pub(super) enum VersionProbe {
Ran(Option<String>),
CannotExecute(String),
}
pub(super) fn binary_version(binary: &str) -> VersionProbe {
let mut child = match std::process::Command::new(binary)
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(child) => child,
Err(e) => return VersionProbe::CannotExecute(format!("spawn `{binary} --version`: {e}")),
};
let deadline = std::time::Instant::now() + VERSION_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => break,
Ok(Some(status)) => {
let _ = child.wait();
return VersionProbe::CannotExecute(format!(
"`{binary} --version` exited {status}"
));
}
Ok(None) if std::time::Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(10));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return VersionProbe::CannotExecute(format!(
"`{binary} --version` did not exit within {VERSION_TIMEOUT:?}"
));
}
Err(e) => {
return VersionProbe::CannotExecute(format!(
"waiting on `{binary} --version`: {e}"
));
}
}
}
match child.wait_with_output() {
Ok(output) => VersionProbe::Ran(
String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.map(str::to_owned),
),
Err(e) => VersionProbe::CannotExecute(format!("reading `{binary} --version`: {e}")),
}
}
fn normalize_addr(addr: &str) -> &str {
crate::url_util::strip_schemes(addr)
}
pub(super) fn detect_service(
id: &'static str,
display_name: &'static str,
binary: &str,
addr_file: PathBuf,
) -> ServiceInfo {
if !binary_on_path(binary) {
return ServiceInfo {
id: id.to_string(),
display_name: display_name.to_string(),
status: ServiceStatus::Absent,
version: None,
url: None,
hint: None,
lifecycle: ServiceLifecycle::Daemon,
};
}
if let Some(addr) = read_addr_file(&addr_file)
&& tcp_probe(&addr)
{
let base_url = format!("http://{}", normalize_addr(&addr));
let version = fetch_health_version(&addr);
return ServiceInfo {
id: id.to_string(),
display_name: display_name.to_string(),
status: ServiceStatus::Running,
version,
url: Some(base_url),
hint: None,
lifecycle: ServiceLifecycle::Daemon,
};
}
ServiceInfo {
id: id.to_string(),
display_name: display_name.to_string(),
status: ServiceStatus::Available,
version: None,
url: None,
hint: None,
lifecycle: ServiceLifecycle::Daemon,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_read_addr_file_returns_trimmed_content() {
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("http_addr");
fs::write(&path, " 127.0.0.1:9999\n").expect("write");
assert_eq!(read_addr_file(&path), Some("127.0.0.1:9999".to_string()));
}
#[test]
fn test_read_addr_file_absent_returns_none() {
let tmp = TempDir::new().expect("tempdir");
assert_eq!(read_addr_file(&tmp.path().join("no_file")), None);
}
#[test]
fn test_read_addr_file_empty_returns_none() {
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("http_addr");
fs::write(&path, " \n").expect("write");
assert_eq!(read_addr_file(&path), None);
}
#[test]
fn test_tcp_probe_unreachable() {
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind free port");
let addr = listener.local_addr().expect("local_addr").to_string();
drop(listener);
assert!(!tcp_probe(&addr), "closed port must return false");
}
#[test]
fn test_tcp_probe_reachable() {
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind free port");
let addr = listener.local_addr().expect("local_addr").to_string();
assert!(tcp_probe(&addr), "listening port must return true");
drop(listener);
}
#[test]
fn test_tcp_probe_malformed_addr() {
assert!(!tcp_probe("not-an-addr"));
}
#[test]
fn test_normalize_addr_bare_unchanged() {
assert_eq!(normalize_addr("127.0.0.1:7788"), "127.0.0.1:7788");
}
#[test]
fn test_normalize_addr_strips_http_scheme() {
assert_eq!(normalize_addr("http://127.0.0.1:7788"), "127.0.0.1:7788");
}
#[test]
fn test_normalize_addr_strips_https_scheme() {
assert_eq!(normalize_addr("https://127.0.0.1:7788"), "127.0.0.1:7788");
}
#[cfg(unix)]
#[test]
fn a_binary_that_cannot_execute_is_not_available() {
match binary_version("/usr/bin/false") {
VersionProbe::CannotExecute(why) => {
assert!(
why.contains("exited"),
"the operator needs the reason: {why}"
);
}
VersionProbe::Ran(v) => {
panic!("a binary exiting 1 must not read as a clean run, got {v:?}")
}
}
}
#[test]
fn binary_version_reads_the_version_off_a_real_binary() {
if which::which("cargo").is_err() {
eprintln!("skip: no cargo on PATH to probe as a stand-in binary");
return;
}
match binary_version("cargo") {
VersionProbe::Ran(version) => {
assert!(version.is_some(), "`cargo --version` prints a version");
}
VersionProbe::CannotExecute(why) => panic!("cargo must run: {why}"),
}
}
}