Skip to main content

ito_core/
backend_health.rs

1//! Backend health-check client for validating connectivity and auth.
2//!
3//! Provides a reusable health-check function that probes the backend server's
4//! health, readiness, and auth verify endpoints. Used by `ito backend status`
5//! and available for programmatic consumers.
6
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10
11use crate::backend_client::BackendRuntime;
12
13/// Status report from a backend health check.
14///
15/// Contains results from probing `/api/v1/health`, `/api/v1/ready`, and
16/// `/api/v1/projects/{org}/{repo}/auth/verify`.
17#[derive(Debug, Clone, Serialize)]
18pub struct BackendHealthStatus {
19    /// Whether the server responded to the health endpoint.
20    pub server_reachable: bool,
21    /// Whether the health endpoint returned `"ok"`.
22    pub server_healthy: bool,
23    /// Whether the ready endpoint returned `"ready"`.
24    pub server_ready: bool,
25    /// Server version from the health response.
26    pub server_version: Option<String>,
27    /// Reason from the ready endpoint when not ready.
28    pub ready_reason: Option<String>,
29    /// Whether the auth verify endpoint returned 200.
30    pub auth_verified: bool,
31    /// Token scope from auth verify (e.g. "admin", "project").
32    pub token_scope: Option<String>,
33    /// Error message if any check failed.
34    pub error: Option<String>,
35}
36
37/// Health endpoint response shape.
38#[derive(Debug, Deserialize)]
39struct HealthResponse {
40    status: String,
41    version: String,
42}
43
44/// Ready endpoint response shape.
45#[derive(Debug, Deserialize)]
46struct ReadyResponse {
47    status: String,
48    reason: Option<String>,
49}
50
51/// Auth verify endpoint response shape.
52#[derive(Debug, Deserialize)]
53struct AuthVerifyResponse {
54    #[allow(dead_code)]
55    valid: bool,
56    scope: String,
57}
58
59/// Default timeout for health-check requests (5 seconds).
60const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
61
62/// Check backend health, readiness, and auth verification.
63///
64/// Makes three HTTP requests:
65/// 1. `GET /api/v1/health` — server is alive and responding
66/// 2. `GET /api/v1/ready` — server data directory is accessible
67/// 3. `GET /api/v1/projects/{org}/{repo}/auth/verify` — token is valid
68///
69/// Uses a 5-second timeout per request, independent of the runtime's
70/// configured timeout for data operations.
71pub fn check_backend_health(runtime: &BackendRuntime) -> BackendHealthStatus {
72    let mut status = BackendHealthStatus {
73        server_reachable: false,
74        server_healthy: false,
75        server_ready: false,
76        server_version: None,
77        ready_reason: None,
78        auth_verified: false,
79        token_scope: None,
80        error: None,
81    };
82
83    let config = ureq::Agent::config_builder()
84        .timeout_global(Some(HEALTH_CHECK_TIMEOUT))
85        // Disable automatic error on 4xx/5xx so we can map status codes
86        .http_status_as_error(false)
87        .build();
88    let agent: ureq::Agent = config.into();
89
90    // 1. Health check
91    let health_url = format!("{}/api/v1/health", runtime.base_url);
92    match agent.get(&health_url).call() {
93        Ok(mut response) => {
94            status.server_reachable = true;
95            let status_code = response.status().as_u16();
96            if status_code != 200 {
97                status.error = Some(format!("Health endpoint returned HTTP {status_code}"));
98                return status;
99            }
100            let text = response
101                .body_mut()
102                .read_to_string()
103                .unwrap_or_else(|_| String::new());
104            match serde_json::from_str::<HealthResponse>(&text) {
105                Ok(health) => {
106                    status.server_healthy = health.status == "ok";
107                    status.server_version = Some(health.version);
108                }
109                Err(e) => {
110                    status.error = Some(format!("Failed to parse health response: {e}"));
111                    return status;
112                }
113            }
114        }
115        Err(e) => {
116            status.error = Some(format!("Server unreachable: {e}"));
117            return status;
118        }
119    }
120
121    // 2. Ready check
122    let ready_url = format!("{}/api/v1/ready", runtime.base_url);
123    match agent.get(&ready_url).call() {
124        Ok(mut response) => {
125            let status_code = response.status().as_u16();
126            let text = response
127                .body_mut()
128                .read_to_string()
129                .unwrap_or_else(|_| String::new());
130            if status_code == 200 {
131                match serde_json::from_str::<ReadyResponse>(&text) {
132                    Ok(ready) => {
133                        status.server_ready = ready.status == "ready";
134                        status.ready_reason = ready.reason;
135                    }
136                    Err(e) => {
137                        status.error = Some(format!("Failed to parse ready response: {e}"));
138                        return status;
139                    }
140                }
141            } else if status_code == 503 {
142                // Not ready is a valid state, try to parse the body
143                status.server_ready = false;
144                match serde_json::from_str::<ReadyResponse>(&text) {
145                    Ok(ready) => {
146                        status.ready_reason = ready.reason;
147                    }
148                    Err(_) => {
149                        status.ready_reason = Some("Server returned 503".to_string());
150                    }
151                }
152            } else {
153                status.error = Some(format!("Ready endpoint returned HTTP {status_code}"));
154                return status;
155            }
156        }
157        Err(e) => {
158            status.error = Some(format!("Ready check failed: {e}"));
159            return status;
160        }
161    }
162
163    // 3. Auth verify
164    let auth_url = format!(
165        "{}/api/v1/projects/{}/{}/auth/verify",
166        runtime.base_url, runtime.org, runtime.repo
167    );
168    match agent
169        .get(&auth_url)
170        .header("Authorization", &format!("Bearer {}", runtime.token))
171        .call()
172    {
173        Ok(mut response) => {
174            let status_code = response.status().as_u16();
175            if status_code == 200 {
176                status.auth_verified = true;
177                let text = response
178                    .body_mut()
179                    .read_to_string()
180                    .unwrap_or_else(|_| String::new());
181                match serde_json::from_str::<AuthVerifyResponse>(&text) {
182                    Ok(verify) => {
183                        status.token_scope = Some(verify.scope);
184                    }
185                    Err(_) => {
186                        // Auth passed but couldn't parse scope - still considered verified
187                    }
188                }
189            } else if status_code == 401 {
190                status.auth_verified = false;
191                status.error = Some(
192                    "Authentication failed. Check your token or seed. \
193                     Use 'ito backend generate-token' to derive a project token from the server seed."
194                        .to_string(),
195                );
196            } else if status_code == 403 {
197                status.auth_verified = false;
198                status.error = Some(format!(
199                    "Organization/repository '{}/{}' is not in the server allowlist.",
200                    runtime.org, runtime.repo
201                ));
202            } else {
203                status.error = Some(format!("Auth verify returned HTTP {status_code}"));
204            }
205        }
206        Err(e) => {
207            status.error = Some(format!("Auth verify failed: {e}"));
208        }
209    }
210
211    status
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn backend_health_status_default_is_all_false() {
220        let status = BackendHealthStatus {
221            server_reachable: false,
222            server_healthy: false,
223            server_ready: false,
224            server_version: None,
225            ready_reason: None,
226            auth_verified: false,
227            token_scope: None,
228            error: None,
229        };
230
231        assert!(!status.server_reachable);
232        assert!(!status.server_healthy);
233        assert!(!status.server_ready);
234        assert!(!status.auth_verified);
235        assert!(status.server_version.is_none());
236        assert!(status.ready_reason.is_none());
237        assert!(status.token_scope.is_none());
238        assert!(status.error.is_none());
239    }
240
241    #[test]
242    fn backend_health_status_serializes_to_json() {
243        let status = BackendHealthStatus {
244            server_reachable: true,
245            server_healthy: true,
246            server_ready: true,
247            server_version: Some("0.1.0".to_string()),
248            ready_reason: None,
249            auth_verified: true,
250            token_scope: Some("project".to_string()),
251            error: None,
252        };
253
254        let json = serde_json::to_string(&status).expect("should serialize");
255        assert!(json.contains("\"server_reachable\":true"));
256        assert!(json.contains("\"server_healthy\":true"));
257        assert!(json.contains("\"server_ready\":true"));
258        assert!(json.contains("\"server_version\":\"0.1.0\""));
259        assert!(json.contains("\"auth_verified\":true"));
260        assert!(json.contains("\"token_scope\":\"project\""));
261    }
262
263    #[test]
264    fn backend_health_status_serializes_error_state() {
265        let status = BackendHealthStatus {
266            server_reachable: false,
267            server_healthy: false,
268            server_ready: false,
269            server_version: None,
270            ready_reason: None,
271            auth_verified: false,
272            token_scope: None,
273            error: Some("Connection refused".to_string()),
274        };
275
276        let json = serde_json::to_string(&status).expect("should serialize");
277        assert!(json.contains("\"server_reachable\":false"));
278        assert!(json.contains("\"error\":\"Connection refused\""));
279    }
280}