1use std::io::ErrorKind;
32use std::process::Command;
33
34use semver::{Version, VersionReq};
35use serde::{Deserialize, Serialize};
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(tag = "status", rename_all = "snake_case")]
43pub enum ToolCheckOutcome {
44 Ok { detail: Option<String> },
47 Missing { install_hint: String },
49 VersionMismatch {
51 found: String,
52 required: String,
53 install_hint: String,
54 },
55 AuthFailed {
57 detail: String,
58 recovery_hint: String,
59 },
60 Unreachable {
62 detail: String,
63 recovery_hint: String,
64 },
65 ProbeError { detail: String },
69}
70
71impl ToolCheckOutcome {
72 pub fn is_ok(&self) -> bool {
74 matches!(self, ToolCheckOutcome::Ok { .. })
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct ToolCheck {
81 pub name: String,
84 pub description: String,
86 pub outcome: ToolCheckOutcome,
87}
88
89impl ToolCheck {
90 pub fn ok(
91 name: impl Into<String>,
92 description: impl Into<String>,
93 detail: Option<String>,
94 ) -> Self {
95 Self {
96 name: name.into(),
97 description: description.into(),
98 outcome: ToolCheckOutcome::Ok { detail },
99 }
100 }
101
102 pub fn missing(
103 name: impl Into<String>,
104 description: impl Into<String>,
105 install_hint: impl Into<String>,
106 ) -> Self {
107 Self {
108 name: name.into(),
109 description: description.into(),
110 outcome: ToolCheckOutcome::Missing {
111 install_hint: install_hint.into(),
112 },
113 }
114 }
115
116 pub fn version_mismatch(
117 name: impl Into<String>,
118 description: impl Into<String>,
119 found: impl Into<String>,
120 required: impl Into<String>,
121 install_hint: impl Into<String>,
122 ) -> Self {
123 Self {
124 name: name.into(),
125 description: description.into(),
126 outcome: ToolCheckOutcome::VersionMismatch {
127 found: found.into(),
128 required: required.into(),
129 install_hint: install_hint.into(),
130 },
131 }
132 }
133
134 pub fn auth_failed(
135 name: impl Into<String>,
136 description: impl Into<String>,
137 detail: impl Into<String>,
138 recovery_hint: impl Into<String>,
139 ) -> Self {
140 Self {
141 name: name.into(),
142 description: description.into(),
143 outcome: ToolCheckOutcome::AuthFailed {
144 detail: detail.into(),
145 recovery_hint: recovery_hint.into(),
146 },
147 }
148 }
149
150 pub fn unreachable(
151 name: impl Into<String>,
152 description: impl Into<String>,
153 detail: impl Into<String>,
154 recovery_hint: impl Into<String>,
155 ) -> Self {
156 Self {
157 name: name.into(),
158 description: description.into(),
159 outcome: ToolCheckOutcome::Unreachable {
160 detail: detail.into(),
161 recovery_hint: recovery_hint.into(),
162 },
163 }
164 }
165
166 pub fn probe_error(
167 name: impl Into<String>,
168 description: impl Into<String>,
169 detail: impl Into<String>,
170 ) -> Self {
171 Self {
172 name: name.into(),
173 description: description.into(),
174 outcome: ToolCheckOutcome::ProbeError {
175 detail: detail.into(),
176 },
177 }
178 }
179}
180
181fn probe(binary: &str, args: &[&str]) -> Result<String, ProbeFailure> {
187 let output = Command::new(binary).args(args).output().map_err(|e| {
188 if e.kind() == ErrorKind::NotFound {
189 ProbeFailure::NotFound
190 } else {
191 ProbeFailure::Io(e.to_string())
192 }
193 })?;
194 if output.status.success() {
195 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
196 } else {
197 Err(ProbeFailure::NonZero {
198 code: output.status.code(),
199 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
200 })
201 }
202}
203
204#[derive(Debug)]
205enum ProbeFailure {
206 NotFound,
207 NonZero { code: Option<i32>, stderr: String },
208 Io(String),
209}
210
211pub fn check_binary_present(
214 name: &str,
215 binary: &str,
216 args: &[&str],
217 install_hint: &str,
218) -> ToolCheck {
219 let description = format!("`{binary}` is on $PATH");
220 match probe(binary, args) {
221 Ok(stdout) => {
222 let detail = stdout.lines().next().map(|s| s.trim().to_string());
223 ToolCheck::ok(name, description, detail)
224 }
225 Err(ProbeFailure::NotFound) => ToolCheck::missing(name, description, install_hint),
226 Err(ProbeFailure::NonZero { code, stderr, .. }) => ToolCheck::probe_error(
227 name,
228 description,
229 format!(
230 "exit {}: {}",
231 code.map(|c| c.to_string()).unwrap_or_else(|| "?".into()),
232 stderr.trim()
233 ),
234 ),
235 Err(ProbeFailure::Io(detail)) => ToolCheck::probe_error(name, description, detail),
236 }
237}
238
239pub fn check_version_probe(
247 name: &str,
248 binary: &str,
249 args: &[&str],
250 parser: fn(&str) -> Option<Version>,
251 required: &VersionReq,
252 install_hint: &str,
253) -> ToolCheck {
254 let description = format!("`{binary}` version satisfies `{required}`");
255 let stdout = match probe(binary, args) {
256 Ok(s) => s,
257 Err(ProbeFailure::NotFound) => {
258 return ToolCheck::missing(name, description, install_hint);
259 }
260 Err(ProbeFailure::NonZero { code, stderr, .. }) => {
261 return ToolCheck::probe_error(
262 name,
263 description,
264 format!(
265 "exit {}: {}",
266 code.map(|c| c.to_string()).unwrap_or_else(|| "?".into()),
267 stderr.trim()
268 ),
269 );
270 }
271 Err(ProbeFailure::Io(detail)) => {
272 return ToolCheck::probe_error(name, description, detail);
273 }
274 };
275 let Some(found) = parser(&stdout) else {
276 return ToolCheck::probe_error(
277 name,
278 description,
279 format!("could not parse version from: {}", stdout.trim()),
280 );
281 };
282 if required.matches(&found) {
283 ToolCheck::ok(name, description, Some(found.to_string()))
284 } else {
285 ToolCheck::version_mismatch(
286 name,
287 description,
288 found.to_string(),
289 required.to_string(),
290 install_hint,
291 )
292 }
293}
294
295pub fn parse_first_semver_token(stdout: &str) -> Option<Version> {
300 for token in stdout.split(|c: char| c.is_whitespace() || matches!(c, ',' | '(' | ')' | '/')) {
301 let cleaned = token.trim_start_matches('v');
302 let core = cleaned.split(['-', '+']).next().unwrap_or(cleaned);
304 if core.matches('.').count() < 2 {
306 continue;
307 }
308 if let Ok(v) = Version::parse(core) {
309 return Some(v);
310 }
311 }
312 None
313}
314
315pub fn parse_kubectl_version(stdout: &str) -> Option<Version> {
319 let cleaned = stdout.replace('"', " ");
323 parse_first_semver_token(&cleaned)
324}
325
326pub const MIN_TOFU_VERSION: &str = "1.6.0";
329pub const MIN_TERRAFORM_VERSION: &str = "1.5.0";
332pub const MIN_KUBECTL_VERSION: &str = "1.27.0";
334pub const MIN_HELM_VERSION: &str = "3.12.0";
336pub const MIN_DOCKER_VERSION: &str = "24.0.0";
338pub const MIN_PODMAN_VERSION: &str = "4.5.0";
340pub const MIN_AWS_VERSION: &str = "2.13.0";
342pub const MIN_GCLOUD_VERSION: &str = "450.0.0";
344pub const MIN_AZ_VERSION: &str = "2.50.0";
346
347fn version_req_at_least(min: &str) -> VersionReq {
351 format!(">={min}")
352 .parse()
353 .expect("hardcoded version requirement parses")
354}
355
356pub fn tofu() -> ToolCheck {
358 check_version_probe(
359 "tofu",
360 "tofu",
361 &["version", "-json"],
362 parse_first_semver_token,
363 &version_req_at_least(MIN_TOFU_VERSION),
364 "Install OpenTofu from https://opentofu.org/docs/intro/install/ (preferred over Terraform).",
365 )
366}
367
368pub fn terraform() -> ToolCheck {
372 check_version_probe(
373 "terraform",
374 "terraform",
375 &["version", "-json"],
376 parse_first_semver_token,
377 &version_req_at_least(MIN_TERRAFORM_VERSION),
378 "Install Terraform >= 1.5.0 from https://developer.hashicorp.com/terraform/install — but prefer `tofu` (OpenTofu).",
379 )
380}
381
382pub fn kubectl() -> ToolCheck {
384 check_version_probe(
385 "kubectl",
386 "kubectl",
387 &["version", "--client", "--output=yaml"],
388 parse_kubectl_version,
389 &version_req_at_least(MIN_KUBECTL_VERSION),
390 "Install kubectl from https://kubernetes.io/docs/tasks/tools/#kubectl.",
391 )
392}
393
394pub fn helm() -> ToolCheck {
396 check_version_probe(
397 "helm",
398 "helm",
399 &["version", "--short"],
400 parse_first_semver_token,
401 &version_req_at_least(MIN_HELM_VERSION),
402 "Install Helm from https://helm.sh/docs/intro/install/.",
403 )
404}
405
406pub fn docker() -> ToolCheck {
408 check_version_probe(
409 "docker",
410 "docker",
411 &["version", "--format", "{{.Client.Version}}"],
412 parse_first_semver_token,
413 &version_req_at_least(MIN_DOCKER_VERSION),
414 "Install Docker from https://docs.docker.com/engine/install/ or use Podman.",
415 )
416}
417
418pub fn podman() -> ToolCheck {
420 check_version_probe(
421 "podman",
422 "podman",
423 &["version", "--format", "{{.Client.Version}}"],
424 parse_first_semver_token,
425 &version_req_at_least(MIN_PODMAN_VERSION),
426 "Install Podman from https://podman.io/docs/installation.",
427 )
428}
429
430pub fn aws() -> ToolCheck {
432 check_version_probe(
433 "aws",
434 "aws",
435 &["--version"],
436 parse_first_semver_token,
437 &version_req_at_least(MIN_AWS_VERSION),
438 "Install AWS CLI v2 from https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html.",
439 )
440}
441
442pub fn gcloud() -> ToolCheck {
444 check_version_probe(
445 "gcloud",
446 "gcloud",
447 &["version", "--format=value(\"Google Cloud SDK\")"],
448 parse_first_semver_token,
449 &version_req_at_least(MIN_GCLOUD_VERSION),
450 "Install gcloud from https://cloud.google.com/sdk/docs/install.",
451 )
452}
453
454pub fn az() -> ToolCheck {
456 check_version_probe(
457 "az",
458 "az",
459 &["version", "--output", "tsv", "--query", "\"azure-cli\""],
460 parse_first_semver_token,
461 &version_req_at_least(MIN_AZ_VERSION),
462 "Install Azure CLI from https://learn.microsoft.com/cli/azure/install-azure-cli.",
463 )
464}
465
466pub fn aws_caller_identity(region: Option<&str>) -> ToolCheck {
470 let name = "aws.caller-identity";
471 let description = "AWS credentials resolve to a caller identity".to_string();
472 let mut args: Vec<&str> = vec!["sts", "get-caller-identity", "--output", "text"];
473 if let Some(r) = region {
474 args.push("--region");
475 args.push(r);
476 }
477 match probe("aws", &args) {
478 Ok(stdout) => ToolCheck::ok(
479 name,
480 description,
481 stdout.lines().next().map(|s| s.trim().to_string()),
482 ),
483 Err(ProbeFailure::NotFound) => ToolCheck::missing(
484 name,
485 description,
486 "AWS CLI v2 not installed; see `aws` check.",
487 ),
488 Err(ProbeFailure::NonZero { stderr, .. }) => ToolCheck::auth_failed(
489 name,
490 description,
491 stderr.trim().to_string(),
492 "Run `aws configure sso` or set AWS_PROFILE / AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.",
493 ),
494 Err(ProbeFailure::Io(detail)) => ToolCheck::probe_error(name, description, detail),
495 }
496}
497
498fn auth_probe_first_line(
505 name: &str,
506 description: &str,
507 binary: &str,
508 args: &[&str],
509 empty_detail: &str,
510 missing_hint: &str,
511 recovery_hint: &str,
512) -> ToolCheck {
513 match probe(binary, args) {
514 Ok(stdout) => {
515 let first = stdout.lines().next().map(|s| s.trim()).unwrap_or("");
516 if first.is_empty() {
517 ToolCheck::auth_failed(name, description, empty_detail, recovery_hint)
518 } else {
519 ToolCheck::ok(name, description, Some(first.to_string()))
520 }
521 }
522 Err(ProbeFailure::NotFound) => ToolCheck::missing(name, description, missing_hint),
523 Err(ProbeFailure::NonZero { stderr, .. }) => {
524 ToolCheck::auth_failed(name, description, stderr.trim().to_string(), recovery_hint)
525 }
526 Err(ProbeFailure::Io(detail)) => ToolCheck::probe_error(name, description, detail),
527 }
528}
529
530pub fn gcloud_auth_list() -> ToolCheck {
533 auth_probe_first_line(
534 "gcloud.auth",
535 "gcloud has an ACTIVE authentication",
536 "gcloud",
537 &[
538 "auth",
539 "list",
540 "--filter=status:ACTIVE",
541 "--format=value(account)",
542 ],
543 "no ACTIVE account found",
544 "gcloud not installed; see `gcloud` check.",
545 "Run `gcloud auth login` (or `gcloud auth activate-service-account`).",
546 )
547}
548
549pub fn az_account_show() -> ToolCheck {
552 auth_probe_first_line(
553 "az.account",
554 "Azure CLI session has an active subscription",
555 "az",
556 &["account", "show", "--output", "tsv", "--query", "id"],
557 "no active subscription",
558 "Azure CLI not installed; see `az` check.",
559 "Run `az login` and `az account set --subscription <id>`.",
560 )
561}
562
563pub fn kubectl_can_i(verb: &str, resource: &str, namespace: Option<&str>) -> ToolCheck {
568 let name = format!("kubectl.can-i:{verb}:{resource}");
569 let description = match namespace {
570 Some(ns) => format!("`kubectl auth can-i {verb} {resource} -n {ns}` is allowed"),
571 None => format!("`kubectl auth can-i {verb} {resource}` is allowed"),
572 };
573 let mut args: Vec<&str> = vec!["auth", "can-i", verb, resource];
574 if let Some(ns) = namespace {
575 args.push("-n");
576 args.push(ns);
577 }
578 match probe("kubectl", &args) {
579 Ok(stdout) => {
580 let answer = stdout.lines().next().map(|s| s.trim()).unwrap_or("");
581 if answer.eq_ignore_ascii_case("yes") {
582 ToolCheck::ok(name, description, Some(answer.to_string()))
583 } else {
584 ToolCheck::auth_failed(
585 name,
586 description,
587 answer.to_string(),
588 "Grant the required role/binding via the env-pack credentials bootstrap (Phase C/D).",
589 )
590 }
591 }
592 Err(ProbeFailure::NotFound) => ToolCheck::missing(
593 name,
594 description,
595 "kubectl not installed; see `kubectl` check.",
596 ),
597 Err(ProbeFailure::NonZero { stderr, .. }) => {
598 let stderr_lc = stderr.to_lowercase();
599 if stderr_lc.contains("unable to connect")
603 || stderr_lc.contains("couldn't get current server")
604 || stderr_lc.contains("no such host")
605 {
606 ToolCheck::unreachable(
607 name,
608 description,
609 stderr.trim().to_string(),
610 "Verify $KUBECONFIG points at a reachable cluster.",
611 )
612 } else {
613 ToolCheck::auth_failed(
614 name,
615 description,
616 stderr.trim().to_string(),
617 "Inspect the kubectl error and grant the required role.",
618 )
619 }
620 }
621 Err(ProbeFailure::Io(detail)) => ToolCheck::probe_error(name, description, detail),
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 #[test]
630 fn outcome_serializes_with_status_tag() {
631 let ok = ToolCheckOutcome::Ok {
632 detail: Some("1.6.0".into()),
633 };
634 let s = serde_json::to_string(&ok).unwrap();
635 assert!(s.contains("\"status\":\"ok\""));
636 assert!(s.contains("\"detail\":\"1.6.0\""));
637
638 let missing = ToolCheckOutcome::Missing {
639 install_hint: "brew install tofu".into(),
640 };
641 let s = serde_json::to_string(&missing).unwrap();
642 assert!(s.contains("\"status\":\"missing\""));
643 assert!(s.contains("install_hint"));
644
645 let auth = ToolCheckOutcome::AuthFailed {
646 detail: "no creds".into(),
647 recovery_hint: "aws configure".into(),
648 };
649 let s = serde_json::to_string(&auth).unwrap();
650 assert!(s.contains("\"status\":\"auth_failed\""));
651 assert!(s.contains("recovery_hint"));
652 }
653
654 #[test]
655 fn is_ok_only_matches_ok() {
656 assert!(
657 ToolCheckOutcome::Ok { detail: None }.is_ok(),
658 "Ok must report is_ok"
659 );
660 assert!(
661 !ToolCheckOutcome::Missing {
662 install_hint: String::new(),
663 }
664 .is_ok(),
665 "Missing must not report is_ok"
666 );
667 }
668
669 #[test]
670 fn parse_first_semver_token_handles_common_shapes() {
671 assert_eq!(
673 parse_first_semver_token("OpenTofu v1.6.2\non darwin_amd64"),
674 Some(Version::new(1, 6, 2))
675 );
676 assert_eq!(
678 parse_first_semver_token("Terraform v1.7.0"),
679 Some(Version::new(1, 7, 0))
680 );
681 assert_eq!(
683 parse_first_semver_token("v3.13.1+gabcdef1"),
684 Some(Version::new(3, 13, 1))
685 );
686 let aws_out = "aws-cli/2.15.0 Python/3.11.6 Linux/5.15.0 source/x86_64";
691 assert_eq!(
692 parse_first_semver_token(aws_out),
693 Some(Version::new(2, 15, 0))
694 );
695 assert_eq!(
697 parse_first_semver_token("1.30.0"),
698 Some(Version::new(1, 30, 0))
699 );
700 assert_eq!(
702 parse_first_semver_token("v1.6.0-rc1"),
703 Some(Version::new(1, 6, 0))
704 );
705 }
706
707 #[test]
708 fn parse_first_semver_token_rejects_short_numbers() {
709 assert_eq!(parse_first_semver_token("foo 2 bar"), None);
711 assert_eq!(parse_first_semver_token("foo 1.0 bar"), None);
713 }
714
715 #[test]
716 fn parse_kubectl_version_strips_quotes_in_yaml_form() {
717 let yaml = "clientVersion:\n gitVersion: \"v1.30.0\"\n ...\n";
718 assert_eq!(parse_kubectl_version(yaml), Some(Version::new(1, 30, 0)));
719 }
720
721 #[test]
722 fn missing_binary_reports_missing() {
723 let check = check_binary_present(
727 "noexist",
728 "definitely-not-a-real-binary-c3-test",
729 &["--version"],
730 "Install foobar from https://example.test/install.",
731 );
732 match &check.outcome {
733 ToolCheckOutcome::Missing { install_hint } => {
734 assert!(install_hint.contains("https://example.test/install"));
735 }
736 other => panic!("expected Missing, got {other:?}"),
737 }
738 assert_eq!(check.name, "noexist");
739 }
740
741 #[test]
742 fn version_probe_missing_binary_reports_missing() {
743 let req: VersionReq = ">=1.0.0".parse().unwrap();
744 let check = check_version_probe(
745 "noexist",
746 "definitely-not-a-real-binary-c3-test",
747 &["--version"],
748 parse_first_semver_token,
749 &req,
750 "install hint here",
751 );
752 assert!(matches!(check.outcome, ToolCheckOutcome::Missing { .. }));
753 }
754
755 #[test]
756 fn install_hints_carry_actionable_text() {
757 for check in [
763 tofu(),
764 terraform(),
765 kubectl(),
766 helm(),
767 docker(),
768 podman(),
769 aws(),
770 gcloud(),
771 az(),
772 ] {
773 if let ToolCheckOutcome::Missing { install_hint } = &check.outcome {
774 assert!(
775 !install_hint.trim().is_empty(),
776 "named check `{}` returned an empty install_hint",
777 check.name
778 );
779 }
780 }
781 }
782
783 #[test]
784 fn catalog_minimum_versions_are_valid_semver() {
785 for min in [
787 MIN_TOFU_VERSION,
788 MIN_TERRAFORM_VERSION,
789 MIN_KUBECTL_VERSION,
790 MIN_HELM_VERSION,
791 MIN_DOCKER_VERSION,
792 MIN_PODMAN_VERSION,
793 MIN_AWS_VERSION,
794 MIN_GCLOUD_VERSION,
795 MIN_AZ_VERSION,
796 ] {
797 let _: Version = min.parse().unwrap_or_else(|e| {
798 panic!("MIN_*_VERSION `{min}` is not valid semver: {e}");
799 });
800 let _ = version_req_at_least(min);
801 }
802 }
803}