opencode_cloud_core/docker/
health.rs1use serde::{Deserialize, Serialize};
6use std::time::Duration;
7use thiserror::Error;
8
9use super::DockerClient;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct HealthResponse {
14 pub healthy: bool,
16 pub version: String,
18}
19
20#[derive(Debug, Serialize)]
22pub struct ExtendedHealthResponse {
23 pub healthy: bool,
25 pub version: String,
27 pub container_state: String,
29 pub uptime_seconds: u64,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub memory_usage_mb: Option<u64>,
34}
35
36#[derive(Debug, Error)]
38pub enum HealthError {
39 #[error("Request failed: {0}")]
41 RequestError(#[from] reqwest::Error),
42
43 #[error("Service unhealthy (HTTP {0})")]
45 Unhealthy(u16),
46
47 #[error("Connection refused - service may not be running")]
49 ConnectionRefused,
50
51 #[error("Timeout - service may be starting")]
53 Timeout,
54}
55
56fn format_host(bind_addr: &str) -> String {
57 if bind_addr.contains(':') && !bind_addr.starts_with('[') {
58 format!("[{bind_addr}]")
59 } else {
60 bind_addr.to_string()
61 }
62}
63
64pub async fn check_health(bind_addr: &str, port: u16) -> Result<HealthResponse, HealthError> {
69 let host = format_host(bind_addr);
70 let url = format!("http://{host}:{port}/global/health");
71
72 let client = reqwest::Client::builder()
73 .timeout(Duration::from_secs(5))
74 .build()?;
75
76 let response = match client.get(&url).send().await {
77 Ok(resp) => resp,
78 Err(e) => {
79 if e.is_connect() {
81 return Err(HealthError::ConnectionRefused);
82 }
83 if e.is_timeout() {
85 return Err(HealthError::Timeout);
86 }
87 return Err(HealthError::RequestError(e));
88 }
89 };
90
91 let status = response.status();
92
93 if status.is_success() {
94 let health_response = response.json::<HealthResponse>().await?;
95 Ok(health_response)
96 } else {
97 Err(HealthError::Unhealthy(status.as_u16()))
98 }
99}
100
101pub async fn check_health_extended(
106 client: &DockerClient,
107 bind_addr: &str,
108 port: u16,
109) -> Result<ExtendedHealthResponse, HealthError> {
110 let health = check_health(bind_addr, port).await?;
112
113 let container_name = super::CONTAINER_NAME;
115
116 let (container_state, uptime_seconds, memory_usage_mb) =
118 match client.inner().inspect_container(container_name, None).await {
119 Ok(info) => {
120 let state = info
121 .state
122 .as_ref()
123 .and_then(|s| s.status.as_ref())
124 .map(|s| s.to_string())
125 .unwrap_or_else(|| "unknown".to_string());
126
127 let uptime = info
129 .state
130 .as_ref()
131 .and_then(|s| s.started_at.as_ref())
132 .and_then(|started| {
133 let timestamp = chrono::DateTime::parse_from_rfc3339(started).ok()?;
134 let now = chrono::Utc::now();
135 let started_utc = timestamp.with_timezone(&chrono::Utc);
136 if now >= started_utc {
137 Some((now - started_utc).num_seconds() as u64)
138 } else {
139 None
140 }
141 })
142 .unwrap_or(0);
143
144 let memory = None;
146
147 (state, uptime, memory)
148 }
149 Err(_) => ("unknown".to_string(), 0, None),
150 };
151
152 Ok(ExtendedHealthResponse {
153 healthy: health.healthy,
154 version: health.version,
155 container_state,
156 uptime_seconds,
157 memory_usage_mb,
158 })
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[tokio::test]
166 async fn test_health_check_connection_refused() {
167 let result = check_health("127.0.0.1", 1).await;
169 assert!(result.is_err());
170 match result.unwrap_err() {
171 HealthError::ConnectionRefused => {}
172 other => panic!("Expected ConnectionRefused, got: {other:?}"),
173 }
174 }
175
176 #[test]
177 fn format_host_wraps_ipv6() {
178 assert_eq!(format_host("::1"), "[::1]");
179 }
180
181 #[test]
182 fn format_host_preserves_ipv4() {
183 assert_eq!(format_host("127.0.0.1"), "127.0.0.1");
184 }
185}