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::active_resource_names().container_name;
115
116    // Try to get container info
117    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            // Calculate uptime
131            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            // Get memory usage (would require stats API call - skip for now)
148            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        // Port 1 should always refuse connection
171        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}