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(format!(".audex_health_{}", uuid::Uuid::new_v4()));
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 = std::env::var("GCLOUD_PROJECT").is_ok()
215 || std::env::var("GCP_PROJECT").is_ok()
216 || std::env::var("GOOGLE_CLOUD_PROJECT").is_ok()
217 || std::env::var("AUDEX_GCP_PROJECT").is_ok();
218
219 if has_creds {
220 Check {
221 name: "gcp_env".to_string(),
222 status: CheckStatus::Ok,
223 message: format!(
224 "GCP credentials found{}",
225 if has_project { " with project" } else { "" }
226 ),
227 }
228 } else {
229 Check {
230 name: "gcp_env".to_string(),
231 status: CheckStatus::Warn,
232 message: "No GCP credentials in environment (optional)".to_string(),
233 }
234 }
235}
236
237fn check_vault() -> Check {
238 let has_addr = std::env::var("VAULT_ADDR").is_ok();
239 let has_token = std::env::var("VAULT_TOKEN").is_ok();
240
241 let config_vault = crate::config::Config::load().ok().and_then(|c| c.vault);
243
244 if config_vault.is_some() {
245 if has_addr
246 || config_vault
247 .as_ref()
248 .and_then(|v| v.address.as_ref())
249 .is_some()
250 {
251 Check {
252 name: "vault".to_string(),
253 status: CheckStatus::Ok,
254 message: "Vault backend configured".to_string(),
255 }
256 } else {
257 Check {
258 name: "vault".to_string(),
259 status: CheckStatus::Warn,
260 message: "Vault configured but no address set (set VAULT_ADDR or vault.address)"
261 .to_string(),
262 }
263 }
264 } else if has_addr && has_token {
265 Check {
266 name: "vault".to_string(),
267 status: CheckStatus::Ok,
268 message: "Vault environment detected (VAULT_ADDR + VAULT_TOKEN)".to_string(),
269 }
270 } else {
271 Check {
272 name: "vault".to_string(),
273 status: CheckStatus::Warn,
274 message: "No Vault backend configured (optional)".to_string(),
275 }
276 }
277}
278
279fn check_azure_env() -> Check {
280 let has_creds =
281 std::env::var("AZURE_TENANT_ID").is_ok() || std::env::var("AZURE_CLIENT_ID").is_ok();
282 let has_sub = std::env::var("AZURE_SUBSCRIPTION_ID").is_ok();
283
284 let has_az_cli = std::process::Command::new("az")
286 .arg("--version")
287 .stdout(std::process::Stdio::null())
288 .stderr(std::process::Stdio::null())
289 .status()
290 .is_ok();
291
292 if has_creds {
293 Check {
294 name: "azure_env".to_string(),
295 status: CheckStatus::Ok,
296 message: format!(
297 "Azure credentials found{}{}",
298 if has_sub { " with subscription" } else { "" },
299 if has_az_cli {
300 ""
301 } else {
302 " (az CLI not in PATH)"
303 }
304 ),
305 }
306 } else if has_az_cli {
307 Check {
308 name: "azure_env".to_string(),
309 status: CheckStatus::Warn,
310 message: "Azure CLI found but no env vars set. Run `az account show` to verify login"
311 .to_string(),
312 }
313 } else {
314 Check {
315 name: "azure_env".to_string(),
316 status: CheckStatus::Warn,
317 message: "No Azure credentials in environment (optional)".to_string(),
318 }
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn test_check_all_returns_report() {
328 let report = check_all();
329 assert!(!report.checks.is_empty());
330 assert!(report.checks.len() >= 5);
331 }
332
333 #[test]
334 fn test_health_report_serializable() {
335 let report = HealthReport {
336 overall: CheckStatus::Ok,
337 checks: vec![Check {
338 name: "test".to_string(),
339 status: CheckStatus::Ok,
340 message: "all good".to_string(),
341 }],
342 };
343 let json = serde_json::to_string(&report).unwrap();
344 assert!(json.contains("\"overall\":\"ok\""));
345 }
346
347 #[test]
348 fn test_overall_fail_if_any_fail() {
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::Fail,
358 message: "bad".into(),
359 },
360 ];
361 let overall = if checks.iter().any(|c| c.status == CheckStatus::Fail) {
362 CheckStatus::Fail
363 } else {
364 CheckStatus::Ok
365 };
366 assert_eq!(overall, CheckStatus::Fail);
367 }
368
369 #[test]
370 fn test_overall_warn_if_any_warn() {
371 let checks = [
372 Check {
373 name: "a".into(),
374 status: CheckStatus::Ok,
375 message: "ok".into(),
376 },
377 Check {
378 name: "b".into(),
379 status: CheckStatus::Warn,
380 message: "meh".into(),
381 },
382 ];
383 let overall = if checks.iter().any(|c| c.status == CheckStatus::Fail) {
384 CheckStatus::Fail
385 } else if checks.iter().any(|c| c.status == CheckStatus::Warn) {
386 CheckStatus::Warn
387 } else {
388 CheckStatus::Ok
389 };
390 assert_eq!(overall, CheckStatus::Warn);
391 }
392
393 #[test]
394 fn test_is_healthy() {
395 let ok_report = HealthReport {
396 overall: CheckStatus::Ok,
397 checks: vec![],
398 };
399 let warn_report = HealthReport {
400 overall: CheckStatus::Warn,
401 checks: vec![],
402 };
403 let fail_report = HealthReport {
404 overall: CheckStatus::Fail,
405 checks: vec![],
406 };
407 assert!(ok_report.is_healthy());
408 assert!(warn_report.is_healthy());
409 assert!(!fail_report.is_healthy());
410 }
411}