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