use std::io::ErrorKind;
use std::process::Command;
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ToolCheckOutcome {
Ok { detail: Option<String> },
Missing { install_hint: String },
VersionMismatch {
found: String,
required: String,
install_hint: String,
},
AuthFailed {
detail: String,
recovery_hint: String,
},
Unreachable {
detail: String,
recovery_hint: String,
},
ProbeError { detail: String },
}
impl ToolCheckOutcome {
pub fn is_ok(&self) -> bool {
matches!(self, ToolCheckOutcome::Ok { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolCheck {
pub name: String,
pub description: String,
pub outcome: ToolCheckOutcome,
}
impl ToolCheck {
pub fn ok(
name: impl Into<String>,
description: impl Into<String>,
detail: Option<String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
outcome: ToolCheckOutcome::Ok { detail },
}
}
pub fn missing(
name: impl Into<String>,
description: impl Into<String>,
install_hint: impl Into<String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
outcome: ToolCheckOutcome::Missing {
install_hint: install_hint.into(),
},
}
}
pub fn version_mismatch(
name: impl Into<String>,
description: impl Into<String>,
found: impl Into<String>,
required: impl Into<String>,
install_hint: impl Into<String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
outcome: ToolCheckOutcome::VersionMismatch {
found: found.into(),
required: required.into(),
install_hint: install_hint.into(),
},
}
}
pub fn auth_failed(
name: impl Into<String>,
description: impl Into<String>,
detail: impl Into<String>,
recovery_hint: impl Into<String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
outcome: ToolCheckOutcome::AuthFailed {
detail: detail.into(),
recovery_hint: recovery_hint.into(),
},
}
}
pub fn unreachable(
name: impl Into<String>,
description: impl Into<String>,
detail: impl Into<String>,
recovery_hint: impl Into<String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
outcome: ToolCheckOutcome::Unreachable {
detail: detail.into(),
recovery_hint: recovery_hint.into(),
},
}
}
pub fn probe_error(
name: impl Into<String>,
description: impl Into<String>,
detail: impl Into<String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
outcome: ToolCheckOutcome::ProbeError {
detail: detail.into(),
},
}
}
}
fn probe(binary: &str, args: &[&str]) -> Result<String, ProbeFailure> {
let output = Command::new(binary).args(args).output().map_err(|e| {
if e.kind() == ErrorKind::NotFound {
ProbeFailure::NotFound
} else {
ProbeFailure::Io(e.to_string())
}
})?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
Err(ProbeFailure::NonZero {
code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
}
#[derive(Debug)]
enum ProbeFailure {
NotFound,
NonZero { code: Option<i32>, stderr: String },
Io(String),
}
pub fn check_binary_present(
name: &str,
binary: &str,
args: &[&str],
install_hint: &str,
) -> ToolCheck {
let description = format!("`{binary}` is on $PATH");
match probe(binary, args) {
Ok(stdout) => {
let detail = stdout.lines().next().map(|s| s.trim().to_string());
ToolCheck::ok(name, description, detail)
}
Err(ProbeFailure::NotFound) => ToolCheck::missing(name, description, install_hint),
Err(ProbeFailure::NonZero { code, stderr, .. }) => ToolCheck::probe_error(
name,
description,
format!(
"exit {}: {}",
code.map(|c| c.to_string()).unwrap_or_else(|| "?".into()),
stderr.trim()
),
),
Err(ProbeFailure::Io(detail)) => ToolCheck::probe_error(name, description, detail),
}
}
pub fn check_version_probe(
name: &str,
binary: &str,
args: &[&str],
parser: fn(&str) -> Option<Version>,
required: &VersionReq,
install_hint: &str,
) -> ToolCheck {
let description = format!("`{binary}` version satisfies `{required}`");
let stdout = match probe(binary, args) {
Ok(s) => s,
Err(ProbeFailure::NotFound) => {
return ToolCheck::missing(name, description, install_hint);
}
Err(ProbeFailure::NonZero { code, stderr, .. }) => {
return ToolCheck::probe_error(
name,
description,
format!(
"exit {}: {}",
code.map(|c| c.to_string()).unwrap_or_else(|| "?".into()),
stderr.trim()
),
);
}
Err(ProbeFailure::Io(detail)) => {
return ToolCheck::probe_error(name, description, detail);
}
};
let Some(found) = parser(&stdout) else {
return ToolCheck::probe_error(
name,
description,
format!("could not parse version from: {}", stdout.trim()),
);
};
if required.matches(&found) {
ToolCheck::ok(name, description, Some(found.to_string()))
} else {
ToolCheck::version_mismatch(
name,
description,
found.to_string(),
required.to_string(),
install_hint,
)
}
}
pub fn parse_first_semver_token(stdout: &str) -> Option<Version> {
for token in stdout.split(|c: char| c.is_whitespace() || matches!(c, ',' | '(' | ')' | '/')) {
let cleaned = token.trim_start_matches('v');
let core = cleaned.split(['-', '+']).next().unwrap_or(cleaned);
if core.matches('.').count() < 2 {
continue;
}
if let Ok(v) = Version::parse(core) {
return Some(v);
}
}
None
}
pub fn parse_kubectl_version(stdout: &str) -> Option<Version> {
let cleaned = stdout.replace('"', " ");
parse_first_semver_token(&cleaned)
}
pub const MIN_TOFU_VERSION: &str = "1.6.0";
pub const MIN_TERRAFORM_VERSION: &str = "1.5.0";
pub const MIN_KUBECTL_VERSION: &str = "1.27.0";
pub const MIN_HELM_VERSION: &str = "3.12.0";
pub const MIN_DOCKER_VERSION: &str = "24.0.0";
pub const MIN_PODMAN_VERSION: &str = "4.5.0";
pub const MIN_AWS_VERSION: &str = "2.13.0";
pub const MIN_GCLOUD_VERSION: &str = "450.0.0";
pub const MIN_AZ_VERSION: &str = "2.50.0";
fn version_req_at_least(min: &str) -> VersionReq {
format!(">={min}")
.parse()
.expect("hardcoded version requirement parses")
}
pub fn tofu() -> ToolCheck {
check_version_probe(
"tofu",
"tofu",
&["version", "-json"],
parse_first_semver_token,
&version_req_at_least(MIN_TOFU_VERSION),
"Install OpenTofu from https://opentofu.org/docs/intro/install/ (preferred over Terraform).",
)
}
pub fn terraform() -> ToolCheck {
check_version_probe(
"terraform",
"terraform",
&["version", "-json"],
parse_first_semver_token,
&version_req_at_least(MIN_TERRAFORM_VERSION),
"Install Terraform >= 1.5.0 from https://developer.hashicorp.com/terraform/install — but prefer `tofu` (OpenTofu).",
)
}
pub fn kubectl() -> ToolCheck {
check_version_probe(
"kubectl",
"kubectl",
&["version", "--client", "--output=yaml"],
parse_kubectl_version,
&version_req_at_least(MIN_KUBECTL_VERSION),
"Install kubectl from https://kubernetes.io/docs/tasks/tools/#kubectl.",
)
}
pub fn helm() -> ToolCheck {
check_version_probe(
"helm",
"helm",
&["version", "--short"],
parse_first_semver_token,
&version_req_at_least(MIN_HELM_VERSION),
"Install Helm from https://helm.sh/docs/intro/install/.",
)
}
pub fn docker() -> ToolCheck {
check_version_probe(
"docker",
"docker",
&["version", "--format", "{{.Client.Version}}"],
parse_first_semver_token,
&version_req_at_least(MIN_DOCKER_VERSION),
"Install Docker from https://docs.docker.com/engine/install/ or use Podman.",
)
}
pub fn podman() -> ToolCheck {
check_version_probe(
"podman",
"podman",
&["version", "--format", "{{.Client.Version}}"],
parse_first_semver_token,
&version_req_at_least(MIN_PODMAN_VERSION),
"Install Podman from https://podman.io/docs/installation.",
)
}
pub fn aws() -> ToolCheck {
check_version_probe(
"aws",
"aws",
&["--version"],
parse_first_semver_token,
&version_req_at_least(MIN_AWS_VERSION),
"Install AWS CLI v2 from https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html.",
)
}
pub fn gcloud() -> ToolCheck {
check_version_probe(
"gcloud",
"gcloud",
&["version", "--format=value(\"Google Cloud SDK\")"],
parse_first_semver_token,
&version_req_at_least(MIN_GCLOUD_VERSION),
"Install gcloud from https://cloud.google.com/sdk/docs/install.",
)
}
pub fn az() -> ToolCheck {
check_version_probe(
"az",
"az",
&["version", "--output", "tsv", "--query", "\"azure-cli\""],
parse_first_semver_token,
&version_req_at_least(MIN_AZ_VERSION),
"Install Azure CLI from https://learn.microsoft.com/cli/azure/install-azure-cli.",
)
}
pub fn aws_caller_identity(region: Option<&str>) -> ToolCheck {
let name = "aws.caller-identity";
let description = "AWS credentials resolve to a caller identity".to_string();
let mut args: Vec<&str> = vec!["sts", "get-caller-identity", "--output", "text"];
if let Some(r) = region {
args.push("--region");
args.push(r);
}
match probe("aws", &args) {
Ok(stdout) => ToolCheck::ok(
name,
description,
stdout.lines().next().map(|s| s.trim().to_string()),
),
Err(ProbeFailure::NotFound) => ToolCheck::missing(
name,
description,
"AWS CLI v2 not installed; see `aws` check.",
),
Err(ProbeFailure::NonZero { stderr, .. }) => ToolCheck::auth_failed(
name,
description,
stderr.trim().to_string(),
"Run `aws configure sso` or set AWS_PROFILE / AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.",
),
Err(ProbeFailure::Io(detail)) => ToolCheck::probe_error(name, description, detail),
}
}
fn auth_probe_first_line(
name: &str,
description: &str,
binary: &str,
args: &[&str],
empty_detail: &str,
missing_hint: &str,
recovery_hint: &str,
) -> ToolCheck {
match probe(binary, args) {
Ok(stdout) => {
let first = stdout.lines().next().map(|s| s.trim()).unwrap_or("");
if first.is_empty() {
ToolCheck::auth_failed(name, description, empty_detail, recovery_hint)
} else {
ToolCheck::ok(name, description, Some(first.to_string()))
}
}
Err(ProbeFailure::NotFound) => ToolCheck::missing(name, description, missing_hint),
Err(ProbeFailure::NonZero { stderr, .. }) => {
ToolCheck::auth_failed(name, description, stderr.trim().to_string(), recovery_hint)
}
Err(ProbeFailure::Io(detail)) => ToolCheck::probe_error(name, description, detail),
}
}
pub fn gcloud_auth_list() -> ToolCheck {
auth_probe_first_line(
"gcloud.auth",
"gcloud has an ACTIVE authentication",
"gcloud",
&[
"auth",
"list",
"--filter=status:ACTIVE",
"--format=value(account)",
],
"no ACTIVE account found",
"gcloud not installed; see `gcloud` check.",
"Run `gcloud auth login` (or `gcloud auth activate-service-account`).",
)
}
pub fn az_account_show() -> ToolCheck {
auth_probe_first_line(
"az.account",
"Azure CLI session has an active subscription",
"az",
&["account", "show", "--output", "tsv", "--query", "id"],
"no active subscription",
"Azure CLI not installed; see `az` check.",
"Run `az login` and `az account set --subscription <id>`.",
)
}
pub fn kubectl_can_i(verb: &str, resource: &str, namespace: Option<&str>) -> ToolCheck {
let name = format!("kubectl.can-i:{verb}:{resource}");
let description = match namespace {
Some(ns) => format!("`kubectl auth can-i {verb} {resource} -n {ns}` is allowed"),
None => format!("`kubectl auth can-i {verb} {resource}` is allowed"),
};
let mut args: Vec<&str> = vec!["auth", "can-i", verb, resource];
if let Some(ns) = namespace {
args.push("-n");
args.push(ns);
}
match probe("kubectl", &args) {
Ok(stdout) => {
let answer = stdout.lines().next().map(|s| s.trim()).unwrap_or("");
if answer.eq_ignore_ascii_case("yes") {
ToolCheck::ok(name, description, Some(answer.to_string()))
} else {
ToolCheck::auth_failed(
name,
description,
answer.to_string(),
"Grant the required role/binding via the env-pack credentials bootstrap (Phase C/D).",
)
}
}
Err(ProbeFailure::NotFound) => ToolCheck::missing(
name,
description,
"kubectl not installed; see `kubectl` check.",
),
Err(ProbeFailure::NonZero { stderr, .. }) => {
let stderr_lc = stderr.to_lowercase();
if stderr_lc.contains("unable to connect")
|| stderr_lc.contains("couldn't get current server")
|| stderr_lc.contains("no such host")
{
ToolCheck::unreachable(
name,
description,
stderr.trim().to_string(),
"Verify $KUBECONFIG points at a reachable cluster.",
)
} else {
ToolCheck::auth_failed(
name,
description,
stderr.trim().to_string(),
"Inspect the kubectl error and grant the required role.",
)
}
}
Err(ProbeFailure::Io(detail)) => ToolCheck::probe_error(name, description, detail),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outcome_serializes_with_status_tag() {
let ok = ToolCheckOutcome::Ok {
detail: Some("1.6.0".into()),
};
let s = serde_json::to_string(&ok).unwrap();
assert!(s.contains("\"status\":\"ok\""));
assert!(s.contains("\"detail\":\"1.6.0\""));
let missing = ToolCheckOutcome::Missing {
install_hint: "brew install tofu".into(),
};
let s = serde_json::to_string(&missing).unwrap();
assert!(s.contains("\"status\":\"missing\""));
assert!(s.contains("install_hint"));
let auth = ToolCheckOutcome::AuthFailed {
detail: "no creds".into(),
recovery_hint: "aws configure".into(),
};
let s = serde_json::to_string(&auth).unwrap();
assert!(s.contains("\"status\":\"auth_failed\""));
assert!(s.contains("recovery_hint"));
}
#[test]
fn is_ok_only_matches_ok() {
assert!(
ToolCheckOutcome::Ok { detail: None }.is_ok(),
"Ok must report is_ok"
);
assert!(
!ToolCheckOutcome::Missing {
install_hint: String::new(),
}
.is_ok(),
"Missing must not report is_ok"
);
}
#[test]
fn parse_first_semver_token_handles_common_shapes() {
assert_eq!(
parse_first_semver_token("OpenTofu v1.6.2\non darwin_amd64"),
Some(Version::new(1, 6, 2))
);
assert_eq!(
parse_first_semver_token("Terraform v1.7.0"),
Some(Version::new(1, 7, 0))
);
assert_eq!(
parse_first_semver_token("v3.13.1+gabcdef1"),
Some(Version::new(3, 13, 1))
);
let aws_out = "aws-cli/2.15.0 Python/3.11.6 Linux/5.15.0 source/x86_64";
assert_eq!(
parse_first_semver_token(aws_out),
Some(Version::new(2, 15, 0))
);
assert_eq!(
parse_first_semver_token("1.30.0"),
Some(Version::new(1, 30, 0))
);
assert_eq!(
parse_first_semver_token("v1.6.0-rc1"),
Some(Version::new(1, 6, 0))
);
}
#[test]
fn parse_first_semver_token_rejects_short_numbers() {
assert_eq!(parse_first_semver_token("foo 2 bar"), None);
assert_eq!(parse_first_semver_token("foo 1.0 bar"), None);
}
#[test]
fn parse_kubectl_version_strips_quotes_in_yaml_form() {
let yaml = "clientVersion:\n gitVersion: \"v1.30.0\"\n ...\n";
assert_eq!(parse_kubectl_version(yaml), Some(Version::new(1, 30, 0)));
}
#[test]
fn missing_binary_reports_missing() {
let check = check_binary_present(
"noexist",
"definitely-not-a-real-binary-c3-test",
&["--version"],
"Install foobar from https://example.test/install.",
);
match &check.outcome {
ToolCheckOutcome::Missing { install_hint } => {
assert!(install_hint.contains("https://example.test/install"));
}
other => panic!("expected Missing, got {other:?}"),
}
assert_eq!(check.name, "noexist");
}
#[test]
fn version_probe_missing_binary_reports_missing() {
let req: VersionReq = ">=1.0.0".parse().unwrap();
let check = check_version_probe(
"noexist",
"definitely-not-a-real-binary-c3-test",
&["--version"],
parse_first_semver_token,
&req,
"install hint here",
);
assert!(matches!(check.outcome, ToolCheckOutcome::Missing { .. }));
}
#[test]
fn install_hints_carry_actionable_text() {
for check in [
tofu(),
terraform(),
kubectl(),
helm(),
docker(),
podman(),
aws(),
gcloud(),
az(),
] {
if let ToolCheckOutcome::Missing { install_hint } = &check.outcome {
assert!(
!install_hint.trim().is_empty(),
"named check `{}` returned an empty install_hint",
check.name
);
}
}
}
#[test]
fn catalog_minimum_versions_are_valid_semver() {
for min in [
MIN_TOFU_VERSION,
MIN_TERRAFORM_VERSION,
MIN_KUBECTL_VERSION,
MIN_HELM_VERSION,
MIN_DOCKER_VERSION,
MIN_PODMAN_VERSION,
MIN_AWS_VERSION,
MIN_GCLOUD_VERSION,
MIN_AZ_VERSION,
] {
let _: Version = min.parse().unwrap_or_else(|e| {
panic!("MIN_*_VERSION `{min}` is not valid semver: {e}");
});
let _ = version_req_at_least(min);
}
}
}