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!(
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            // Check if parent dir is writable
134            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    // Also check config for vault settings
240    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}