use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use super::generate::{self, GenerateArgs};
#[derive(clap::Args)]
pub struct CheckArgs {
#[arg(long)]
pub path: Option<PathBuf>,
#[arg(long)]
pub out: Option<PathBuf>,
#[arg(long = "env", value_name = "ENV")]
pub envs: Vec<String>,
}
pub fn run(args: CheckArgs) -> Result<()> {
let service_dir = generate::resolve_service_dir(args.path.as_deref())?;
let chart_dir = args
.out
.clone()
.unwrap_or_else(|| service_dir.join("chart"));
let drift = collect_drift(&service_dir, &chart_dir, &args.envs)?;
if drift.is_empty() {
println!("✓ chart is up to date");
return Ok(());
}
eprintln!("✗ chart/ is out of date — `generate` would change:");
for line in &drift {
eprintln!(" {line}");
}
eprintln!("\nRun: make helm-generate");
std::process::exit(1);
}
fn collect_drift(service_dir: &Path, chart_dir: &Path, envs: &[String]) -> Result<Vec<String>> {
let tmp = tempfile::TempDir::new().context("creating temp dir for chart check")?;
generate::run(GenerateArgs {
path: Some(service_dir.to_path_buf()),
out: Some(tmp.path().to_path_buf()),
envs: envs.to_vec(),
})
.context("re-rendering chart for drift check")?;
let mut drift = Vec::new();
for rel in collect_files(tmp.path())? {
let generated = tmp.path().join(&rel);
let committed = chart_dir.join(&rel);
let rel_disp = rel.display();
match std::fs::read(&committed) {
Err(_) => drift.push(format!("added {rel_disp}")),
Ok(committed_bytes) => {
let generated_bytes = std::fs::read(&generated)
.with_context(|| format!("reading {}", generated.display()))?;
if generated_bytes != committed_bytes {
drift.push(format!("changed {rel_disp}"));
}
}
}
}
drift.sort();
Ok(drift)
}
fn collect_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
collect_into(dir, dir, &mut files)?;
Ok(files)
}
fn collect_into(root: &Path, dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
let path = entry?.path();
if path.is_dir() {
collect_into(root, &path, files)?;
} else if let Ok(rel) = path.strip_prefix(root) {
files.push(rel.to_path_buf());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const SVC_TOML: &str = "\
schema = \"v1\"
[service]
name = \"svc\"
version = \"0.1.0\"
type = \"http\"
port = 7001
[deploy]
namespace = \"demo\"
replicas = 1
[resources]
cpu = \"100m\"
memory = \"128Mi\"
";
fn generate_chart() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("tonin.toml"), SVC_TOML).unwrap();
let chart = dir.path().join("chart");
generate::run(GenerateArgs {
path: Some(dir.path().to_path_buf()),
out: Some(chart.clone()),
envs: vec!["dev".into()],
})
.unwrap();
(dir, chart)
}
#[test]
fn fresh_generate_has_no_drift() {
let (dir, chart) = generate_chart();
let drift = collect_drift(dir.path(), &chart, &["dev".to_string()]).unwrap();
assert!(drift.is_empty(), "expected no drift, got {drift:?}");
}
#[test]
fn mutated_file_is_detected() {
let (dir, chart) = generate_chart();
std::fs::write(chart.join("Chart.yaml"), "garbage: true\n").unwrap();
let drift = collect_drift(dir.path(), &chart, &["dev".to_string()]).unwrap();
assert!(
drift.iter().any(|d| d.contains("Chart.yaml")),
"expected Chart.yaml drift, got {drift:?}"
);
}
#[test]
fn user_authored_template_is_not_drift() {
let (dir, chart) = generate_chart();
std::fs::write(
chart.join("templates").join("custom-cronjob.yaml"),
"kind: CronJob\n",
)
.unwrap();
let drift = collect_drift(dir.path(), &chart, &["dev".to_string()]).unwrap();
assert!(drift.is_empty(), "user file must not be drift: {drift:?}");
}
}