Skip to main content

greentic_deployer/
tool_check.rs

1//! Tool preflight (`C3`).
2//!
3//! Cross-cutting preflight checks for external tools an env-pack handler needs
4//! to do real work (e.g. `terraform`/`kubectl`/`helm`/`docker`/`aws`/`gcloud`/
5//! `az`). The Phase A surface ships:
6//!
7//! - [`ToolCheck`] / [`ToolCheckOutcome`] — the result shape every check
8//!   returns, with structured `Missing` / `VersionMismatch` / `AuthFailed` /
9//!   `Unreachable` / `ProbeError` variants and an honest `install_hint`.
10//! - Generic primitives: [`check_binary_present`] and [`check_version_probe`]
11//!   — handlers compose these into per-tool checks.
12//! - A **named-tool catalog** ([`terraform`], [`tofu`], [`kubectl`], [`helm`],
13//!   [`docker`], [`podman`], [`aws`], [`gcloud`], [`az`]) with minimum-version
14//!   defaults the catalog tracks and honest install-hint text.
15//! - Auth/scope probes ([`aws_caller_identity`], [`gcloud_auth_list`],
16//!   [`az_account_show`], [`kubectl_can_i`]) that go beyond binary presence
17//!   to verify credentials and required permissions.
18//!
19//! Phase A handlers (`local-process`, `dev-store`, `stdout`, `in-memory`) are
20//! all in-process and return an empty preflight via the default
21//! [`crate::env_packs::EnvPackHandler::preflight`]. Phase D handlers (K8s,
22//! cloud) compose this catalog by name.
23//!
24//! `gtc op env tool-check <env_id>` aggregates per-binding [`ToolCheck`]s into
25//! a structured JSON outcome.
26//!
27//! No timeout discipline is enforced here — built-in Phase A handlers don't
28//! shell out, so a hanging probe can't surface yet. Phase D plumbing should
29//! wrap the slow primitives in `wait_timeout`-style guards before live use.
30
31use std::io::ErrorKind;
32use std::process::Command;
33
34use semver::{Version, VersionReq};
35use serde::{Deserialize, Serialize};
36
37/// Outcome of a single [`ToolCheck`].
38///
39/// All failure variants carry an `install_hint` (or equivalent recovery hint)
40/// so the operator sees an actionable message, not just "missing kubectl".
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(tag = "status", rename_all = "snake_case")]
43pub enum ToolCheckOutcome {
44    /// The check passed. `detail` typically holds the observed version or a
45    /// short success line (e.g. caller identity ARN).
46    Ok { detail: Option<String> },
47    /// The binary is not on `$PATH`.
48    Missing { install_hint: String },
49    /// Binary present but its version is outside the required range.
50    VersionMismatch {
51        found: String,
52        required: String,
53        install_hint: String,
54    },
55    /// Binary present but authentication / credentials are not valid.
56    AuthFailed {
57        detail: String,
58        recovery_hint: String,
59    },
60    /// Network or cluster endpoint is unreachable.
61    Unreachable {
62        detail: String,
63        recovery_hint: String,
64    },
65    /// Probe ran but produced output we couldn't make sense of, or exited
66    /// non-zero for a reason we don't model. Carries the raw detail so the
67    /// operator can investigate.
68    ProbeError { detail: String },
69}
70
71impl ToolCheckOutcome {
72    /// `true` if the outcome is [`ToolCheckOutcome::Ok`].
73    pub fn is_ok(&self) -> bool {
74        matches!(self, ToolCheckOutcome::Ok { .. })
75    }
76}
77
78/// A single preflight check.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct ToolCheck {
81    /// Short identifier shown to operators (e.g. `terraform`,
82    /// `aws.caller-identity`).
83    pub name: String,
84    /// Human-readable description of what this check verifies.
85    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
181/// Run `binary` with the given `args` and capture stdout. Returns:
182/// - `Ok(stdout_string)` on exit-0.
183/// - `Err(ProbeFailure::NotFound)` when the binary is not on `$PATH`.
184/// - `Err(ProbeFailure::NonZero { stderr, code })` on a non-zero exit.
185/// - `Err(ProbeFailure::Io(detail))` for any other I/O failure.
186fn 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
211/// Verify a binary is on `$PATH` by invoking it with the given `args`
212/// (typically `--version`).
213pub 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
239/// Verify a binary's `--version`-style output parses to a [`Version`] that
240/// satisfies `required`. The `parser` extracts a [`Version`] from the raw
241/// stdout — different tools embed their version in different shapes, so the
242/// caller owns the regex / `split_whitespace` choice.
243///
244/// On non-zero exit or parse failure the check returns `ProbeError` with the
245/// raw output so the operator can debug.
246pub 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
295/// Extract a `MAJOR.MINOR.PATCH` from the first token that parses as one.
296/// Strips a leading `v` and drops any suffix after `-`/`+` so `v1.5.7-rc1`
297/// parses as `1.5.7`. `/` is a separator so `aws-cli/2.15.0 Python/3.11.6`
298/// yields `2.15.0`.
299pub 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        // Pre-release/build suffixes break VersionReq matching.
303        let core = cleaned.split(['-', '+']).next().unwrap_or(cleaned);
304        // Require two dots so a stray "2" or "1.0" isn't taken for a version.
305        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
315/// Parses the first `MAJOR.MINOR.PATCH` from a `kubectl version --client
316/// --output=yaml`-style or plain `--version` string. Falls back to
317/// [`parse_first_semver_token`].
318pub fn parse_kubectl_version(stdout: &str) -> Option<Version> {
319    // `kubectl version --client` prints `Client Version: v1.30.0` with optional
320    // build metadata; the generic parser handles that. Some older builds
321    // print `GitVersion:"v1.27.0"` — strip the quote in that case.
322    let cleaned = stdout.replace('"', " ");
323    parse_first_semver_token(&cleaned)
324}
325
326/// Minimum supported OpenTofu version. Bumped as the deployer adopts newer
327/// HCL features.
328pub const MIN_TOFU_VERSION: &str = "1.6.0";
329/// Minimum supported Terraform version. We prefer OpenTofu (see plan §7 C3);
330/// Terraform is still accepted for environments stuck on it.
331pub const MIN_TERRAFORM_VERSION: &str = "1.5.0";
332/// Minimum supported kubectl version.
333pub const MIN_KUBECTL_VERSION: &str = "1.27.0";
334/// Minimum supported Helm version.
335pub const MIN_HELM_VERSION: &str = "3.12.0";
336/// Minimum supported Docker version.
337pub const MIN_DOCKER_VERSION: &str = "24.0.0";
338/// Minimum supported Podman version (Docker-equivalent OCI runtime).
339pub const MIN_PODMAN_VERSION: &str = "4.5.0";
340/// Minimum supported AWS CLI v2 version.
341pub const MIN_AWS_VERSION: &str = "2.13.0";
342/// Minimum supported gcloud version.
343pub const MIN_GCLOUD_VERSION: &str = "450.0.0";
344/// Minimum supported Azure CLI version.
345pub const MIN_AZ_VERSION: &str = "2.50.0";
346
347/// `>=min`, unbounded above: these are external CLIs we don't own, and a newer
348/// major (terraform 2.x, kubectl 1.31, ...) is the normal upgrade path, not a
349/// break — so we don't cap with a caret the way we do for our own descriptors.
350fn version_req_at_least(min: &str) -> VersionReq {
351    format!(">={min}")
352        .parse()
353        .expect("hardcoded version requirement parses")
354}
355
356/// Check `tofu` is installed and at or above [`MIN_TOFU_VERSION`].
357pub 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
368/// Check `terraform` is installed and at or above [`MIN_TERRAFORM_VERSION`].
369/// Prefer [`tofu`] for new environments — Terraform shells are accepted for
370/// legacy compatibility only.
371pub 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
382/// Check `kubectl` is installed and at or above [`MIN_KUBECTL_VERSION`].
383pub 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
394/// Check `helm` is installed and at or above [`MIN_HELM_VERSION`].
395pub 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
406/// Check `docker` is installed and at or above [`MIN_DOCKER_VERSION`].
407pub 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
418/// Check `podman` is installed and at or above [`MIN_PODMAN_VERSION`].
419pub 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
430/// Check `aws` is installed and at or above [`MIN_AWS_VERSION`].
431pub 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
442/// Check `gcloud` is installed and at or above [`MIN_GCLOUD_VERSION`].
443pub 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
454/// Check `az` is installed and at or above [`MIN_AZ_VERSION`].
455pub 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
466/// `aws sts get-caller-identity` — verifies AWS credentials are configured
467/// and usable. `region` is optional; when present it is passed as
468/// `--region`.
469pub 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
498/// Shared shape for "the first non-empty stdout line is the authenticated
499/// identity". An empty stdout on exit-0 (nothing active) and a non-zero exit
500/// both mean "no usable session" — distinguished only by the detail string.
501/// `gcloud auth list` and `az account show` both fit; `aws_caller_identity`
502/// does not (it has no empty-stdout case) and `kubectl_can_i` parses a
503/// yes/no answer, so they stay bespoke.
504fn 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
530/// `gcloud auth list --filter=status:ACTIVE` — verifies a gcloud
531/// authentication is active.
532pub 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
549/// `az account show` — verifies an Azure subscription is selected and the
550/// session is not expired.
551pub 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
563/// `kubectl auth can-i <verb> <resource> [-n <namespace>]` — verifies the
564/// configured kubeconfig has the requested permission on the cluster. This
565/// goes beyond binary-present: it surfaces both kubeconfig reachability and
566/// RBAC sufficiency in one probe.
567pub 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            // kubectl uses "couldn't get current server" / "Unable to connect"
600            // for unreachable endpoints; everything else we surface as
601            // AuthFailed so the operator sees the kubectl-side message.
602            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        // `tofu --version`: `OpenTofu v1.6.2`
672        assert_eq!(
673            parse_first_semver_token("OpenTofu v1.6.2\non darwin_amd64"),
674            Some(Version::new(1, 6, 2))
675        );
676        // `terraform version`: `Terraform v1.7.0`
677        assert_eq!(
678            parse_first_semver_token("Terraform v1.7.0"),
679            Some(Version::new(1, 7, 0))
680        );
681        // `helm version --short`: `v3.13.1+gabcdef1`
682        assert_eq!(
683            parse_first_semver_token("v3.13.1+gabcdef1"),
684            Some(Version::new(3, 13, 1))
685        );
686        // `aws --version`: `aws-cli/2.15.0 Python/3.11.6 Linux/...` — the `/`
687        // separator lets us reach the `2.15.0` token (the `aws-cli` prefix and
688        // the `Python/3.11.6` runtime version are skipped: prefix has no dots,
689        // and `2.15.0` is the first 3-part token).
690        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        // Plain semver: `1.30.0`
696        assert_eq!(
697            parse_first_semver_token("1.30.0"),
698            Some(Version::new(1, 30, 0))
699        );
700        // Pre-release suffix is dropped for matching: `1.6.0-rc1` → 1.6.0
701        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        // A stray `2` should not be picked up as `2.0.0`.
710        assert_eq!(parse_first_semver_token("foo 2 bar"), None);
711        // A stray `1.0` should not be picked up either — we require 3-part.
712        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        // We exploit `Command::new` returning `ErrorKind::NotFound` for a
724        // bogus binary so the check returns the structured Missing outcome
725        // with the install-hint preserved verbatim.
726        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        // Spot-check that every named-tool catalog entry has a non-empty
758        // install_hint when its binary isn't present (which it won't be in
759        // CI by default for at least a few of these). We don't assert which
760        // ones are missing — just that every catalog entry produces a
761        // ToolCheck with a non-empty install_hint when Missing.
762        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        // Guard against typo-introduced unparseable minimums.
786        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}