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(_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 okd = Command::new(program("argo"))
.args(&argv)
.current_dir(&root)
.stdin(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !okd {
fail("argo lint failed (output above)");
return Outcome::Failed;
}
let n = workflows.len();
ok(&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(_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 okd = Command::new(program("kube-linter"))
.args(["lint", ".", "--config", cfg])
.current_dir(&root)
.stdin(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !okd {
fail(&format!("kube-linter ({cfg}) found issues"));
overall = 1;
}
}
if overall != 0 {
return Outcome::Failed;
}
let n = configs.len();
ok(&format!(
"kube-linter passed ({n} config{})",
if n > 1 { "s" } else { "" }
));
Outcome::Passed
}
pub fn kustomization_roots(root: &str, staged: &[String]) -> 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 base.join("kustomization.yaml").is_file() || base.join("kustomization.yml").is_file()
{
if !roots.contains(&s) {
roots.push(s);
}
break;
}
dir = d.parent();
}
}
roots
}
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(_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(&root, r, skip.as_deref()) {
fail(&format!("kubeconform failed for {r}"));
overall = 1;
}
}
if overall != 0 {
return Outcome::Failed;
}
let n = roots.len();
ok(&format!(
"kubeconform passed ({n} kustomization root{})",
if n > 1 { "s" } else { "" }
));
Outcome::Passed
}
fn validate_root(root: &str, sub: &str, skip: Option<&str>) -> bool {
let Ok(mut build) = Command::new(program("kustomize"))
.args(["build", "--", 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 conform = Command::new(program("kubeconform"))
.args(&argv)
.current_dir(root)
.stdin(Stdio::from(out))
.status()
.map(|s| s.success())
.unwrap_or(false);
let built = build.wait().map(|s| s.success()).unwrap_or(false);
built && conform
}
#[cfg(test)]
mod tests {
use super::*;
#[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 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);
}
}