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!("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 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 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}