sokuto_cli/commands/
healthcheck.rs1use std::io::{Read, Write};
7use std::net::{TcpStream, ToSocketAddrs};
8use std::time::Duration;
9
10#[derive(Debug, Clone)]
12pub struct HealthcheckArgs {
13 pub url: String,
15 pub timeout_secs: u64,
17}
18
19fn parse_http_url(url: &str) -> Result<(String, u16, String), String> {
21 let stripped = url.strip_prefix("http://").ok_or_else(|| {
22 format!("無効な URL スキームです。http:// で始まる必要があります: '{url}'。")
23 })?;
24
25 let (host_port, path) = match stripped.find('/') {
26 Some(idx) => (&stripped[..idx], &stripped[idx..]),
27 None => (stripped, "/"),
28 };
29
30 let (host, port) = if let Some(idx) = host_port.find(':') {
31 let host = &host_port[..idx];
32 let port_str = &host_port[idx + 1..];
33 let port = port_str
34 .parse::<u16>()
35 .map_err(|e| format!("ポート番号のパースに失敗しました: '{port_str}' ({e})。"))?;
36 (host.to_string(), port)
37 } else {
38 (host_port.to_string(), 80)
39 };
40
41 let path = if path.is_empty() {
42 "/".to_string()
43 } else {
44 path.to_string()
45 };
46
47 Ok((host, port, path))
48}
49
50pub fn run_healthcheck(
55 args: HealthcheckArgs,
56) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
57 let (host, port, path) = parse_http_url(&args.url)?;
58 let timeout = Duration::from_secs(args.timeout_secs);
59
60 let socket_addrs: Vec<_> = format!("{host}:{port}").to_socket_addrs()?.collect();
61 if socket_addrs.is_empty() {
62 return Err(format!("ホスト名のアドレス解決に失敗しました: {host}:{port}。").into());
63 }
64
65 let mut last_err = None;
66 let mut stream = None;
67
68 for addr in socket_addrs {
69 match TcpStream::connect_timeout(&addr, timeout) {
70 Ok(s) => {
71 stream = Some(s);
72 break;
73 }
74 Err(e) => {
75 last_err = Some(e);
76 }
77 }
78 }
79
80 let mut stream = match stream {
81 Some(s) => s,
82 None => {
83 let err_msg = last_err
84 .map(|e| e.to_string())
85 .unwrap_or_else(|| "接続先アドレスがありません。".to_string());
86 return Err(format!("{host}:{port} への接続に失敗しました: {err_msg}。").into());
87 }
88 };
89
90 stream.set_read_timeout(Some(timeout))?;
91 stream.set_write_timeout(Some(timeout))?;
92
93 let user_agent = concat!("sokuto-healthcheck/", env!("CARGO_PKG_VERSION"));
94 let request = format!(
95 "GET {path} HTTP/1.1\r\n\
96 Host: {host}:{port}\r\n\
97 User-Agent: {user_agent}\r\n\
98 Connection: close\r\n\
99 \r\n"
100 );
101
102 stream.write_all(request.as_bytes())?;
103
104 let mut response_bytes = Vec::new();
105 let mut buffer = [0u8; 1024];
106 loop {
107 match stream.read(&mut buffer) {
108 Ok(0) => break,
109 Ok(n) => response_bytes.extend_from_slice(&buffer[..n]),
110 Err(e)
111 if e.kind() == std::io::ErrorKind::WouldBlock
112 || e.kind() == std::io::ErrorKind::TimedOut =>
113 {
114 return Err("応答読み取りがタイムアウトしました。".into());
115 }
116 Err(e) => return Err(e.into()),
117 }
118 }
119
120 let response_text = String::from_utf8_lossy(&response_bytes);
121 let mut lines = response_text.lines();
122 let status_line = lines.next().ok_or("空の HTTP レスポンスを受信しました。")?;
123
124 let parts: Vec<&str> = status_line.split_whitespace().collect();
126 if parts.len() < 2 {
127 return Err(format!("不正な HTTP ステータス行です: '{status_line}'。").into());
128 }
129
130 let status_code = parts[1].parse::<u16>().map_err(|e| {
131 format!(
132 "ステータスコードのパースに失敗しました: '{}' ({e})。",
133 parts[1]
134 )
135 })?;
136
137 if status_code == 200 {
138 tracing::debug!("ヘルスチェック成功: {status_line}。");
139 Ok(())
140 } else {
141 Err(
142 format!("ヘルスチェック失敗 (ステータスコード {status_code}): '{status_line}'。")
143 .into(),
144 )
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn test_parse_http_url() {
154 let (host, port, path) = parse_http_url("http://127.0.0.1:3000/ready").unwrap();
155 assert_eq!(host, "127.0.0.1");
156 assert_eq!(port, 3000);
157 assert_eq!(path, "/ready");
158
159 let (host, port, path) = parse_http_url("http://localhost/health").unwrap();
160 assert_eq!(host, "localhost");
161 assert_eq!(port, 80);
162 assert_eq!(path, "/health");
163
164 let err = parse_http_url("https://localhost:3000/health");
165 assert!(err.is_err());
166 }
167}