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(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    // Also check config for vault settings
242    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    // Check if `az` CLI is available in PATH
285    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}