1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct Check {
6 pub name: String,
7 pub status: CheckStatus,
8 pub message: String,
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(rename_all = "lowercase")]
13pub enum CheckStatus {
14 Ok,
15 Warn,
16 Fail,
17}
18
19impl std::fmt::Display for CheckStatus {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 match self {
22 Self::Ok => write!(f, "ok"),
23 Self::Warn => write!(f, "warn"),
24 Self::Fail => write!(f, "fail"),
25 }
26 }
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct HealthReport {
32 pub overall: CheckStatus,
33 pub checks: Vec<Check>,
34}
35
36impl HealthReport {
37 pub fn is_healthy(&self) -> bool {
38 self.overall == CheckStatus::Ok || self.overall == CheckStatus::Warn
39 }
40}
41
42pub fn check_all() -> HealthReport {
44 let checks = vec![
45 check_config(),
46 check_session_store(),
47 check_audit_log(),
48 check_audit_writable(),
49 check_credential_cache(),
50 check_aws_env(),
51 check_gcp_env(),
52 check_azure_env(),
53 check_vault(),
54 ];
55
56 let overall = if checks.iter().any(|c| c.status == CheckStatus::Fail) {
57 CheckStatus::Fail
58 } else if checks.iter().any(|c| c.status == CheckStatus::Warn) {
59 CheckStatus::Warn
60 } else {
61 CheckStatus::Ok
62 };
63
64 HealthReport { overall, checks }
65}
66
67fn check_config() -> Check {
68 match crate::config::Config::load() {
69 Ok(_) => Check {
70 name: "config".to_string(),
71 status: CheckStatus::Ok,
72 message: format!(
73 "Config loaded from {}",
74 crate::config::Config::path().display()
75 ),
76 },
77 Err(e) => Check {
78 name: "config".to_string(),
79 status: CheckStatus::Warn,
80 message: format!("Config error (using defaults): {}", e),
81 },
82 }
83}
84
85fn check_session_store() -> Check {
86 match crate::session::SessionStore::new() {
87 Ok(store) => match store.list() {
88 Ok(sessions) => Check {
89 name: "session_store".to_string(),
90 status: CheckStatus::Ok,
91 message: format!("{} sessions on disk", sessions.len()),
92 },
93 Err(e) => Check {
94 name: "session_store".to_string(),
95 status: CheckStatus::Warn,
96 message: format!("Cannot list sessions: {}", e),
97 },
98 },
99 Err(e) => Check {
100 name: "session_store".to_string(),
101 status: CheckStatus::Fail,
102 message: format!("Cannot initialize session store: {}", e),
103 },
104 }
105}
106
107fn check_audit_log() -> Check {
108 match crate::audit::AuditLog::new() {
109 Ok(audit) => match audit.read(None) {
110 Ok(entries) => Check {
111 name: "audit_log".to_string(),
112 status: CheckStatus::Ok,
113 message: format!("{} audit entries", entries.len()),
114 },
115 Err(e) => Check {
116 name: "audit_log".to_string(),
117 status: CheckStatus::Warn,
118 message: format!("Cannot read audit log: {}", e),
119 },
120 },
121 Err(e) => Check {
122 name: "audit_log".to_string(),
123 status: CheckStatus::Fail,
124 message: format!("Cannot initialize audit log: {}", e),
125 },
126 }
127}
128
129fn check_audit_writable() -> Check {
130 match crate::audit::AuditLog::new() {
131 Ok(audit) => {
132 let path = audit.path();
133 if let Some(parent) = path.parent() {
135 let test_file = parent.join(".audex_health_check");
136 match std::fs::write(&test_file, "health") {
137 Ok(_) => {
138 let _ = std::fs::remove_file(&test_file);
139 Check {
140 name: "audit_writable".to_string(),
141 status: CheckStatus::Ok,
142 message: "Audit log directory is writable".to_string(),
143 }
144 }
145 Err(e) => Check {
146 name: "audit_writable".to_string(),
147 status: CheckStatus::Fail,
148 message: format!("Audit log directory not writable: {}", e),
149 },
150 }
151 } else {
152 Check {
153 name: "audit_writable".to_string(),
154 status: CheckStatus::Fail,
155 message: "Cannot determine audit log parent directory".to_string(),
156 }
157 }
158 }
159 Err(e) => Check {
160 name: "audit_writable".to_string(),
161 status: CheckStatus::Fail,
162 message: format!("Cannot initialize audit log: {}", e),
163 },
164 }
165}
166
167fn check_credential_cache() -> Check {
168 match crate::credentials::CredentialCache::new() {
169 Ok(_) => Check {
170 name: "credential_cache".to_string(),
171 status: CheckStatus::Ok,
172 message: "Credential cache directory accessible".to_string(),
173 },
174 Err(e) => Check {
175 name: "credential_cache".to_string(),
176 status: CheckStatus::Warn,
177 message: format!("Credential cache unavailable: {}", e),
178 },
179 }
180}
181
182fn check_aws_env() -> Check {
183 let has_region =
184 std::env::var("AWS_REGION").is_ok() || std::env::var("AWS_DEFAULT_REGION").is_ok();
185 let has_creds = std::env::var("AWS_ACCESS_KEY_ID").is_ok()
186 || std::env::var("AWS_PROFILE").is_ok()
187 || std::env::var("AWS_ROLE_ARN").is_ok();
188
189 if has_creds && has_region {
190 Check {
191 name: "aws_env".to_string(),
192 status: CheckStatus::Ok,
193 message: "AWS credentials and region configured".to_string(),
194 }
195 } else if has_creds {
196 Check {
197 name: "aws_env".to_string(),
198 status: CheckStatus::Warn,
199 message: "AWS credentials found but no region set (AWS_REGION)".to_string(),
200 }
201 } else {
202 Check {
203 name: "aws_env".to_string(),
204 status: CheckStatus::Warn,
205 message: "No AWS credentials in environment (set AWS_PROFILE or AWS_ACCESS_KEY_ID)"
206 .to_string(),
207 }
208 }
209}
210
211fn check_gcp_env() -> Check {
212 let has_creds = std::env::var("GOOGLE_APPLICATION_CREDENTIALS").is_ok()
213 || std::env::var("CLOUDSDK_AUTH_ACCESS_TOKEN").is_ok();
214 let has_project =
215 std::env::var("GCLOUD_PROJECT").is_ok() || std::env::var("GCP_PROJECT").is_ok();
216
217 if has_creds {
218 Check {
219 name: "gcp_env".to_string(),
220 status: CheckStatus::Ok,
221 message: format!(
222 "GCP credentials found{}",
223 if has_project { " with project" } else { "" }
224 ),
225 }
226 } else {
227 Check {
228 name: "gcp_env".to_string(),
229 status: CheckStatus::Warn,
230 message: "No GCP credentials in environment (optional)".to_string(),
231 }
232 }
233}
234
235fn check_vault() -> Check {
236 let has_addr = std::env::var("VAULT_ADDR").is_ok();
237 let has_token = std::env::var("VAULT_TOKEN").is_ok();
238
239 let config_vault = crate::config::Config::load().ok().and_then(|c| c.vault);
241
242 if config_vault.is_some() {
243 if has_addr
244 || config_vault
245 .as_ref()
246 .and_then(|v| v.address.as_ref())
247 .is_some()
248 {
249 Check {
250 name: "vault".to_string(),
251 status: CheckStatus::Ok,
252 message: "Vault backend configured".to_string(),
253 }
254 } else {
255 Check {
256 name: "vault".to_string(),
257 status: CheckStatus::Warn,
258 message: "Vault configured but no address set (set VAULT_ADDR or vault.address)"
259 .to_string(),
260 }
261 }
262 } else if has_addr && has_token {
263 Check {
264 name: "vault".to_string(),
265 status: CheckStatus::Ok,
266 message: "Vault environment detected (VAULT_ADDR + VAULT_TOKEN)".to_string(),
267 }
268 } else {
269 Check {
270 name: "vault".to_string(),
271 status: CheckStatus::Warn,
272 message: "No Vault backend configured (optional)".to_string(),
273 }
274 }
275}
276
277fn check_azure_env() -> Check {
278 let has_creds =
279 std::env::var("AZURE_TENANT_ID").is_ok() || std::env::var("AZURE_CLIENT_ID").is_ok();
280 let has_sub = std::env::var("AZURE_SUBSCRIPTION_ID").is_ok();
281
282 if has_creds {
283 Check {
284 name: "azure_env".to_string(),
285 status: CheckStatus::Ok,
286 message: format!(
287 "Azure credentials found{}",
288 if has_sub { " with subscription" } else { "" }
289 ),
290 }
291 } else {
292 Check {
293 name: "azure_env".to_string(),
294 status: CheckStatus::Warn,
295 message: "No Azure credentials in environment (optional)".to_string(),
296 }
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn test_check_all_returns_report() {
306 let report = check_all();
307 assert!(!report.checks.is_empty());
308 assert!(report.checks.len() >= 5);
309 }
310
311 #[test]
312 fn test_health_report_serializable() {
313 let report = HealthReport {
314 overall: CheckStatus::Ok,
315 checks: vec![Check {
316 name: "test".to_string(),
317 status: CheckStatus::Ok,
318 message: "all good".to_string(),
319 }],
320 };
321 let json = serde_json::to_string(&report).unwrap();
322 assert!(json.contains("\"overall\":\"ok\""));
323 }
324
325 #[test]
326 fn test_overall_fail_if_any_fail() {
327 let checks = [
328 Check {
329 name: "a".into(),
330 status: CheckStatus::Ok,
331 message: "ok".into(),
332 },
333 Check {
334 name: "b".into(),
335 status: CheckStatus::Fail,
336 message: "bad".into(),
337 },
338 ];
339 let overall = if checks.iter().any(|c| c.status == CheckStatus::Fail) {
340 CheckStatus::Fail
341 } else {
342 CheckStatus::Ok
343 };
344 assert_eq!(overall, CheckStatus::Fail);
345 }
346
347 #[test]
348 fn test_overall_warn_if_any_warn() {
349 let checks = [
350 Check {
351 name: "a".into(),
352 status: CheckStatus::Ok,
353 message: "ok".into(),
354 },
355 Check {
356 name: "b".into(),
357 status: CheckStatus::Warn,
358 message: "meh".into(),
359 },
360 ];
361 let overall = if checks.iter().any(|c| c.status == CheckStatus::Fail) {
362 CheckStatus::Fail
363 } else if checks.iter().any(|c| c.status == CheckStatus::Warn) {
364 CheckStatus::Warn
365 } else {
366 CheckStatus::Ok
367 };
368 assert_eq!(overall, CheckStatus::Warn);
369 }
370
371 #[test]
372 fn test_is_healthy() {
373 let ok_report = HealthReport {
374 overall: CheckStatus::Ok,
375 checks: vec![],
376 };
377 let warn_report = HealthReport {
378 overall: CheckStatus::Warn,
379 checks: vec![],
380 };
381 let fail_report = HealthReport {
382 overall: CheckStatus::Fail,
383 checks: vec![],
384 };
385 assert!(ok_report.is_healthy());
386 assert!(warn_report.is_healthy());
387 assert!(!fail_report.is_healthy());
388 }
389}