Skip to main content

sokuto_cli/commands/
healthcheck.rs

1//! # ヘルスチェック実行コマンド
2//!
3//! 最小コンテナ環境など curl が存在しない環境において、
4//! 外部依存なしで HTTP エンドポイントの死活監視・準備状態確認 (Liveness / Readiness) を行う。
5
6use std::io::{Read, Write};
7use std::net::{TcpStream, ToSocketAddrs};
8use std::time::Duration;
9
10/// ヘルスチェック実行引数構造体。
11#[derive(Debug, Clone)]
12pub struct HealthcheckArgs {
13    /// 監視対象 URL (例: `http://127.0.0.1:3000/ready`)。
14    pub url: String,
15    /// 接続および応答タイムアウト秒数。
16    pub timeout_secs: u64,
17}
18
19/// URL をホスト、ポート、パスにパースする。
20fn 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
50/// HTTP GET リクエストを送信し、ステータスコード 200 OK であるかを判定する。
51///
52/// # 引数
53/// - `args`: ヘルスチェック対象 URL およびタイムアウト設定。
54pub 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    // 例: "HTTP/1.1 200 OK"
125    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}