Skip to main content

tryaudex_core/
health.rs

1use serde::{Deserialize, Serialize};
2
3/// Individual health check result.
4#[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/// Full health report.
30#[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
42/// Run all health checks and return a report.
43pub 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!("Config loaded from {}", crate::config::Config::path().display()),
73        },
74        Err(e) => Check {
75            name: "config".to_string(),
76            status: CheckStatus::Warn,
77            message: format!("Config error (using defaults): {}", e),
78        },
79    }
80}
81
82fn check_session_store() -> Check {
83    match crate::session::SessionStore::new() {
84        Ok(store) => {
85            match store.list() {
86                Ok(sessions) => Check {
87                    name: "session_store".to_string(),
88                    status: CheckStatus::Ok,
89                    message: format!("{} sessions on disk", sessions.len()),
90                },
91                Err(e) => Check {
92                    name: "session_store".to_string(),
93                    status: CheckStatus::Warn,
94                    message: format!("Cannot list sessions: {}", e),
95                },
96            }
97        }
98        Err(e) => Check {
99            name: "session_store".to_string(),
100            status: CheckStatus::Fail,
101            message: format!("Cannot initialize session store: {}", e),
102        },
103    }
104}
105
106fn check_audit_log() -> Check {
107    match crate::audit::AuditLog::new() {
108        Ok(audit) => {
109            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        }
122        Err(e) => Check {
123            name: "audit_log".to_string(),
124            status: CheckStatus::Fail,
125            message: format!("Cannot initialize audit log: {}", e),
126        },
127    }
128}
129
130fn check_audit_writable() -> Check {
131    match crate::audit::AuditLog::new() {
132        Ok(audit) => {
133            let path = audit.path();
134            // Check if parent dir is writable
135            if let Some(parent) = path.parent() {
136                let test_file = parent.join(".audex_health_check");
137                match std::fs::write(&test_file, "health") {
138                    Ok(_) => {
139                        let _ = std::fs::remove_file(&test_file);
140                        Check {
141                            name: "audit_writable".to_string(),
142                            status: CheckStatus::Ok,
143                            message: "Audit log directory is writable".to_string(),
144                        }
145                    }
146                    Err(e) => Check {
147                        name: "audit_writable".to_string(),
148                        status: CheckStatus::Fail,
149                        message: format!("Audit log directory not writable: {}", e),
150                    },
151                }
152            } else {
153                Check {
154                    name: "audit_writable".to_string(),
155                    status: CheckStatus::Fail,
156                    message: "Cannot determine audit log parent directory".to_string(),
157                }
158            }
159        }
160        Err(e) => Check {
161            name: "audit_writable".to_string(),
162            status: CheckStatus::Fail,
163            message: format!("Cannot initialize audit log: {}", e),
164        },
165    }
166}
167
168fn check_credential_cache() -> Check {
169    match crate::credentials::CredentialCache::new() {
170        Ok(_) => Check {
171            name: "credential_cache".to_string(),
172            status: CheckStatus::Ok,
173            message: "Credential cache directory accessible".to_string(),
174        },
175        Err(e) => Check {
176            name: "credential_cache".to_string(),
177            status: CheckStatus::Warn,
178            message: format!("Credential cache unavailable: {}", e),
179        },
180    }
181}
182
183fn check_aws_env() -> Check {
184    let has_region = 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)".to_string(),
206        }
207    }
208}
209
210fn check_gcp_env() -> Check {
211    let has_creds = std::env::var("GOOGLE_APPLICATION_CREDENTIALS").is_ok()
212        || std::env::var("CLOUDSDK_AUTH_ACCESS_TOKEN").is_ok();
213    let has_project = std::env::var("GCLOUD_PROJECT").is_ok()
214        || std::env::var("GCP_PROJECT").is_ok();
215
216    if has_creds {
217        Check {
218            name: "gcp_env".to_string(),
219            status: CheckStatus::Ok,
220            message: format!("GCP credentials found{}", if has_project { " with project" } else { "" }),
221        }
222    } else {
223        Check {
224            name: "gcp_env".to_string(),
225            status: CheckStatus::Warn,
226            message: "No GCP credentials in environment (optional)".to_string(),
227        }
228    }
229}
230
231fn check_vault() -> Check {
232    let has_addr = std::env::var("VAULT_ADDR").is_ok();
233    let has_token = std::env::var("VAULT_TOKEN").is_ok();
234
235    // Also check config for vault settings
236    let config_vault = crate::config::Config::load()
237        .ok()
238        .and_then(|c| c.vault);
239
240    if config_vault.is_some() {
241        if has_addr || config_vault.as_ref().and_then(|v| v.address.as_ref()).is_some() {
242            Check {
243                name: "vault".to_string(),
244                status: CheckStatus::Ok,
245                message: "Vault backend configured".to_string(),
246            }
247        } else {
248            Check {
249                name: "vault".to_string(),
250                status: CheckStatus::Warn,
251                message: "Vault configured but no address set (set VAULT_ADDR or vault.address)".to_string(),
252            }
253        }
254    } else if has_addr && has_token {
255        Check {
256            name: "vault".to_string(),
257            status: CheckStatus::Ok,
258            message: "Vault environment detected (VAULT_ADDR + VAULT_TOKEN)".to_string(),
259        }
260    } else {
261        Check {
262            name: "vault".to_string(),
263            status: CheckStatus::Warn,
264            message: "No Vault backend configured (optional)".to_string(),
265        }
266    }
267}
268
269fn check_azure_env() -> Check {
270    let has_creds = std::env::var("AZURE_TENANT_ID").is_ok()
271        || std::env::var("AZURE_CLIENT_ID").is_ok();
272    let has_sub = std::env::var("AZURE_SUBSCRIPTION_ID").is_ok();
273
274    if has_creds {
275        Check {
276            name: "azure_env".to_string(),
277            status: CheckStatus::Ok,
278            message: format!("Azure credentials found{}", if has_sub { " with subscription" } else { "" }),
279        }
280    } else {
281        Check {
282            name: "azure_env".to_string(),
283            status: CheckStatus::Warn,
284            message: "No Azure credentials in environment (optional)".to_string(),
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_check_all_returns_report() {
295        let report = check_all();
296        assert!(!report.checks.is_empty());
297        assert!(report.checks.len() >= 5);
298    }
299
300    #[test]
301    fn test_health_report_serializable() {
302        let report = HealthReport {
303            overall: CheckStatus::Ok,
304            checks: vec![Check {
305                name: "test".to_string(),
306                status: CheckStatus::Ok,
307                message: "all good".to_string(),
308            }],
309        };
310        let json = serde_json::to_string(&report).unwrap();
311        assert!(json.contains("\"overall\":\"ok\""));
312    }
313
314    #[test]
315    fn test_overall_fail_if_any_fail() {
316        let checks = vec![
317            Check { name: "a".into(), status: CheckStatus::Ok, message: "ok".into() },
318            Check { name: "b".into(), status: CheckStatus::Fail, message: "bad".into() },
319        ];
320        let overall = if checks.iter().any(|c| c.status == CheckStatus::Fail) {
321            CheckStatus::Fail
322        } else {
323            CheckStatus::Ok
324        };
325        assert_eq!(overall, CheckStatus::Fail);
326    }
327
328    #[test]
329    fn test_overall_warn_if_any_warn() {
330        let checks = vec![
331            Check { name: "a".into(), status: CheckStatus::Ok, message: "ok".into() },
332            Check { name: "b".into(), status: CheckStatus::Warn, message: "meh".into() },
333        ];
334        let overall = if checks.iter().any(|c| c.status == CheckStatus::Fail) {
335            CheckStatus::Fail
336        } else if checks.iter().any(|c| c.status == CheckStatus::Warn) {
337            CheckStatus::Warn
338        } else {
339            CheckStatus::Ok
340        };
341        assert_eq!(overall, CheckStatus::Warn);
342    }
343
344    #[test]
345    fn test_is_healthy() {
346        let ok_report = HealthReport { overall: CheckStatus::Ok, checks: vec![] };
347        let warn_report = HealthReport { overall: CheckStatus::Warn, checks: vec![] };
348        let fail_report = HealthReport { overall: CheckStatus::Fail, checks: vec![] };
349        assert!(ok_report.is_healthy());
350        assert!(warn_report.is_healthy());
351        assert!(!fail_report.is_healthy());
352    }
353}