ito_core/
backend_health.rs1use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10
11use crate::backend_client::BackendRuntime;
12
13#[derive(Debug, Clone, Serialize)]
18pub struct BackendHealthStatus {
19 pub server_reachable: bool,
21 pub server_healthy: bool,
23 pub server_ready: bool,
25 pub server_version: Option<String>,
27 pub ready_reason: Option<String>,
29 pub auth_verified: bool,
31 pub token_scope: Option<String>,
33 pub error: Option<String>,
35}
36
37#[derive(Debug, Deserialize)]
39struct HealthResponse {
40 status: String,
41 version: String,
42}
43
44#[derive(Debug, Deserialize)]
46struct ReadyResponse {
47 status: String,
48 reason: Option<String>,
49}
50
51#[derive(Debug, Deserialize)]
53struct AuthVerifyResponse {
54 #[allow(dead_code)]
55 valid: bool,
56 scope: String,
57}
58
59const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
61
62pub 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 .http_status_as_error(false)
87 .build();
88 let agent: ureq::Agent = config.into();
89
90 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 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 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 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 }
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}