use super::common::{fail, hl, ok, program, repo_root, staged_files, warn, which};
use crate::check::Outcome;
use std::path::Path;
use std::process::{Command, Stdio};
pub const EXTS: &[&str] = &[".yaml", ".yml"];
fn k8s_staged() -> Vec<String> {
const PREFIXES: [&str; 7] = [
"kubernetes/",
"manifests/",
"chart/",
"charts/",
"k8s/",
"helm/",
"deploy/",
];
staged_files(EXTS)
.into_iter()
.filter(|f| PREFIXES.iter().any(|p| f.starts_with(p)))
.collect()
}
const ARGO_KINDS: [&str; 4] = [
"Workflow",
"CronWorkflow",
"WorkflowTemplate",
"ClusterWorkflowTemplate",
];
pub fn declares_argo_kind(content: &str) -> bool {
content.lines().any(|line| {
line.strip_prefix("kind: ")
.map(|k| ARGO_KINDS.contains(&k.trim_end()))
.unwrap_or(false)
})
}
pub fn argo_lint(settings: &crate::config::Settings, _args: &[std::ffi::OsString]) -> Outcome {
let staged = k8s_staged();
if staged.is_empty() {
return Outcome::Passed;
}
let root = repo_root();
let workflows: Vec<String> = staged
.into_iter()
.filter(|file| {
std::fs::read_to_string(Path::new(&root).join(file))
.map(|c| declares_argo_kind(&c))
.unwrap_or(false)
})
.collect();
if workflows.is_empty() {
return Outcome::Passed;
}
if which("argo").is_none() {
warn(&format!(
"Argo workflow manifests staged. Skipping argo lint; install: {}",
hl("argo")
));
return Outcome::Unavailable;
}
let mut argv = vec![
"lint".to_string(),
"--offline".to_string(),
"--".to_string(),
];
argv.extend(workflows.iter().cloned());
let mut cmd = Command::new(program("argo"));
cmd.args(&argv).current_dir(&root).stdin(Stdio::null());
let okd = super::common::bounded_success(settings, &mut cmd, "argo");
if !okd {
fail("argo lint failed (output above)");
return Outcome::Failed;
}
let n = workflows.len();
ok(
settings,
&format!(
"argo lint passed ({n} workflow manifest{})",
if n > 1 { "s" } else { "" }
),
);
Outcome::Passed
}
pub fn kube_linter_configs(root: &str) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut out: Vec<String> = rd
.flatten()
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| n.starts_with(".kube-linter") && (n.ends_with(".yaml") || n.ends_with(".yml")))
.collect();
out.sort();
out
}
pub fn kube_linter(settings: &crate::config::Settings, _args: &[std::ffi::OsString]) -> Outcome {
if k8s_staged().is_empty() {
return Outcome::Passed;
}
let root = repo_root();
let configs = kube_linter_configs(&root);
if configs.is_empty() {
return Outcome::Passed;
}
if which("kube-linter").is_none() {
warn(&format!(
"This repo configures kube-linter but it is not installed. Install {}",
hl("kube-linter")
));
return Outcome::Unavailable;
}
let mut overall = 0;
for cfg in &configs {
let mut cmd = Command::new(program("kube-linter"));
cmd.args(["lint", ".", "--config", cfg])
.current_dir(&root)
.stdin(Stdio::null());
let okd = super::common::bounded_success(settings, &mut cmd, "kube-linter");
if !okd {
fail(&format!("kube-linter ({cfg}) found issues"));
overall = 1;
}
}
if overall != 0 {
return Outcome::Failed;
}
let n = configs.len();
ok(
settings,
&format!(
"kube-linter passed ({n} config{})",
if n > 1 { "s" } else { "" }
),
);
Outcome::Passed
}
pub fn marker_roots(root: &str, staged: &[String], markers: &[&str]) -> Vec<String> {
let mut roots: Vec<String> = Vec::new();
for f in staged {
let mut dir = Path::new(f).parent();
while let Some(d) = dir {
let s = d.to_string_lossy().to_string();
if s.is_empty() || s == "." {
break;
}
let base = Path::new(root).join(&s);
if markers.iter().any(|m| base.join(m).is_file()) {
if !roots.contains(&s) {
roots.push(s);
}
break;
}
dir = d.parent();
}
}
roots
}
pub fn kustomization_roots(root: &str, staged: &[String]) -> Vec<String> {
marker_roots(root, staged, &["kustomization.yaml", "kustomization.yml"])
}
pub fn skip_kinds(content: &str) -> Vec<String> {
content
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| l.replace(' ', ""))
.collect()
}
pub fn kubeconform(settings: &crate::config::Settings, _args: &[std::ffi::OsString]) -> Outcome {
let staged = k8s_staged();
if staged.is_empty() {
return Outcome::Passed;
}
let root = repo_root();
let roots = kustomization_roots(&root, &staged);
if roots.is_empty() {
return Outcome::Passed;
}
let missing: Vec<&str> = ["kustomize", "kubeconform"]
.into_iter()
.filter(|t| which(t).is_none())
.collect();
if !missing.is_empty() {
warn(&format!(
"Kustomizations staged. Skipping kubeconform; install: {}",
hl(&missing.join(", "))
));
return Outcome::Unavailable;
}
let skip = std::fs::read_to_string(Path::new(&root).join(".kubeconform-skip"))
.ok()
.map(|c| skip_kinds(&c))
.filter(|k| !k.is_empty())
.map(|k| k.join(","));
let mut overall = 0;
for r in &roots {
if !validate_root(settings, &root, r, skip.as_deref()) {
fail(&format!("kubeconform failed for {r}"));
overall = 1;
}
}
if overall != 0 {
return Outcome::Failed;
}
let n = roots.len();
ok(
settings,
&format!(
"kubeconform passed ({n} kustomization root{})",
if n > 1 { "s" } else { "" }
),
);
Outcome::Passed
}
fn validate_root(
settings: &crate::config::Settings,
root: &str,
sub: &str,
skip: Option<&str>,
) -> bool {
let Ok(mut build) = Command::new(program("kustomize"))
.args([
"build",
"--load-restrictor",
"LoadRestrictionsNone",
"--",
sub,
])
.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.spawn()
else {
return false;
};
let Some(out) = build.stdout.take() else {
return false;
};
let mut argv = vec![
"--strict",
"--ignore-missing-schemas",
"--schema-location",
"default",
];
if let Some(s) = skip {
argv.push("--skip");
argv.push(s);
}
argv.push("--summary");
argv.push("-");
let mut cmd = Command::new(program("kubeconform"));
cmd.args(&argv).current_dir(root).stdin(Stdio::from(out));
let conform = super::common::bounded_success(settings, &mut cmd, "kubeconform");
let built = matches!(
super::common::wait_within(&mut build, super::common::check_timeout(settings), 0, None),
Ok(super::common::Ran::Status(s)) if s.success()
);
built && conform
}
#[cfg(test)]
mod tests {
use super::*;
fn test_settings() -> crate::config::Settings {
crate::config::Settings::default()
}
#[test]
fn recognises_every_argo_kind_and_nothing_else() {
for k in ARGO_KINDS {
assert!(
declares_argo_kind(&format!("apiVersion: x\nkind: {k}\n")),
"{k}"
);
}
assert!(!declares_argo_kind("kind: Deployment\n"));
assert!(!declares_argo_kind("kind: WorkflowSomethingElse\n"));
assert!(!declares_argo_kind(" kind: Workflow\n")); }
#[test]
fn parses_the_skip_list() {
let c = "# comment\nCiliumNetworkPolicy\n\n Foo Bar \n";
assert_eq!(skip_kinds(c), vec!["CiliumNetworkPolicy", "FooBar"]);
assert!(skip_kinds("# only a comment\n").is_empty());
}
#[test]
fn a_root_that_reaches_above_itself_still_validates() {
if which("kustomize").is_none() || which("kubeconform").is_none() {
return;
}
let tmp = std::env::temp_dir().join(format!("amont-loadrestrictor-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let shared = tmp.join("shared");
let overlay = tmp.join("overlay");
std::fs::create_dir_all(&shared).unwrap();
std::fs::create_dir_all(&overlay).unwrap();
std::fs::write(
shared.join("namespace.yaml"),
"apiVersion: v1\nkind: Namespace\nmetadata:\n name: demo\n",
)
.unwrap();
std::fs::write(
overlay.join("kustomization.yaml"),
"apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - ../shared/namespace.yaml\n",
)
.unwrap();
let root = tmp.to_string_lossy().to_string();
assert!(
validate_root(&test_settings(), &root, "overlay", None),
"a kustomization built from ../shared must validate — kustomize-controller renders it"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn walks_up_to_the_nearest_kustomization_root() {
let tmp = std::env::temp_dir().join("amont-kustomize-test");
let _ = std::fs::remove_dir_all(&tmp);
let base = tmp.join("kubernetes/app/base");
std::fs::create_dir_all(&base).unwrap();
std::fs::write(base.join("kustomization.yaml"), "resources: []").unwrap();
let root = tmp.to_string_lossy().to_string();
let found = kustomization_roots(&root, &["kubernetes/app/base/deploy.yaml".into()]);
assert_eq!(found, vec!["kubernetes/app/base".to_string()]);
let none = kustomization_roots(&root, &["kubernetes/loose/deploy.yaml".into()]);
assert!(none.is_empty());
let _ = std::fs::remove_dir_all(&tmp);
}
}