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::active_resource_names().container_name;
115
116 let (container_state, uptime_seconds, memory_usage_mb) = match client
118 .inner()
119 .inspect_container(&container_name, None)
120 .await
121 {
122 Ok(info) => {
123 let state = info
124 .state
125 .as_ref()
126 .and_then(|s| s.status.as_ref())
127 .map(|s| s.to_string())
128 .unwrap_or_else(|| "unknown".to_string());
129
130 let uptime = info
132 .state
133 .as_ref()
134 .and_then(|s| s.started_at.as_ref())
135 .and_then(|started| {
136 let timestamp = chrono::DateTime::parse_from_rfc3339(started).ok()?;
137 let now = chrono::Utc::now();
138 let started_utc = timestamp.with_timezone(&chrono::Utc);
139 if now >= started_utc {
140 Some((now - started_utc).num_seconds() as u64)
141 } else {
142 None
143 }
144 })
145 .unwrap_or(0);
146
147 let memory = None;
149
150 (state, uptime, memory)
151 }
152 Err(_) => ("unknown".to_string(), 0, None),
153 };
154
155 Ok(ExtendedHealthResponse {
156 healthy: health.healthy,
157 version: health.version,
158 container_state,
159 uptime_seconds,
160 memory_usage_mb,
161 })
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[tokio::test]
169 async fn test_health_check_connection_refused() {
170 let result = check_health("127.0.0.1", 1).await;
172 assert!(result.is_err());
173 match result.unwrap_err() {
174 HealthError::ConnectionRefused => {}
175 other => panic!("Expected ConnectionRefused, got: {other:?}"),
176 }
177 }
178
179 #[test]
180 fn format_host_wraps_ipv6() {
181 assert_eq!(format_host("::1"), "[::1]");
182 }
183
184 #[test]
185 fn format_host_preserves_ipv4() {
186 assert_eq!(format_host("127.0.0.1"), "127.0.0.1");
187 }
188}