Skip to main content

opencode_cloud_core/docker/
health.rs

1//! Health check module for OpenCode service
2//!
3//! Provides health checking functionality by querying OpenCode's /global/health endpoint.
4
5use serde::{Deserialize, Serialize};
6use std::time::Duration;
7use thiserror::Error;
8
9use super::DockerClient;
10
11/// Response from OpenCode's /global/health endpoint
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct HealthResponse {
14    /// Whether the service is healthy
15    pub healthy: bool,
16    /// Service version string
17    pub version: String,
18}
19
20/// Extended health response including container stats
21#[derive(Debug, Serialize)]
22pub struct ExtendedHealthResponse {
23    /// Whether the service is healthy
24    pub healthy: bool,
25    /// Service version string
26    pub version: String,
27    /// Container state (running, stopped, etc.)
28    pub container_state: String,
29    /// Uptime in seconds
30    pub uptime_seconds: u64,
31    /// Memory usage in megabytes (if available)
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub memory_usage_mb: Option<u64>,
34}
35
36/// Errors that can occur during health checks
37#[derive(Debug, Error)]
38pub enum HealthError {
39    /// HTTP request failed
40    #[error("Request failed: {0}")]
41    RequestError(#[from] reqwest::Error),
42
43    /// Service returned non-200 status
44    #[error("Service unhealthy (HTTP {0})")]
45    Unhealthy(u16),
46
47    /// Connection refused - service may not be running
48    #[error("Connection refused - service may not be running")]
49    ConnectionRefused,
50
51    /// Request timed out - service may be starting
52    #[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
64/// Check health by querying OpenCode's /global/health endpoint
65///
66/// Returns the health response on success (HTTP 200).
67/// Returns an error for connection issues, timeouts, or non-200 responses.
68pub 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            // Check for connection refused
80            if e.is_connect() {
81                return Err(HealthError::ConnectionRefused);
82            }
83            // Check for timeout
84            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
101/// Check health with extended information including container stats
102///
103/// Combines basic health check with container statistics from Docker.
104/// If container stats fail, still returns response with container_state = "unknown".
105pub async fn check_health_extended(
106    client: &DockerClient,
107    bind_addr: &str,
108    port: u16,
109) -> Result<ExtendedHealthResponse, HealthError> {
110    // Get basic health info
111    let health = check_health(bind_addr, port).await?;
112
113    // Get container stats
114    let container_name = super::CONTAINER_NAME;
115
116    // Try to get container info
117    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                // Calculate uptime
128                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                // Get memory usage (would require stats API call - skip for now)
145                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        // Port 1 should always refuse connection
168        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}