use clap::{Args, Subcommand};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use crate::{resources::extract_nyl_release, NylError, Result};
#[derive(Args, Debug)]
pub struct GenerateArgs {
#[command(subcommand)]
pub command: GenerateSubcommand,
}
#[derive(Subcommand, Debug)]
pub enum GenerateSubcommand {
Argocd {
#[arg(default_value = ".")]
path: String,
#[arg(long)]
repo_url: String,
#[arg(long, default_value = "main")]
revision: String,
#[arg(long, default_value = "argocd")]
argocd_namespace: String,
#[arg(long, default_value = "default")]
project: String,
#[arg(short, long)]
output: Option<PathBuf>,
},
Schema {
#[command(subcommand)]
command: SchemaSubcommand,
},
}
#[derive(Subcommand, Debug)]
pub enum SchemaSubcommand {
Config,
}
pub fn execute(args: GenerateArgs) -> Result<()> {
match args.command {
GenerateSubcommand::Argocd {
path,
repo_url,
revision,
argocd_namespace,
project,
output,
} => generate_argocd_applications(
&path,
&repo_url,
&revision,
&argocd_namespace,
&project,
output.as_deref(),
),
GenerateSubcommand::Schema {
command: SchemaSubcommand::Config,
} => {
let schema = crate::config::schema::generate_project_config_schema();
let output = serde_json::to_string_pretty(&schema)
.map_err(|e| NylError::Config(format!("Failed to serialize schema JSON: {}", e)))?;
println!("{}", output);
Ok(())
}
}
}
fn generate_argocd_applications(
manifests_dir: &str,
repo_url: &str,
revision: &str,
argocd_namespace: &str,
project: &str,
output_dir: Option<&Path>,
) -> Result<()> {
let yaml_files = find_yaml_files(manifests_dir)?;
if yaml_files.is_empty() {
tracing::warn!("No YAML files found in {}", manifests_dir);
return Ok(());
}
let mut applications = Vec::new();
let mut skipped = 0;
for file_path in yaml_files {
tracing::debug!("Reading YAML file: {}", file_path.display());
let content = std::fs::read_to_string(&file_path)
.map_err(|e| NylError::Config(format!("Failed to read {}: {}", file_path.display(), e)))?;
let source_ctx = crate::util::SourceContext::new(file_path.clone());
let manifests: Vec<serde_json::Value> = match source_ctx.parse_yaml_documents(&content) {
Ok(docs) => docs,
Err(e) => {
tracing::warn!(
"YAML parse error in {}: {}. Attempting best-effort document extraction.",
file_path.display(),
e
);
super::render::best_effort_parse_yaml_documents(&content)
}
};
let (nyl_release, _) = extract_nyl_release(&manifests)?;
if let Some(release) = nyl_release {
let app = create_argocd_application(
&release,
&file_path,
manifests_dir,
repo_url,
revision,
argocd_namespace,
project,
)?;
applications.push(app);
} else {
skipped += 1;
tracing::warn!("Skipping {} - no NylRelease resource found", file_path.display());
}
}
if applications.is_empty() {
tracing::warn!("No ArgoCD Applications generated");
tracing::info!("Files must contain a NylRelease resource to generate Applications");
return Err(NylError::Config("No valid NylRelease resources found".to_string()));
}
if let Some(out_dir) = output_dir {
std::fs::create_dir_all(out_dir)?;
for app in &applications {
let app_name = app["metadata"]["name"].as_str().unwrap();
let output_file = out_dir.join(format!("{}.yaml", app_name));
let yaml = serde_norway::to_string(app)?;
std::fs::write(&output_file, yaml)?;
println!("✓ Generated {}", output_file.display());
}
println!(
"\nGenerated {} ArgoCD Application(s) ({} file(s) skipped)",
applications.len(),
skipped
);
} else {
for (i, app) in applications.iter().enumerate() {
if i > 0 {
println!("---");
}
let yaml = serde_norway::to_string(app)?;
print!("{}", yaml);
}
}
Ok(())
}
fn create_argocd_application(
release: &crate::resources::NylRelease,
file_path: &Path,
base_dir: &str,
repo_url: &str,
revision: &str,
argocd_namespace: &str,
project: &str,
) -> Result<serde_json::Value> {
let base_path = std::fs::canonicalize(base_dir)?;
let file_abs = std::fs::canonicalize(file_path)?;
let rel_dir = file_abs
.strip_prefix(&base_path)
.unwrap_or(file_path)
.parent()
.unwrap_or(Path::new(""));
let rel_dir_normalized = normalize_relative_path_to_posix(rel_dir);
let source_path = if rel_dir_normalized.is_empty() {
".".to_string()
} else {
rel_dir_normalized
};
let template_input = file_abs
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| NylError::Config(format!("Invalid file name: {}", file_path.display())))?;
Ok(serde_json::json!({
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Application",
"metadata": {
"name": release.metadata.name,
"namespace": argocd_namespace,
},
"spec": {
"project": project,
"source": {
"repoURL": repo_url,
"path": source_path,
"targetRevision": revision,
"plugin": {
"name": "nyl-v2",
"env": [
{
"name": "NYL_RELEASE_NAME",
"value": release.metadata.name,
},
{
"name": "NYL_RELEASE_NAMESPACE",
"value": release.metadata.namespace,
},
{
"name": "NYL_CMP_TEMPLATE_INPUT",
"value": template_input,
},
],
},
},
"destination": {
"server": "https://kubernetes.default.svc",
"namespace": release.metadata.namespace,
},
"syncPolicy": {
"automated": {
"prune": true,
"selfHeal": true,
},
},
},
}))
}
fn normalize_relative_path_to_posix(path: &Path) -> String {
let mut normalized = String::new();
for component in path.components() {
if let std::path::Component::Normal(os_str) = component {
if !normalized.is_empty() {
normalized.push('/');
}
normalized.push_str(&os_str.to_string_lossy());
}
}
normalized
}
fn find_yaml_files(dir: &str) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in WalkDir::new(dir).follow_links(true) {
let entry = entry.map_err(|e| NylError::Config(format!("Failed to read directory: {}", e)))?;
let path = entry.path();
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.starts_with('.') {
continue;
}
}
if path.is_file() {
if let Some(ext) = path.extension() {
if ext == "yaml" || ext == "yml" {
files.push(path.to_path_buf());
}
}
}
}
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_yaml_documents() {
let yaml = r"
apiVersion: nyl.niklasrosenstein.github.com/v1
kind: NylRelease
metadata:
name: test
namespace: default
---
apiVersion: v1
kind: ConfigMap
metadata:
name: config
";
let source_ctx = crate::util::SourceContext::new(std::path::PathBuf::from("test.yaml"));
let docs = source_ctx.parse_yaml_documents(yaml).unwrap();
assert_eq!(docs.len(), 2);
assert_eq!(docs[0]["kind"], "NylRelease");
assert_eq!(docs[1]["kind"], "ConfigMap");
}
#[test]
fn test_create_argocd_application() {
use crate::resources::{NylRelease, NylReleaseMetadata, NylReleaseSpec};
use std::env;
let release = NylRelease {
api_version: "nyl.niklasrosenstein.github.com/v1".to_string(),
kind: "NylRelease".to_string(),
metadata: NylReleaseMetadata {
name: "myapp".to_string(),
namespace: "production".to_string(),
},
spec: NylReleaseSpec::default(),
};
let temp_dir = env::temp_dir();
let test_file = temp_dir.join("test-manifest.yaml");
std::fs::write(&test_file, "test").unwrap();
let app = create_argocd_application(
&release,
&test_file,
temp_dir.to_str().unwrap(),
"https://github.com/example/repo",
"main",
"argocd",
"default",
)
.unwrap();
std::fs::remove_file(&test_file).ok();
assert_eq!(app["apiVersion"], "argoproj.io/v1alpha1");
assert_eq!(app["kind"], "Application");
assert_eq!(app["metadata"]["name"], "myapp");
assert_eq!(app["metadata"]["namespace"], "argocd");
assert_eq!(app["spec"]["source"]["repoURL"], "https://github.com/example/repo");
assert_eq!(app["spec"]["destination"]["namespace"], "production");
assert_eq!(app["spec"]["source"]["path"], ".");
assert_eq!(app["spec"]["source"]["plugin"]["name"], "nyl-v2");
let env = app["spec"]["source"]["plugin"]["env"].as_array().unwrap();
let template_input = env
.iter()
.find(|v| v["name"] == "NYL_CMP_TEMPLATE_INPUT")
.and_then(|v| v["value"].as_str())
.unwrap();
assert_eq!(template_input, "test-manifest.yaml");
}
}