use clap::Args;
use glob::{glob, Pattern};
use kube::Client;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use crate::{
cli::namespace_resolution::{adjust_duplicate_keys_for_namespace_resolution, resolve_manifest_namespaces},
config::{ProjectConfig, StripEmptyMetadataLabelsMode},
constants::{API_VERSION, API_VERSION_ARGOCD, API_VERSION_COMPONENTS},
git::is_argocd_env,
helm::{HelmChartResolver, HelmTemplateExecutor},
kubernetes::{KubeClient, KubeRsClient, ResourceKey},
postprocess::apply_kyverno_policies,
profiles::{deep_merge_value, Profile},
resources::{
component_kind_to_chart_ref, extract_all_kyverno_policies, extract_application_generators, extract_nyl_release,
is_nyl_component, is_remote_helm_chart_shortcut, is_supported_application_array_field_path,
is_supported_application_field_path, join_field_path_segments, parse_component_kind, path_matches_glob,
ChartRef, HelmChart, KyvernoScope, NylComponent, NylRelease, RemoteManifest,
},
secrets::SecretsConfig,
template::{TemplateContext, TemplateEngine},
NylError, Result,
};
#[derive(Args, Debug, Clone)]
pub struct RenderOptions {
#[arg(value_name = "FILE")]
pub path: String,
#[arg(long, value_name = "KIND")]
pub only_source_kind: Option<String>,
#[arg(long, value_delimiter = ',', conflicts_with = "exclude_kind")]
pub only_kind: Vec<String>,
#[arg(long, value_delimiter = ',', conflicts_with = "only_kind")]
pub exclude_kind: Vec<String>,
#[arg(short, long)]
pub profile: Option<String>,
#[arg(long, default_value = "10")]
pub max_depth: usize,
#[arg(long)]
pub track_parent: bool,
}
#[derive(Args, Debug)]
pub struct RenderArgs {
#[command(flatten)]
pub common: RenderOptions,
#[arg(long)]
pub offline: bool,
#[arg(long, required_if_eq("offline", "true"))]
pub kube_version: Option<String>,
#[arg(long, required_if_eq("offline", "true"), value_delimiter = ',')]
pub kube_api_versions: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
enum OutputFormat {
Yaml,
#[allow(dead_code)]
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClusterClientRequirement {
OnDemand,
Required,
}
pub struct RenderPreflightOptions<'a> {
pub common: &'a RenderOptions,
pub offline: bool,
pub kube_version: Option<&'a str>,
pub kube_api_versions: &'a [String],
pub context_override: Option<&'a str>,
pub cluster_client_requirement: ClusterClientRequirement,
pub resolve_namespaces: bool,
pub release_namespace_hint: Option<&'a str>,
pub adjust_duplicate_keys: bool,
}
pub struct RenderPreflightResult {
pub manifests: Vec<serde_json::Value>,
pub nyl_release: Option<NylRelease>,
pub strip_empty_metadata_labels: bool,
pub profile: Profile,
pub env_name: String,
pub duplicates: HashMap<ResourceKey, usize>,
pub kube_client: Option<KubeRsClient>,
pub raw_client: Option<Client>,
}
#[allow(clippy::too_many_lines)]
pub async fn run_render_preflight(options: RenderPreflightOptions<'_>) -> Result<RenderPreflightResult> {
let (mut manifests, nyl_release, strip_empty_metadata_labels_mode, profile, env_name, mut duplicates) =
render_manifests_complete(
&options.common.path,
options.common.only_source_kind.as_deref(),
options.common.profile.as_deref(),
options.offline,
options.kube_version,
options.kube_api_versions,
options.common.max_depth,
options.common.track_parent,
)
.await?;
manifests = crate::cli::filter::filter_manifests_by_kind(
manifests,
&options.common.only_kind,
&options.common.exclude_kind,
)?;
let should_initialize_clients = should_initialize_cluster_clients(
options.offline,
options.cluster_client_requirement,
options.resolve_namespaces,
&manifests,
options.adjust_duplicate_keys,
);
let (kube_client, raw_client) = if should_initialize_clients {
let config = KubeRsClient::load_kube_config_from_profile(&profile, options.context_override).await?;
let client = Client::try_from(config)?;
(Some(KubeRsClient::from_client(client.clone()).await?), Some(client))
} else {
(None, None)
};
let release_namespace_hint = options
.release_namespace_hint
.or_else(|| nyl_release.as_ref().map(|release| release.metadata.namespace.as_str()));
if options.resolve_namespaces && should_resolve_namespaces(&manifests, options.offline) {
let client = kube_client
.as_ref()
.expect("kube client must exist when namespace resolution is enabled");
resolve_manifest_namespaces(client, &mut manifests, release_namespace_hint).await?;
}
if options.adjust_duplicate_keys {
if let Some(client) = kube_client.as_ref() {
duplicates =
adjust_duplicate_keys_for_namespace_resolution(client, &duplicates, release_namespace_hint).await?;
}
}
Ok(RenderPreflightResult {
manifests,
nyl_release,
strip_empty_metadata_labels: strip_empty_metadata_labels_mode.should_strip(is_argocd_env()),
profile,
env_name,
duplicates,
kube_client,
raw_client,
})
}
#[allow(clippy::too_many_arguments)]
pub async fn render_manifests_complete(
path: &str,
only_source_kind: Option<&str>,
environment: Option<&str>,
offline: bool,
cli_kube_version: Option<&str>,
cli_api_versions: &[String],
max_depth: usize,
track_parent: bool,
) -> Result<(
Vec<serde_json::Value>,
Option<NylRelease>,
StripEmptyMetadataLabelsMode,
Profile,
String,
std::collections::HashMap<ResourceKey, usize>,
)> {
let (manifests, strip_empty_metadata_labels_mode, profile, env_name, credential_provider, template_context) =
render_manifests(
path,
only_source_kind,
environment,
offline,
cli_kube_version,
cli_api_versions,
max_depth,
track_parent,
)
.await?;
let (nyl_release, manifests) = extract_nyl_release(&manifests)?;
let strip_empty_metadata_labels_mode =
resolve_strip_empty_metadata_labels_mode(strip_empty_metadata_labels_mode, nyl_release.as_ref());
let (generators, mut final_manifests) = extract_application_generators(&manifests)?;
if !generators.is_empty() {
tracing::debug!(
"Found {} ApplicationGenerator resource(s) in {}",
generators.len(),
path
);
}
for generator in generators {
let applications =
process_application_generator(&generator, path, credential_provider.clone(), &template_context)?;
final_manifests.extend(applications);
}
let (policies_by_scope, final_manifests) = extract_all_kyverno_policies(&final_manifests)?;
let global_policies = policies_by_scope
.get(&KyvernoScope::Global)
.cloned()
.unwrap_or_default();
let non_global_count: usize = policies_by_scope
.iter()
.filter(|(scope, _)| **scope != KyvernoScope::Global)
.map(|(_, policies)| policies.len())
.sum();
if non_global_count > 0 {
tracing::warn!(
"Found {} non-Global Kyverno policies. Only Global scope is currently supported. \
Immediate and Subtree scopes will be supported in a future version.",
non_global_count
);
}
let final_manifests = if global_policies.is_empty() {
final_manifests
} else {
apply_kyverno_policies(&final_manifests, &global_policies)?
};
let (final_manifests, duplicates) = deduplicate_manifests(final_manifests)?;
Ok((
final_manifests,
nyl_release,
strip_empty_metadata_labels_mode,
profile,
env_name,
duplicates,
))
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn render_manifests(
path: &str,
only_source_kind: Option<&str>,
environment: Option<&str>,
offline: bool,
cli_kube_version: Option<&str>,
cli_api_versions: &[String],
max_depth: usize,
track_parent: bool,
) -> Result<(
Vec<serde_json::Value>,
StripEmptyMetadataLabelsMode,
Profile,
String,
Option<Arc<crate::git::CredentialProvider>>,
TemplateContext,
)> {
let project_config = ProjectConfig::load_with_warning(None)?;
let (profile, profile_name) = select_profile_from_project(&project_config, environment)?;
let secrets_config = SecretsConfig::load(None)?;
let context = TemplateContext::build(&profile, &secrets_config, &profile_name)?;
let credential_provider = crate::git::argocd_credential_provider_from_cluster().await;
let resources = load_resources(path, &context)?;
let filtered = filter_resources(resources, only_source_kind)?;
let needs_helm_rendering = needs_helm_rendering(&filtered, &project_config);
let (kube_version, api_versions) = if !needs_helm_rendering {
(String::new(), Vec::new())
} else if offline {
(
cli_kube_version.unwrap_or_default().to_string(),
cli_api_versions.to_vec(),
)
} else {
let client = KubeRsClient::from_profile(&profile, None).await?;
let kube_version = if let Some(v) = cli_kube_version {
v.to_string()
} else {
client.get_server_version().await?
};
let api_versions = if cli_api_versions.is_empty() {
client.get_api_versions().await?
} else {
cli_api_versions.to_vec()
};
(kube_version, api_versions)
};
let mut all_manifests = Vec::new();
let mut pending = filtered;
for _ in 0..max_depth {
let mut next_pending = Vec::new();
for resource in pending {
let manifests = generate_resource(
&resource,
&context,
&project_config,
&kube_version,
&api_versions,
credential_provider.clone(),
track_parent,
)
.await?;
for manifest in manifests {
if is_renderable_resource(&manifest, &project_config) {
next_pending.push(manifest);
} else {
all_manifests.push(manifest);
}
}
}
pending = next_pending;
if pending.is_empty() {
break;
}
}
all_manifests.extend(pending);
Ok((
all_manifests,
project_config.get_strip_empty_metadata_labels_mode(),
profile,
profile_name,
credential_provider,
context,
))
}
fn select_profile_from_project(project_config: &ProjectConfig, requested: Option<&str>) -> Result<(Profile, String)> {
let profile_name = requested.unwrap_or("default");
let selected = if let Some(values) = project_config.get_profile_values(profile_name) {
Profile {
values: values.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
..Default::default()
}
} else if requested.is_some() {
return Err(NylError::Config(format!("Profile '{}' not found", profile_name)));
} else if !project_config.has_profiles() {
Profile::default()
} else {
return Err(NylError::Config(format!(
"Profile '{}' not found. Available profiles: {}",
profile_name,
project_config.profile_names().join(", ")
)));
};
Ok((selected, profile_name.to_string()))
}
pub async fn execute(args: RenderArgs) -> Result<()> {
let preflight = run_render_preflight(RenderPreflightOptions {
common: &args.common,
offline: args.offline,
kube_version: args.kube_version.as_deref(),
kube_api_versions: &args.kube_api_versions,
context_override: None,
cluster_client_requirement: ClusterClientRequirement::OnDemand,
resolve_namespaces: true,
release_namespace_hint: None,
adjust_duplicate_keys: false,
})
.await?;
if !preflight.duplicates.is_empty() {
let total_unique = preflight.duplicates.len();
let total_ignored: usize = preflight.duplicates.values().map(|count| count - 1).sum();
tracing::warn!(
"Found {} unique resources with duplicates ({} total duplicates ignored, keeping last occurrence)",
total_unique,
total_ignored
);
}
output_manifests(
&preflight.manifests,
OutputFormat::Yaml,
preflight.strip_empty_metadata_labels,
)?;
Ok(())
}
fn should_resolve_namespaces(manifests: &[serde_json::Value], offline: bool) -> bool {
!offline && manifests.iter().any(manifest_requires_namespace_resolution)
}
fn should_initialize_cluster_clients(
offline: bool,
cluster_client_requirement: ClusterClientRequirement,
resolve_namespaces: bool,
manifests: &[serde_json::Value],
adjust_duplicate_keys: bool,
) -> bool {
!offline
&& (cluster_client_requirement == ClusterClientRequirement::Required
|| adjust_duplicate_keys
|| (resolve_namespaces && should_resolve_namespaces(manifests, offline)))
}
fn normalize_emitted_manifests(manifests: &mut [serde_json::Value]) {
for manifest in manifests {
strip_empty_metadata_labels(manifest);
}
}
fn resolve_strip_empty_metadata_labels_mode(
project_mode: StripEmptyMetadataLabelsMode,
release: Option<&NylRelease>,
) -> StripEmptyMetadataLabelsMode {
release
.and_then(|release| release.spec.strip_empty_metadata_labels)
.unwrap_or(project_mode)
}
fn prepare_manifests_for_output(
manifests: &[serde_json::Value],
strip_empty_metadata_labels: bool,
) -> Vec<serde_json::Value> {
let mut emitted_manifests = manifests.to_vec();
if strip_empty_metadata_labels {
normalize_emitted_manifests(&mut emitted_manifests);
}
emitted_manifests
}
fn strip_empty_metadata_labels(manifest: &mut serde_json::Value) {
let Some(metadata) = manifest.get_mut("metadata").and_then(|value| value.as_object_mut()) else {
return;
};
let should_remove = metadata
.get("labels")
.and_then(|value| value.as_object())
.is_some_and(serde_json::Map::is_empty);
if should_remove {
metadata.remove("labels");
}
}
fn manifest_requires_namespace_resolution(manifest: &serde_json::Value) -> bool {
let has_namespace = crate::kubernetes::extract_namespace(manifest)
.as_deref()
.is_some_and(|ns| !ns.trim().is_empty());
if has_namespace {
return false;
}
crate::kubernetes::extract_gvk(manifest).map_or(true, |gvk| !crate::kubernetes::is_known_cluster_scoped_gvk(&gvk))
}
fn load_resources(path: &str, context: &TemplateContext) -> Result<Vec<serde_json::Value>> {
let path = Path::new(path);
if !path.exists() {
return Err(NylError::Config(format!("File not found: {}", path.display())));
}
if !path.is_file() {
return Err(NylError::Config(format!(
"Path must be a file, not a directory: {}. \
Nyl processes single files only. Please specify a YAML/JSON file path.",
path.display()
)));
}
let engine = TemplateEngine::new();
let ctx_json = context.to_json();
let mut resources = Vec::new();
let files: Vec<std::path::PathBuf> = vec![path.to_path_buf()];
for file_path in &files {
let ext = file_path.extension().and_then(|s| s.to_str());
if !matches!(ext, Some("yaml" | "yml" | "json")) {
continue;
}
let stem = file_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if matches!(stem, "nyl" | "nyl-project" | "nyl-profiles" | "nyl-secrets") {
continue;
}
tracing::debug!("Reading manifest file: {}", file_path.display());
let raw =
std::fs::read_to_string(file_path).map_err(|e| NylError::Config(format!("Failed to read file: {}", e)))?;
let rendered = engine.render_named(&file_path.display().to_string(), &raw, &ctx_json)?;
let source_ctx = crate::util::SourceContext::new(file_path.clone());
let docs = source_ctx.parse_yaml_documents(&rendered)?;
resources.extend(docs);
}
Ok(resources)
}
fn filter_resources(
resources: Vec<serde_json::Value>,
only_source_kind: Option<&str>,
) -> Result<Vec<serde_json::Value>> {
if let Some(filter) = only_source_kind {
Ok(resources
.into_iter()
.filter(|r| {
let kind = r.get("kind").and_then(|k| k.as_str()).unwrap_or("");
let api_version = r.get("apiVersion").and_then(|a| a.as_str()).unwrap_or("");
filter == kind || filter == format!("{}/{}", api_version, kind)
})
.collect())
} else {
Ok(resources)
}
}
fn deduplicate_manifests(
manifests: Vec<serde_json::Value>,
) -> Result<(Vec<serde_json::Value>, std::collections::HashMap<ResourceKey, usize>)> {
use crate::kubernetes::ResourceKey;
use std::collections::HashMap;
let mut seen: HashMap<ResourceKey, usize> = HashMap::new();
let mut deduplicated = Vec::new();
let mut duplicate_counts: HashMap<ResourceKey, usize> = HashMap::new();
for manifest in manifests {
let key = ResourceKey::from_json_value(&manifest)?;
if let Some(prev_index) = seen.get(&key) {
tracing::warn!("Duplicate resource: {} (keeping last occurrence)", key);
deduplicated[*prev_index] = manifest;
*duplicate_counts.entry(key).or_insert(1) += 1;
} else {
duplicate_counts.insert(key.clone(), 1);
seen.insert(key, deduplicated.len());
deduplicated.push(manifest);
}
}
let duplicates: HashMap<_, _> = duplicate_counts.into_iter().filter(|(_, count)| *count > 1).collect();
Ok((deduplicated, duplicates))
}
fn is_renderable_resource(resource: &serde_json::Value, config: &ProjectConfig) -> bool {
let kind = resource.get("kind").and_then(|k| k.as_str());
let api_version = resource.get("apiVersion").and_then(|a| a.as_str());
(kind == Some("HelmChart") && api_version == Some(API_VERSION))
|| (kind == Some("RemoteManifest") && api_version == Some(API_VERSION))
|| is_nyl_component(resource)
|| api_version
.zip(kind)
.and_then(|(av, k)| config.get_alias_target_for_kind(av, k))
.is_some()
}
fn needs_helm_rendering(resources: &[serde_json::Value], config: &ProjectConfig) -> bool {
resources.iter().any(|resource| {
let kind = resource.get("kind").and_then(|k| k.as_str());
let api_version = resource.get("apiVersion").and_then(|a| a.as_str());
(kind == Some("HelmChart") && api_version == Some(API_VERSION))
|| api_version == Some(API_VERSION_COMPONENTS)
|| api_version
.zip(kind)
.and_then(|(av, k)| config.get_alias_target_for_kind(av, k))
.is_some()
})
}
const MAX_TYPO_DISTANCE: usize = 3;
const MAX_REMOTE_MANIFEST_BYTES: usize = 30 * 1024 * 1024;
fn is_nyl_like_api_version(api_version: &str) -> bool {
if api_version.contains("nyl.niklasrosenstein.github.com") {
return true;
}
let domain = api_version.split('/').next().unwrap_or(api_version);
let nyl_api_versions = [API_VERSION, API_VERSION_COMPONENTS, API_VERSION_ARGOCD];
for api_ver in &nyl_api_versions {
let known_domain = api_ver.split('/').next().unwrap_or(api_ver);
if levenshtein_distance(domain, known_domain) <= MAX_TYPO_DISTANCE {
return true;
}
}
false
}
fn levenshtein_distance(s1: &str, s2: &str) -> usize {
let len1 = s1.len();
let len2 = s2.len();
if len1 == 0 {
return len2;
}
if len2 == 0 {
return len1;
}
let chars1: Vec<char> = s1.chars().collect();
let chars2: Vec<char> = s2.chars().collect();
let len1 = chars1.len();
let len2 = chars2.len();
let mut matrix = vec![vec![0; len2 + 1]; len1 + 1];
for (i, row) in matrix.iter_mut().enumerate().take(len1 + 1) {
row[0] = i;
}
for (j, cell) in matrix[0].iter_mut().enumerate().take(len2 + 1) {
*cell = j;
}
for (i, c1) in chars1.iter().enumerate() {
for (j, c2) in chars2.iter().enumerate() {
let cost = usize::from(c1 != c2);
matrix[i + 1][j + 1] = (matrix[i][j + 1] + 1)
.min(matrix[i + 1][j] + 1)
.min(matrix[i][j] + cost);
}
}
matrix[len1][len2]
}
fn is_known_nyl_resource(resource: &serde_json::Value) -> bool {
let kind = resource.get("kind").and_then(|k| k.as_str());
let api_version = resource.get("apiVersion").and_then(|a| a.as_str());
if kind == Some("HelmChart") && api_version == Some(API_VERSION) {
return true;
}
if is_nyl_component(resource) {
return true;
}
if NylRelease::is_nyl_release(resource) {
return true;
}
if RemoteManifest::is_remote_manifest(resource) {
return true;
}
if let Some(api_ver) = api_version {
if api_ver == API_VERSION_ARGOCD && kind == Some("ApplicationGenerator") {
return true;
}
}
false
}
#[allow(clippy::too_many_lines)]
async fn generate_resource(
resource: &serde_json::Value,
context: &TemplateContext,
config: &ProjectConfig,
kube_version: &str,
api_versions: &[String],
credential_provider: Option<Arc<crate::git::CredentialProvider>>,
track_parent: bool,
) -> Result<Vec<serde_json::Value>> {
let kind = resource.get("kind").and_then(|k| k.as_str());
let api_version = resource.get("apiVersion").and_then(|a| a.as_str());
if kind == Some("HelmChart") && api_version == Some(API_VERSION) {
let chart: HelmChart = serde_json::from_value(resource.clone())
.map_err(|e| NylError::Config(format!("Failed to parse HelmChart: {}", e)))?;
let manifests = render_helm_chart(
&chart,
context,
config,
kube_version,
api_versions,
credential_provider.clone(),
)?;
Ok(apply_parent_tracking_annotations(
manifests,
track_parent,
&chart.api_version,
&chart.kind,
&chart.metadata.name,
chart.metadata.namespace.as_deref(),
))
} else if kind == Some("RemoteManifest") && api_version == Some(API_VERSION) {
let remote_manifest = RemoteManifest::from_value(resource)?;
remote_manifest.validate()?;
let manifests = fetch_remote_manifest_documents(&remote_manifest).await?;
Ok(apply_parent_tracking_annotations(
manifests,
track_parent,
&remote_manifest.api_version,
&remote_manifest.kind,
&remote_manifest.metadata.name,
remote_manifest.metadata.namespace.as_deref(),
))
} else if is_nyl_component(resource)
|| api_version
.zip(kind)
.and_then(|(av, k)| config.get_alias_target_for_kind(av, k))
.is_some()
{
let mut component: NylComponent = serde_json::from_value(resource.clone())
.map_err(|e| NylError::Config(format!("Failed to parse Component: {}", e)))?;
if let Some(target) = config.get_alias_target_for_kind(&component.api_version, &component.kind) {
component.kind = target.to_string();
}
if is_remote_helm_chart_shortcut(&component.kind) {
let parsed = parse_component_kind(&component.kind);
if (parsed.base.starts_with("http://") || parsed.base.starts_with("https://")) && parsed.name.is_none() {
return Err(NylError::Config(format!(
"Invalid remote Helm chart shortcut '{}': missing chart name. \
Use '<repository>#<chart-name>' or '<repository>#<chart-name>@<version>'.",
component.kind
)));
}
let chart_ref = component_kind_to_chart_ref(&parsed);
let release_namespace = component.metadata.namespace.clone();
let component_api_version = component.api_version.clone();
let component_kind = component.kind.clone();
let component_name = component.metadata.name.clone();
let chart = HelmChart {
api_version: API_VERSION.to_string(),
kind: "HelmChart".to_string(),
metadata: component.metadata,
spec: crate::resources::HelmChartSpec {
chart: chart_ref,
values: component.spec,
include_crds: None,
},
};
let manifests = render_helm_chart(
&chart,
context,
config,
kube_version,
api_versions,
credential_provider.clone(),
)?;
Ok(apply_parent_tracking_annotations(
manifests,
track_parent,
&component_api_version,
&component_kind,
&component_name,
release_namespace.as_deref(),
))
} else {
let chart_dir = config.resolve_component_chart_dir(&component.kind)?;
let release_namespace = component.metadata.namespace.clone();
let component_api_version = component.api_version.clone();
let component_kind = component.kind.clone();
let component_name = component.metadata.name.clone();
let chart = HelmChart {
api_version: API_VERSION.to_string(),
kind: "HelmChart".to_string(),
metadata: component.metadata,
spec: crate::resources::HelmChartSpec {
chart: ChartRef {
name: Some(chart_dir.to_string_lossy().into_owned()),
..Default::default()
},
values: component.spec,
include_crds: None,
},
};
let manifests = render_helm_chart(
&chart,
context,
config,
kube_version,
api_versions,
credential_provider.clone(),
)?;
Ok(apply_parent_tracking_annotations(
manifests,
track_parent,
&component_api_version,
&component_kind,
&component_name,
release_namespace.as_deref(),
))
}
} else {
if let Some(api_ver) = api_version {
if is_nyl_like_api_version(api_ver) && !is_known_nyl_resource(resource) {
let kind_str = kind.unwrap_or("<unknown>");
let known_api_versions = [API_VERSION, API_VERSION_COMPONENTS, API_VERSION_ARGOCD];
let api_versions_str = known_api_versions
.iter()
.map(|s| format!("'{}'", s))
.collect::<Vec<_>>()
.join(", ");
tracing::warn!(
"Resource with apiVersion '{}' and kind '{}' looks like a Nyl resource but is not recognized. \
It will be treated as a regular Kubernetes manifest. \
Known Nyl apiVersions: {}. \
Known kinds: HelmChart, RemoteManifest, NylRelease, ApplicationGenerator, and any Component kind.",
api_ver,
kind_str,
api_versions_str
);
}
}
Ok(vec![resource.clone()])
}
}
async fn fetch_remote_manifest_documents(remote_manifest: &RemoteManifest) -> Result<Vec<serde_json::Value>> {
let url = remote_manifest.spec.url.trim();
let sanitized_url = crate::util::sanitize_url(url);
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::custom(|attempt| {
if attempt.url().scheme() == "https" {
attempt.follow()
} else {
attempt.stop()
}
}))
.build()
.map_err(|e| {
NylError::Process(format!(
"Failed to initialize HTTPS client for RemoteManifest '{}' from {}: {}",
remote_manifest.metadata.name, sanitized_url, e
))
})?;
let mut response = client.get(url).send().await.map_err(|e| {
let detail = if e.is_timeout() {
"request timed out"
} else if e.is_connect() {
"connection failed"
} else {
"request failed"
};
NylError::Process(format!(
"Failed to fetch RemoteManifest '{}' from {}: {}",
remote_manifest.metadata.name, sanitized_url, detail
))
})?;
if !response.status().is_success() {
return Err(NylError::Process(format!(
"Failed to fetch RemoteManifest '{}' from {}: HTTP {}",
remote_manifest.metadata.name,
sanitized_url,
response.status()
)));
}
if let Some(content_length) = response.content_length() {
if content_length > MAX_REMOTE_MANIFEST_BYTES as u64 {
return Err(NylError::Process(format!(
"RemoteManifest '{}' from {} exceeds size limit ({} bytes > {} bytes)",
remote_manifest.metadata.name, sanitized_url, content_length, MAX_REMOTE_MANIFEST_BYTES
)));
}
}
let mut body_bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|e| {
NylError::Process(format!(
"Failed to read RemoteManifest response body from {}: {}",
sanitized_url, e
))
})? {
if body_bytes.len() + chunk.len() > MAX_REMOTE_MANIFEST_BYTES {
return Err(NylError::Process(format!(
"RemoteManifest '{}' from {} exceeds size limit (>{} bytes)",
remote_manifest.metadata.name, sanitized_url, MAX_REMOTE_MANIFEST_BYTES
)));
}
body_bytes.extend_from_slice(&chunk);
}
let body = String::from_utf8(body_bytes).map_err(|e| {
NylError::Process(format!(
"RemoteManifest '{}' from {} returned non-UTF-8 content: {}",
remote_manifest.metadata.name, sanitized_url, e
))
})?;
let source_ctx = crate::util::SourceContext::new(PathBuf::from(format!("remote:{sanitized_url}")));
let mut documents = source_ctx.parse_yaml_documents(&body)?;
if remote_manifest.spec.override_namespace {
override_fetched_manifest_namespaces(&mut documents, remote_manifest.metadata.namespace.as_deref());
}
Ok(documents)
}
fn override_fetched_manifest_namespaces(manifests: &mut [serde_json::Value], namespace: Option<&str>) {
let Some(namespace) = namespace else {
return;
};
for manifest in manifests {
let Some(obj) = manifest.as_object_mut() else {
continue;
};
let Some(metadata_obj) = obj.get_mut("metadata").and_then(|v| v.as_object_mut()) else {
continue;
};
if metadata_obj.contains_key("namespace") {
metadata_obj.insert(
"namespace".to_string(),
serde_json::Value::String(namespace.to_string()),
);
}
let is_rbac_binding_kind = obj
.get("kind")
.and_then(|v| v.as_str())
.is_some_and(|k| k == "RoleBinding" || k == "ClusterRoleBinding");
let is_rbac_api_group = obj
.get("apiVersion")
.and_then(|v| v.as_str())
.is_some_and(|v| v.starts_with("rbac.authorization.k8s.io/"));
if is_rbac_binding_kind && is_rbac_api_group {
let Some(spec_subjects) = obj.get_mut("subjects").and_then(|v| v.as_array_mut()) else {
continue;
};
for subject in spec_subjects {
let Some(subject_obj) = subject.as_object_mut() else {
continue;
};
let is_service_account = subject_obj.get("kind").and_then(|v| v.as_str()) == Some("ServiceAccount");
if subject_obj.contains_key("namespace") || is_service_account {
subject_obj.insert(
"namespace".to_string(),
serde_json::Value::String(namespace.to_string()),
);
}
}
}
}
}
fn apply_parent_tracking_annotations(
manifests: Vec<serde_json::Value>,
track_parent: bool,
parent_api_version: &str,
parent_kind: &str,
parent_name: &str,
parent_namespace: Option<&str>,
) -> Vec<serde_json::Value> {
if !track_parent {
return manifests;
}
manifests
.into_iter()
.map(|mut manifest| {
add_parent_annotations(
&mut manifest,
parent_api_version,
parent_kind,
parent_name,
parent_namespace,
);
manifest
})
.collect()
}
fn render_helm_chart(
chart: &HelmChart,
context: &TemplateContext,
config: &ProjectConfig,
kube_version: &str,
api_versions: &[String],
credential_provider: Option<Arc<crate::git::CredentialProvider>>,
) -> Result<Vec<serde_json::Value>> {
let working_dir =
std::env::current_dir().map_err(|e| NylError::Config(format!("Failed to get current directory: {}", e)))?;
let resolver = HelmChartResolver::with_cache_dir_and_provider(
config.get_helm_chart_search_paths().to_vec(),
working_dir,
None,
credential_provider,
);
let resolved = resolver.resolve_chart(&chart.spec.chart)?;
let merged_values = deep_merge_value(Some(chart.spec.values.clone()), context.values.clone());
let executor = HelmTemplateExecutor::new()
.with_kube_version(kube_version.to_string())
.with_api_versions(api_versions.to_vec())
.with_include_crds(chart.spec.include_crds.unwrap_or(true));
let namespace = chart.release_namespace().or(Some("default"));
executor.template(&resolved, chart.release_name(), namespace, &merged_values)
}
fn output_manifests(
manifests: &[serde_json::Value],
format: OutputFormat,
strip_empty_metadata_labels: bool,
) -> Result<()> {
let manifests = prepare_manifests_for_output(manifests, strip_empty_metadata_labels);
match format {
OutputFormat::Yaml => {
for (i, manifest) in manifests.iter().enumerate() {
if i > 0 {
println!("---");
}
let yaml = crate::yaml::serialize_yaml_document(manifest).map_err(NylError::YamlEmit)?;
print!("{}", yaml);
}
}
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&manifests)
.map_err(|e| NylError::Config(format!("Failed to serialize JSON: {}", e)))?;
println!("{}", json);
}
}
Ok(())
}
fn render_yaml_file_with_jinja(
file_path: &Path,
source_root: &Path,
engine: &TemplateEngine,
ctx_json: &serde_json::Value,
) -> Result<(Vec<serde_json::Value>, Option<String>)> {
let raw = std::fs::read_to_string(file_path)
.map_err(|e| NylError::Config(format!("Failed to read file {}: {}", file_path.display(), e)))?;
let source_ctx = crate::util::SourceContext::new(file_path.to_path_buf());
let rel_path = file_path
.strip_prefix(source_root)
.unwrap_or(file_path)
.display()
.to_string();
Ok(match engine.render_named(&rel_path, &raw, ctx_json) {
Ok(rendered) => match source_ctx.parse_yaml_documents(&rendered) {
Ok(docs) => (docs, None),
Err(e) => {
tracing::warn!(
"YAML parse error after Jinja rendering in {}: {}. \
Attempting best-effort document extraction.",
file_path.display(),
e
);
(best_effort_parse_yaml_documents(&rendered), Some(e.to_string()))
}
},
Err(e) => {
tracing::warn!(
"Jinja template rendering failed for {}: {}. \
Attempting best-effort document extraction.",
file_path.display(),
e
);
(best_effort_parse_yaml_documents(&raw), Some(e.to_string()))
}
})
}
fn process_application_generator(
generator: &crate::resources::ApplicationGenerator,
_base_dir: &str,
credential_provider: Option<Arc<crate::git::CredentialProvider>>,
template_context: &TemplateContext,
) -> Result<Vec<serde_json::Value>> {
let source_selectors = application_generator_source_selectors(generator);
tracing::debug!(
"Processing ApplicationGenerator {}: repoURL={}, targetRevision={}, selectors={}",
generator.metadata.name,
generator.spec.source.repo_url,
generator.spec.source.target_revision,
source_selectors.join(", ")
);
let source_root = resolve_application_generator_source_path(generator, credential_provider)?;
tracing::debug!(
"ApplicationGenerator {} resolved source root to {}",
generator.metadata.name,
source_root.display()
);
let yaml_files = find_yaml_files_filtered(
&source_root,
&source_selectors,
&generator.spec.source.include,
&generator.spec.source.exclude,
)?;
tracing::debug!(
"ApplicationGenerator {} discovered {} YAML file(s) after include/exclude filters",
generator.metadata.name,
yaml_files.len()
);
let scanned_file_count = yaml_files.len();
let engine = TemplateEngine::new();
let ctx_json = template_context.to_json();
let mut applications = Vec::new();
let mut missing_release_files = Vec::new();
let mut missing_release_count = 0usize;
for file_path in yaml_files {
tracing::debug!("Reading YAML file: {}", file_path.display());
let (docs, render_error) = render_yaml_file_with_jinja(&file_path, &source_root, &engine, &ctx_json)?;
let (nyl_release, _) = extract_nyl_release(&docs)?;
if let Some(release) = nyl_release {
let mut app = create_argocd_application_from_generator(&release, &file_path, &source_root, generator)?;
if let Some(ref error_msg) = render_error {
let rel_path = file_path
.strip_prefix(&source_root)
.unwrap_or(&file_path)
.display()
.to_string();
disable_automated_sync(&mut app);
append_render_error_info(&mut app, &rel_path, error_msg)?;
tracing::warn!(
"Generated husk ArgoCD Application {} from NylRelease in {} (rendering or parsing failed: {})",
release.metadata.name,
file_path.display(),
error_msg
);
} else {
tracing::debug!(
"Generated ArgoCD Application {} from NylRelease in {}",
release.metadata.name,
file_path.display()
);
}
applications.push(app);
} else {
tracing::trace!("No NylRelease found in {}, skipping", file_path.display());
missing_release_count += 1;
let display_path = file_path
.strip_prefix(&source_root)
.map_or_else(|_| file_path.display().to_string(), normalize_relative_path_to_posix);
missing_release_files.push(display_path);
}
}
if missing_release_count > 0 {
tracing::warn!(
"{}",
missing_nyl_release_warning_message(
generator,
missing_release_count,
scanned_file_count,
&missing_release_files
)
);
}
tracing::debug!(
"ApplicationGenerator {} generated {} ArgoCD Application(s) total",
generator.metadata.name,
applications.len()
);
Ok(applications)
}
pub(super) fn best_effort_parse_yaml_documents(raw: &str) -> Vec<serde_json::Value> {
let mut docs = Vec::new();
for doc_str in split_yaml_documents(raw) {
let trimmed = doc_str.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(parsed) = crate::yaml::parse_yaml_documents_k8s_compatible(trimmed) {
docs.extend(parsed);
}
}
docs
}
fn split_yaml_documents(raw: &str) -> Vec<&str> {
let mut docs = Vec::new();
let mut start = 0;
let mut offset = 0;
for line in raw.split_inclusive('\n') {
let line_content = line.trim_end_matches(['\r', '\n']);
if line_content == "---" {
if offset > start {
docs.push(&raw[start..offset]);
}
start = offset + line.len();
}
offset += line.len();
}
if offset > start {
let remainder = &raw[start..offset];
if !remainder.trim().is_empty() {
docs.push(remainder);
}
}
docs
}
fn disable_automated_sync(app: &mut serde_json::Value) {
if let Some(spec) = app.get_mut("spec").and_then(|v| v.as_object_mut()) {
if let Some(sync_policy) = spec.get_mut("syncPolicy").and_then(|v| v.as_object_mut()) {
sync_policy.remove("automated");
}
}
}
fn append_render_error_info(app: &mut serde_json::Value, file_path: &str, error_msg: &str) -> Result<()> {
let spec = app
.get_mut("spec")
.and_then(|v| v.as_object_mut())
.ok_or_else(|| NylError::Config("Generated Application is missing spec".to_string()))?;
let info_value = spec
.entry("info".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if !info_value.is_array() {
let previous = std::mem::take(info_value);
*info_value = serde_json::Value::Array(vec![previous]);
}
let info_items = info_value
.as_array_mut()
.ok_or_else(|| NylError::Config("Application spec.info is not an array".to_string()))?;
info_items.push(serde_json::json!({
"name": "nyl-render-error",
"value": format!("Failed to render or parse {}: {}", file_path, error_msg),
}));
Ok(())
}
fn missing_nyl_release_warning_message(
generator: &crate::resources::ApplicationGenerator,
missing_release_count: usize,
scanned_file_count: usize,
skipped_files: &[String],
) -> String {
let selectors = application_generator_source_selectors(generator);
let selectors_text = if selectors.is_empty() {
"<none>".to_string()
} else {
selectors.join(", ")
};
let base = format!(
"ApplicationGenerator {} (repoURL={}, targetRevision={}, source paths={}): skipped {}/{} file(s) because no NylRelease was found.",
generator.metadata.name,
generator.spec.source.repo_url,
generator.spec.source.target_revision,
selectors_text,
missing_release_count,
scanned_file_count
);
if skipped_files.is_empty() {
base
} else {
format!("{} Skipped files: {}", base, skipped_files.join(", "))
}
}
fn application_generator_source_selectors(generator: &crate::resources::ApplicationGenerator) -> Vec<String> {
if let Some(path) = &generator.spec.source.path {
return vec![path.clone()];
}
generator.spec.source.paths.clone().unwrap_or_default()
}
fn resolve_application_generator_source_path(
generator: &crate::resources::ApplicationGenerator,
credential_provider: Option<Arc<crate::git::CredentialProvider>>,
) -> Result<PathBuf> {
const APPGEN_REPO_PATH_OVERRIDE: &str = "NYL_APPGEN_REPO_PATH_OVERRIDE";
if let Ok(override_root_raw) = std::env::var(APPGEN_REPO_PATH_OVERRIDE) {
let override_root_raw = override_root_raw.trim();
if !override_root_raw.is_empty() {
let override_root = resolve_override_root_path(APPGEN_REPO_PATH_OVERRIDE, override_root_raw)?;
if !override_root.exists() {
return Err(NylError::Config(format!(
"Environment variable {} points to a path that does not exist: {}",
APPGEN_REPO_PATH_OVERRIDE,
override_root.display()
)));
}
if !override_root.is_dir() {
return Err(NylError::Config(format!(
"Environment variable {} must point to a directory, got: {}",
APPGEN_REPO_PATH_OVERRIDE,
override_root.display()
)));
}
tracing::debug!(
"Using {} for ApplicationGenerator {} (repoURL={}, targetRevision={})",
APPGEN_REPO_PATH_OVERRIDE,
generator.metadata.name,
generator.spec.source.repo_url,
generator.spec.source.target_revision
);
return Ok(override_root);
}
}
if let Some(local_repo_root) = try_resolve_application_generator_source_from_local_git_repo(generator) {
return Ok(local_repo_root);
}
let mut git_manager = crate::git::GitManager::with_credential_provider(credential_provider)?;
Ok(git_manager.resolve_ref(
&generator.spec.source.repo_url,
Some(&generator.spec.source.target_revision),
None,
)?)
}
fn try_resolve_application_generator_source_from_local_git_repo(
generator: &crate::resources::ApplicationGenerator,
) -> Option<PathBuf> {
let cwd = match resolve_current_pwd() {
Ok(path) => path,
Err(err) => {
tracing::trace!(
"Skipping local git worktree reuse for ApplicationGenerator {}: failed to resolve current directory: {}",
generator.metadata.name,
err
);
return None;
}
};
let repo = match git2::Repository::discover(&cwd) {
Ok(repo) => repo,
Err(err) => {
tracing::trace!(
"Skipping local git worktree reuse for ApplicationGenerator {}: no Git repository discovered from {}: {}",
generator.metadata.name,
cwd.display(),
err
);
return None;
}
};
let Some(repo_root) = repo_root_path(&repo) else {
tracing::trace!(
"Skipping local git worktree reuse for ApplicationGenerator {}: discovered repository has no worktree root",
generator.metadata.name
);
return None;
};
let requested_url = crate::git::normalize_git_url_for_equality(&generator.spec.source.repo_url);
let remote_urls = local_git_remote_urls(&repo);
if remote_urls.is_empty() {
tracing::trace!(
"Skipping local git worktree reuse for ApplicationGenerator {}: discovered repository has no remote URLs",
generator.metadata.name
);
return None;
}
if !remote_urls
.iter()
.any(|remote_url| crate::git::normalize_git_url_for_equality(remote_url) == requested_url)
{
let local_remote_urls = remote_urls
.iter()
.map(|url| crate::util::sanitize_url(url))
.collect::<Vec<_>>()
.join(", ");
tracing::trace!(
"Skipping local git worktree reuse for ApplicationGenerator {}: repoURL mismatch (requested={}, local remotes=[{}])",
generator.metadata.name,
crate::util::sanitize_url(&generator.spec.source.repo_url),
local_remote_urls
);
return None;
}
let requested_revision = generator.spec.source.target_revision.trim();
if requested_revision == "HEAD" {
tracing::debug!(
"Reusing local git worktree for ApplicationGenerator {} at {} (repoURL={}, targetRevision=HEAD)",
generator.metadata.name,
repo_root.display(),
crate::util::sanitize_url(&generator.spec.source.repo_url)
);
return Some(repo_root);
}
let Some(current_branch) = current_local_branch_name(&repo) else {
tracing::trace!(
"Skipping local git worktree reuse for ApplicationGenerator {}: current checkout is detached HEAD and targetRevision requires branch match (requested={})",
generator.metadata.name,
requested_revision
);
return None;
};
if requested_revision != current_branch {
tracing::trace!(
"Skipping local git worktree reuse for ApplicationGenerator {}: targetRevision mismatch (requested={}, current branch={})",
generator.metadata.name,
requested_revision,
current_branch
);
return None;
}
tracing::debug!(
"Reusing local git worktree for ApplicationGenerator {} at {} (repoURL={}, targetRevision={})",
generator.metadata.name,
repo_root.display(),
crate::util::sanitize_url(&generator.spec.source.repo_url),
requested_revision
);
Some(repo_root)
}
fn repo_root_path(repo: &git2::Repository) -> Option<PathBuf> {
repo.workdir().map(Path::to_path_buf).or_else(|| {
if repo.is_bare() {
Some(repo.path().to_path_buf())
} else {
repo.path().parent().map(Path::to_path_buf)
}
})
}
fn local_git_remote_urls(repo: &git2::Repository) -> Vec<String> {
let mut urls = std::collections::BTreeSet::new();
let Ok(remotes) = repo.remotes() else {
return Vec::new();
};
for remote_name in remotes.iter().flatten() {
let Ok(remote) = repo.find_remote(remote_name) else {
continue;
};
if let Some(url) = remote.url() {
urls.insert(url.to_string());
}
if let Some(push_url) = remote.pushurl() {
urls.insert(push_url.to_string());
}
}
urls.into_iter().collect()
}
fn current_local_branch_name(repo: &git2::Repository) -> Option<String> {
let Ok(head) = repo.head() else {
return None;
};
if !head.is_branch() {
return None;
}
head.shorthand().map(str::to_string)
}
fn resolve_override_root_path(env_var_name: &str, raw: &str) -> Result<PathBuf> {
if raw == "@git" {
return resolve_git_repo_root_from_current_pwd(env_var_name);
}
let candidate = PathBuf::from(raw);
if candidate.is_absolute() {
return Ok(candidate);
}
Ok(resolve_current_pwd()?.join(candidate))
}
fn resolve_git_repo_root_from_current_pwd(env_var_name: &str) -> Result<PathBuf> {
let cwd = resolve_current_pwd()?;
let repo = git2::Repository::discover(&cwd).map_err(|e| {
NylError::Config(format!(
"Environment variable {} is set to @git, but no Git repository root could be discovered from {}: {}",
env_var_name,
cwd.display(),
e
))
})?;
if let Some(workdir) = repo.workdir() {
return Ok(workdir.to_path_buf());
}
let repo_path = repo.path();
if let Some(parent) = repo_path.parent() {
return Ok(parent.to_path_buf());
}
Err(NylError::Config(format!(
"Environment variable {} is set to @git, but the discovered Git repository has no parent directory: {}",
env_var_name,
repo_path.display()
)))
}
fn resolve_current_pwd() -> Result<PathBuf> {
if let Ok(pwd) = std::env::var("PWD") {
let pwd_path = PathBuf::from(pwd);
if pwd_path.is_absolute() {
return Ok(pwd_path);
}
}
std::env::current_dir()
.map_err(|e| NylError::Config(format!("Failed to get current directory for path resolution: {}", e)))
}
fn find_yaml_files_filtered(
source_root: &Path,
selectors: &[String],
include: &[String],
exclude: &[String],
) -> Result<Vec<std::path::PathBuf>> {
if !source_root.exists() {
return Err(NylError::Config(format!(
"Source path does not exist: {}",
source_root.display()
)));
}
if !source_root.is_dir() {
return Err(NylError::Config(format!(
"Source path must be a directory: {}",
source_root.display()
)));
}
let mut warnings = ScanWarnings::default();
let mut candidates = std::collections::BTreeSet::new();
for selector in selectors {
collect_selector_candidates(source_root, selector, &mut candidates, &mut warnings)?;
}
let mut files = Vec::new();
for candidate in candidates {
let rel = candidate.strip_prefix(source_root).map_err(|e| {
NylError::Config(format!(
"Failed to compute relative path for {} under {}: {}",
candidate.display(),
source_root.display(),
e
))
})?;
if !matches_glob_patterns(rel, include)? {
continue;
}
if matches_glob_patterns(rel, exclude)? {
continue;
}
files.push(candidate);
}
if warnings.unreadable_entries > 0 {
let examples = if warnings.examples.is_empty() {
String::new()
} else {
format!(" Examples: {}", warnings.examples.join(" | "))
};
tracing::warn!(
"Skipped {} unreadable path(s) while scanning {}.{}",
warnings.unreadable_entries,
source_root.display(),
examples
);
}
Ok(files)
}
#[derive(Default)]
struct ScanWarnings {
unreadable_entries: usize,
examples: Vec<String>,
}
fn collect_selector_candidates(
source_root: &Path,
selector: &str,
candidates: &mut std::collections::BTreeSet<PathBuf>,
warnings: &mut ScanWarnings,
) -> Result<()> {
if selector_has_glob(selector) {
let pattern_path = source_root.join(selector);
let pattern_str = pattern_path.to_string_lossy().to_string();
let entries = glob(&pattern_str)
.map_err(|e| NylError::Config(format!("Invalid source selector glob '{}': {}", selector, e)))?;
for entry in entries {
match entry {
Ok(path) => collect_path_candidate(path, candidates, warnings),
Err(e) => record_scan_warning(warnings, format!("{}", e)),
}
}
return Ok(());
}
let selected = source_root.join(selector);
if !selected.exists() {
return Err(NylError::Config(format!(
"Source selector '{}' does not exist under {}",
selector,
source_root.display()
)));
}
collect_path_candidate(selected, candidates, warnings);
Ok(())
}
fn collect_path_candidate(
path: PathBuf,
candidates: &mut std::collections::BTreeSet<PathBuf>,
warnings: &mut ScanWarnings,
) {
if path.is_file() {
candidates.insert(path);
return;
}
if path.is_dir() {
let read_dir = match std::fs::read_dir(&path) {
Ok(read_dir) => read_dir,
Err(e) => {
record_scan_warning(warnings, format!("{}: {}", path.display(), e));
return;
}
};
for entry in read_dir {
match entry {
Ok(entry) => {
let child = entry.path();
if child.is_file() {
candidates.insert(child);
}
}
Err(e) => {
record_scan_warning(warnings, format!("{}: {}", path.display(), e));
}
}
}
}
}
fn record_scan_warning(warnings: &mut ScanWarnings, message: String) {
warnings.unreadable_entries += 1;
if warnings.examples.len() < 3 {
warnings.examples.push(message);
}
}
fn selector_has_glob(selector: &str) -> bool {
selector.contains('*') || selector.contains('?') || selector.contains('[')
}
fn matches_glob_patterns(relative_path: &Path, patterns: &[String]) -> Result<bool> {
let rel_posix = normalize_relative_path_to_posix(relative_path);
let file_name = relative_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
for pattern in patterns {
let glob_pattern = Pattern::new(pattern)
.map_err(|e| NylError::Config(format!("Invalid include/exclude glob pattern '{}': {}", pattern, e)))?;
let target = if pattern.contains('/') || pattern.contains('\\') {
rel_posix.as_str()
} else {
file_name
};
if glob_pattern.matches(target) {
return Ok(true);
}
}
Ok(false)
}
const NYL_CUSTOMIZATION_WARNING_NAME: &str = "nyl-release-customization-warning";
const IMMUTABLE_APPLICATION_PATH_PATTERNS: &[&str] = &[
"apiVersion",
"kind",
"metadata.name",
"metadata.namespace",
"spec.project",
"spec.source.repoURL",
"spec.source.path",
"spec.source.targetRevision",
"spec.source.plugin.name",
"spec.source.plugin.env.**",
"spec.destination.server",
"spec.destination.namespace",
];
#[derive(Debug, Clone, PartialEq, Eq)]
enum IgnoredOverrideReason {
Disallowed,
Invalid,
Unsupported,
}
impl IgnoredOverrideReason {
fn as_str(&self) -> &'static str {
match self {
Self::Disallowed => "disallowed",
Self::Invalid => "invalid",
Self::Unsupported => "unsupported",
}
}
}
#[derive(Debug, Clone)]
struct IgnoredOverride {
path: String,
reason: IgnoredOverrideReason,
}
#[derive(Debug, Clone, Copy)]
enum OverrideLeafOperation {
Append,
Replace,
}
#[derive(Debug, Clone)]
struct OverrideLeaf {
segments: Vec<String>,
path: String,
original_key: String,
value: serde_json::Value,
operation: OverrideLeafOperation,
}
impl OverrideLeaf {
fn display_path(&self) -> String {
let mut segments = self.segments.clone();
if let Some(last) = segments.last_mut() {
last.clone_from(&self.original_key);
}
join_field_path_segments(&segments)
}
}
fn create_argocd_application_from_generator(
release: &NylRelease,
file_path: &Path,
source_root: &Path,
generator: &crate::resources::ApplicationGenerator,
) -> Result<serde_json::Value> {
let rel_dir = file_path
.strip_prefix(source_root)
.unwrap_or(file_path)
.parent()
.unwrap_or(Path::new(""));
let rel_dir_normalized = normalize_relative_path_to_posix(rel_dir);
let template_input = file_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| NylError::Config(format!("Invalid file name: {}", file_path.display())))?;
let path_str = if rel_dir_normalized.is_empty() {
".".to_string()
} else {
rel_dir_normalized
};
let mut app = serde_json::json!({
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Application",
"metadata": {
"name": release.metadata.name,
"namespace": generator.spec.destination.namespace,
},
"spec": {
"project": generator.spec.project,
"source": {
"repoURL": generator.spec.source.repo_url,
"path": path_str,
"targetRevision": generator.spec.source.target_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": generator.spec.destination.server,
"namespace": release.metadata.namespace,
},
},
});
if !generator.spec.labels.is_empty() {
app["metadata"]["labels"] = serde_json::to_value(&generator.spec.labels)?;
}
if !generator.spec.annotations.is_empty() {
app["metadata"]["annotations"] = serde_json::to_value(&generator.spec.annotations)?;
}
if let Some(ref sync_policy) = generator.spec.sync_policy {
app["spec"]["syncPolicy"] = serde_json::to_value(sync_policy)?;
}
apply_release_customization_overrides(&mut app, release, generator)?;
Ok(app)
}
fn apply_release_customization_overrides(
app: &mut serde_json::Value,
release: &NylRelease,
generator: &crate::resources::ApplicationGenerator,
) -> Result<()> {
let Some(application_override) = release
.spec
.argocd
.as_ref()
.and_then(|argocd| argocd.application_override.clone())
else {
return Ok(());
};
let mut override_leaves = Vec::new();
let mut prefix = Vec::new();
flatten_override_leaves(
&serde_json::Value::Object(application_override),
&mut prefix,
&mut override_leaves,
);
if override_leaves.is_empty() {
return Ok(());
}
let mut replace_leaves = Vec::new();
let mut append_leaves = Vec::new();
let mut ignored = Vec::new();
let customization =
generator
.spec
.release_customization
.clone()
.unwrap_or(crate::resources::ReleaseCustomizationPolicy {
allowed_paths: None,
denied_paths: Vec::new(),
});
let allowed_paths = customization.effective_allowed_paths();
let denied_paths = &customization.denied_paths;
for leaf in override_leaves {
if !is_supported_application_field_path(&leaf.path) {
ignored.push(IgnoredOverride {
path: leaf.display_path(),
reason: IgnoredOverrideReason::Unsupported,
});
continue;
}
if path_matches_any(&leaf.path, IMMUTABLE_APPLICATION_PATH_PATTERNS)? {
ignored.push(IgnoredOverride {
path: leaf.display_path(),
reason: IgnoredOverrideReason::Disallowed,
});
continue;
}
let denied = path_matches_any(&leaf.path, denied_paths)?;
let allowed = path_matches_any(&leaf.path, &allowed_paths)?;
if denied || !allowed {
ignored.push(IgnoredOverride {
path: leaf.display_path(),
reason: IgnoredOverrideReason::Disallowed,
});
} else {
match leaf.operation {
OverrideLeafOperation::Replace => replace_leaves.push(leaf),
OverrideLeafOperation::Append => {
if is_supported_application_array_field_path(&leaf.path) {
append_leaves.push(leaf);
} else {
ignored.push(IgnoredOverride {
path: leaf.display_path(),
reason: IgnoredOverrideReason::Invalid,
});
}
}
}
}
}
if !replace_leaves.is_empty() {
let override_value = build_override_value(&replace_leaves);
*app = deep_merge_value(Some(app.clone()), override_value);
}
for leaf in append_leaves {
let serde_json::Value::Array(items) = &leaf.value else {
ignored.push(IgnoredOverride {
path: leaf.display_path(),
reason: IgnoredOverrideReason::Invalid,
});
continue;
};
let items = items.clone();
if let Err(reason) = append_override_items(app, &leaf.segments, items) {
ignored.push(IgnoredOverride {
path: leaf.display_path(),
reason,
});
}
}
if !ignored.is_empty() {
append_customization_warning(app, &ignored)?;
}
Ok(())
}
fn flatten_override_leaves(value: &serde_json::Value, prefix: &mut Vec<String>, leaves: &mut Vec<OverrideLeaf>) {
if let serde_json::Value::Object(map) = value {
if map.is_empty() {
return;
}
for (key, child) in map {
let (canonical_key, operation) = parse_override_key(key);
prefix.push(canonical_key);
if matches!(operation, OverrideLeafOperation::Append) {
leaves.push(OverrideLeaf {
segments: prefix.clone(),
path: join_field_path_segments(prefix),
original_key: key.clone(),
value: child.clone(),
operation: OverrideLeafOperation::Append,
});
} else {
flatten_override_leaves(child, prefix, leaves);
}
prefix.pop();
}
return;
}
leaves.push(OverrideLeaf {
segments: prefix.clone(),
path: join_field_path_segments(prefix),
original_key: prefix.last().cloned().unwrap_or_default(),
value: value.clone(),
operation: OverrideLeafOperation::Replace,
});
}
fn parse_override_key(key: &str) -> (String, OverrideLeafOperation) {
if let Some(stripped) = key.strip_prefix('+') {
if !stripped.is_empty() {
return (stripped.to_string(), OverrideLeafOperation::Append);
}
}
(key.to_string(), OverrideLeafOperation::Replace)
}
fn build_override_value(leaves: &[OverrideLeaf]) -> serde_json::Value {
let mut root = serde_json::Map::new();
for leaf in leaves {
insert_override_leaf(&mut root, &leaf.segments, leaf.value.clone());
}
serde_json::Value::Object(root)
}
fn insert_override_leaf(
root: &mut serde_json::Map<String, serde_json::Value>,
segments: &[String],
value: serde_json::Value,
) {
if segments.is_empty() {
return;
}
if segments.len() == 1 {
root.insert(segments[0].clone(), value);
return;
}
let entry = root
.entry(segments[0].clone())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if !entry.is_object() {
*entry = serde_json::Value::Object(serde_json::Map::new());
}
if let Some(map) = entry.as_object_mut() {
insert_override_leaf(map, &segments[1..], value);
}
}
fn path_matches_any(path: &str, patterns: &[impl AsRef<str>]) -> Result<bool> {
for pattern in patterns {
if path_matches_glob(path, pattern.as_ref())? {
return Ok(true);
}
}
Ok(false)
}
fn coerce_to_object(value: &mut serde_json::Value) -> std::result::Result<(), IgnoredOverrideReason> {
match value {
serde_json::Value::Object(_) => Ok(()),
serde_json::Value::Null => {
*value = serde_json::Value::Object(serde_json::Map::new());
Ok(())
}
_ => Err(IgnoredOverrideReason::Invalid),
}
}
fn append_override_items(
current: &mut serde_json::Value,
segments: &[String],
items: Vec<serde_json::Value>,
) -> std::result::Result<(), IgnoredOverrideReason> {
if segments.is_empty() {
return Err(IgnoredOverrideReason::Invalid);
}
coerce_to_object(current)?;
let map = current.as_object_mut().unwrap();
if segments.len() == 1 {
let entry = map
.entry(segments[0].clone())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
match entry {
serde_json::Value::Array(array) => {
array.extend(items);
Ok(())
}
serde_json::Value::Null => {
*entry = serde_json::Value::Array(items);
Ok(())
}
_ => Err(IgnoredOverrideReason::Invalid),
}
} else {
let entry = map
.entry(segments[0].clone())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
coerce_to_object(entry)?;
append_override_items(entry, &segments[1..], items)
}
}
fn append_customization_warning(app: &mut serde_json::Value, ignored: &[IgnoredOverride]) -> Result<()> {
let mut grouped: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
for item in ignored {
grouped.entry(item.reason.as_str()).or_default().push(item.path.clone());
}
for values in grouped.values_mut() {
values.sort();
values.dedup();
}
let summary = grouped
.iter()
.map(|(reason, paths)| format!("{}={} ({})", reason, paths.len(), summarize_paths(paths)))
.collect::<Vec<_>>()
.join("; ");
let warning_value = format!("Ignored NylRelease applicationOverride fields: {}", summary);
let spec = app
.get_mut("spec")
.and_then(|v| v.as_object_mut())
.ok_or_else(|| NylError::Config("Generated Application is missing spec".to_string()))?;
let info_value = spec
.entry("info".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if !info_value.is_array() {
let previous = std::mem::take(info_value);
*info_value = serde_json::Value::Array(vec![previous]);
}
let info_items = info_value
.as_array_mut()
.ok_or_else(|| NylError::Config("Application spec.info is not an array".to_string()))?;
let mut existing_index = None;
for (idx, item) in info_items.iter().enumerate() {
if item
.get("name")
.and_then(|v| v.as_str())
.is_some_and(|name| name == NYL_CUSTOMIZATION_WARNING_NAME)
{
existing_index = Some(idx);
break;
}
}
let warning_item = serde_json::json!({
"name": NYL_CUSTOMIZATION_WARNING_NAME,
"value": warning_value,
});
if let Some(idx) = existing_index {
info_items[idx] = warning_item;
} else {
info_items.push(warning_item);
}
Ok(())
}
fn summarize_paths(paths: &[String]) -> String {
const LIMIT: usize = 5;
if paths.len() <= LIMIT {
return paths.join(", ");
}
let head = paths.iter().take(LIMIT).cloned().collect::<Vec<_>>();
format!("{}, +{} more", head.join(", "), paths.len() - LIMIT)
}
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 add_parent_annotations(
manifest: &mut serde_json::Value,
parent_api_version: &str,
parent_kind: &str,
parent_name: &str,
parent_namespace: Option<&str>,
) {
use crate::constants::{
ANNOTATION_PARENT_API_VERSION, ANNOTATION_PARENT_KIND, ANNOTATION_PARENT_NAME, ANNOTATION_PARENT_NAMESPACE,
};
if let Some(metadata) = manifest.get_mut("metadata").and_then(|m| m.as_object_mut()) {
let annotations = metadata
.entry("annotations")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut();
if let Some(annotations) = annotations {
annotations.insert(
ANNOTATION_PARENT_API_VERSION.to_string(),
serde_json::Value::String(parent_api_version.to_string()),
);
annotations.insert(
ANNOTATION_PARENT_KIND.to_string(),
serde_json::Value::String(parent_kind.to_string()),
);
annotations.insert(
ANNOTATION_PARENT_NAME.to_string(),
serde_json::Value::String(parent_name.to_string()),
);
if let Some(ns) = parent_namespace {
annotations.insert(
ANNOTATION_PARENT_NAMESPACE.to_string(),
serde_json::Value::String(ns.to_string()),
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StripEmptyMetadataLabelsMode;
use crate::resources::{
ApplicationDestination, ApplicationGenerator, ApplicationGeneratorMetadata, ApplicationGeneratorSpec,
ApplicationSource, NylReleaseArgoCdSpec, NylReleaseMetadata, NylReleaseSpec, ReleaseCustomizationPolicy,
};
use git2::{Repository, RepositoryInitOptions, Signature};
use std::sync::{Mutex, MutexGuard};
use tempfile::TempDir;
static APPGEN_OVERRIDE_ENV_LOCK: Mutex<()> = Mutex::new(());
fn lock_appgen_override_env() -> MutexGuard<'static, ()> {
APPGEN_OVERRIDE_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn test_project_config() -> ProjectConfig {
ProjectConfig {
file: None,
config: crate::config::ProjectFile::default(),
}
}
fn create_test_worktree_paths() -> (TempDir, std::path::PathBuf, std::path::PathBuf) {
let temp = TempDir::new().unwrap();
let source_root = temp.path().join("worktree");
let file_path = source_root.join("clusters/default/addons/nginx.yaml");
std::fs::create_dir_all(file_path.parent().unwrap()).unwrap();
std::fs::write(&file_path, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n").unwrap();
(temp, source_root, file_path)
}
fn test_release_with_override(override_value: serde_json::Value) -> NylRelease {
use crate::resources::{NylReleaseArgoCdSpec, NylReleaseMetadata, NylReleaseSpec};
NylRelease {
api_version: API_VERSION.to_string(),
kind: "NylRelease".to_string(),
metadata: NylReleaseMetadata {
name: "nginx".to_string(),
namespace: "web".to_string(),
},
spec: NylReleaseSpec {
strip_empty_metadata_labels: None,
argocd: Some(NylReleaseArgoCdSpec {
application_override: Some(serde_json::from_value(override_value).unwrap()),
}),
},
}
}
fn test_application_generator(
sync_policy: Option<crate::resources::SyncPolicy>,
release_customization: Option<crate::resources::ReleaseCustomizationPolicy>,
) -> crate::resources::ApplicationGenerator {
use crate::resources::{
ApplicationDestination, ApplicationGenerator, ApplicationGeneratorMetadata, ApplicationGeneratorSpec,
ApplicationSource,
};
use std::collections::HashMap;
ApplicationGenerator {
api_version: API_VERSION_ARGOCD.to_string(),
kind: "ApplicationGenerator".to_string(),
metadata: ApplicationGeneratorMetadata {
name: "apps".to_string(),
namespace: Some("argocd".to_string()),
},
spec: ApplicationGeneratorSpec {
destination: ApplicationDestination {
server: "https://kubernetes.default.svc".to_string(),
namespace: "argocd".to_string(),
},
source: ApplicationSource {
repo_url: "https://github.com/example/repo.git".to_string(),
target_revision: "HEAD".to_string(),
path: Some("clusters/default".to_string()),
paths: None,
include: vec!["*.yaml".to_string()],
exclude: vec![".*".to_string()],
},
project: "default".to_string(),
sync_policy,
application_name_template: "{{ .release.name }}".to_string(),
labels: HashMap::new(),
annotations: HashMap::new(),
release_customization,
},
}
}
struct PwdCwdGuard {
pwd: Option<String>,
cwd: std::path::PathBuf,
}
impl PwdCwdGuard {
fn new() -> Self {
Self {
pwd: std::env::var("PWD").ok(),
cwd: std::env::current_dir().unwrap(),
}
}
}
impl Drop for PwdCwdGuard {
fn drop(&mut self) {
match &self.pwd {
Some(pwd) => std::env::set_var("PWD", pwd),
None => std::env::remove_var("PWD"),
}
let _ = std::env::set_current_dir(&self.cwd);
}
}
fn create_test_application_generator(
repo_url: &str,
target_revision: &str,
) -> crate::resources::ApplicationGenerator {
use crate::resources::{
ApplicationDestination, ApplicationGenerator, ApplicationGeneratorMetadata, ApplicationGeneratorSpec,
ApplicationSource,
};
use std::collections::HashMap;
ApplicationGenerator {
api_version: API_VERSION_ARGOCD.to_string(),
kind: "ApplicationGenerator".to_string(),
metadata: ApplicationGeneratorMetadata {
name: "apps".to_string(),
namespace: Some("argocd".to_string()),
},
spec: ApplicationGeneratorSpec {
destination: ApplicationDestination {
server: "https://kubernetes.default.svc".to_string(),
namespace: "argocd".to_string(),
},
source: ApplicationSource {
repo_url: repo_url.to_string(),
target_revision: target_revision.to_string(),
path: Some("clusters/default".to_string()),
paths: None,
include: vec!["*.yaml".to_string()],
exclude: vec![".*".to_string()],
},
project: "default".to_string(),
sync_policy: None,
application_name_template: "{{ .release.name }}".to_string(),
labels: HashMap::new(),
annotations: HashMap::new(),
release_customization: None,
},
}
}
fn create_local_git_repo(branch: &str, remote_url: &str) -> (TempDir, Repository) {
let temp = TempDir::new().unwrap();
let mut init = RepositoryInitOptions::new();
init.initial_head(branch);
let repo = Repository::init_opts(temp.path(), &init).unwrap();
std::fs::write(temp.path().join("README.md"), "test\n").unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new("README.md")).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = Signature::now("Test User", "test@example.com").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]).unwrap();
drop(tree);
let remote = repo.remote("origin", remote_url).unwrap();
drop(remote);
(temp, repo)
}
fn test_application_generator_for_warning() -> crate::resources::ApplicationGenerator {
use crate::resources::{
ApplicationDestination, ApplicationGenerator, ApplicationGeneratorMetadata, ApplicationGeneratorSpec,
ApplicationSource,
};
use std::collections::HashMap;
ApplicationGenerator {
api_version: API_VERSION_ARGOCD.to_string(),
kind: "ApplicationGenerator".to_string(),
metadata: ApplicationGeneratorMetadata {
name: "apps".to_string(),
namespace: Some("argocd".to_string()),
},
spec: ApplicationGeneratorSpec {
destination: ApplicationDestination {
server: "https://kubernetes.default.svc".to_string(),
namespace: "argocd".to_string(),
},
source: ApplicationSource {
repo_url: "https://github.com/example/repo.git".to_string(),
target_revision: "main".to_string(),
path: Some("clusters/default".to_string()),
paths: None,
include: vec!["*.yaml".to_string()],
exclude: vec![".*".to_string()],
},
project: "default".to_string(),
sync_policy: None,
application_name_template: "{{ .release.name }}".to_string(),
labels: HashMap::new(),
annotations: HashMap::new(),
release_customization: None,
},
}
}
#[test]
fn test_missing_nyl_release_warning_message_includes_counts_and_generator_name() {
let generator = test_application_generator_for_warning();
let msg = missing_nyl_release_warning_message(
&generator,
2,
5,
&[
"clusters/default/a.yaml".to_string(),
"clusters/default/b.yaml".to_string(),
],
);
assert!(msg.contains("ApplicationGenerator apps"));
assert!(msg.contains("repoURL=https://github.com/example/repo.git"));
assert!(msg.contains("targetRevision=main"));
assert!(msg.contains("source paths=clusters/default"));
assert!(msg.contains("skipped 2/5 file(s)"));
assert!(msg.contains("no NylRelease was found"));
assert!(msg.contains("clusters/default/a.yaml"));
assert!(msg.contains("clusters/default/b.yaml"));
assert!(msg.contains("Skipped files:"));
}
#[test]
fn test_missing_nyl_release_warning_message_lists_all_skipped_files() {
let generator = test_application_generator_for_warning();
let msg = missing_nyl_release_warning_message(
&generator,
4,
4,
&[
"a.yaml".to_string(),
"b.yaml".to_string(),
"c.yaml".to_string(),
"d.yaml".to_string(),
],
);
assert!(msg.contains("a.yaml"));
assert!(msg.contains("b.yaml"));
assert!(msg.contains("c.yaml"));
assert!(msg.contains("d.yaml"));
assert!(!msg.contains("Examples:"));
}
#[test]
fn test_missing_nyl_release_warning_message_without_examples() {
let generator = test_application_generator_for_warning();
let msg = missing_nyl_release_warning_message(&generator, 1, 1, &[]);
assert!(msg.contains("ApplicationGenerator apps"));
assert!(msg.contains("skipped 1/1 file(s)"));
assert!(!msg.contains("Skipped files:"));
}
#[test]
fn test_parse_yaml_documents_single() {
let yaml = r"
apiVersion: v1
kind: ConfigMap
metadata:
name: test
";
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(), 1);
assert_eq!(docs[0]["kind"], "ConfigMap");
}
#[test]
fn test_parse_yaml_documents_multiple() {
let yaml = r"
apiVersion: v1
kind: ConfigMap
metadata:
name: test1
---
apiVersion: v1
kind: Service
metadata:
name: test2
";
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"], "ConfigMap");
assert_eq!(docs[1]["kind"], "Service");
}
#[test]
fn test_filter_resources_no_filter() {
let resources = vec![
serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap"
}),
serde_json::json!({
"apiVersion": "v1",
"kind": "Service"
}),
];
let filtered = filter_resources(resources.clone(), None).unwrap();
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_filter_resources_by_kind() {
let resources = vec![
serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap"
}),
serde_json::json!({
"apiVersion": "v1",
"kind": "Service"
}),
];
let filtered = filter_resources(resources, Some("ConfigMap")).unwrap();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0]["kind"], "ConfigMap");
}
#[test]
fn test_filter_resources_by_api_kind() {
let resources = vec![
serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap"
}),
serde_json::json!({
"apiVersion": "apps/v1",
"kind": "Deployment"
}),
];
let filtered = filter_resources(resources, Some("apps/v1/Deployment")).unwrap();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0]["kind"], "Deployment");
}
#[test]
fn test_create_argocd_application_from_generator_sets_template_input() {
use crate::resources::{
ApplicationDestination, ApplicationGenerator, ApplicationGeneratorMetadata, ApplicationGeneratorSpec,
ApplicationSource, NylReleaseMetadata, NylReleaseSpec,
};
use std::collections::HashMap;
let release = NylRelease {
api_version: API_VERSION.to_string(),
kind: "NylRelease".to_string(),
metadata: NylReleaseMetadata {
name: "nginx".to_string(),
namespace: "web".to_string(),
},
spec: NylReleaseSpec::default(),
};
let generator = ApplicationGenerator {
api_version: API_VERSION_ARGOCD.to_string(),
kind: "ApplicationGenerator".to_string(),
metadata: ApplicationGeneratorMetadata {
name: "apps".to_string(),
namespace: Some("argocd".to_string()),
},
spec: ApplicationGeneratorSpec {
destination: ApplicationDestination {
server: "https://kubernetes.default.svc".to_string(),
namespace: "argocd".to_string(),
},
source: ApplicationSource {
repo_url: "https://github.com/example/repo.git".to_string(),
target_revision: "HEAD".to_string(),
path: Some("clusters/default".to_string()),
paths: None,
include: vec!["*.yaml".to_string()],
exclude: vec![".*".to_string()],
},
project: "default".to_string(),
sync_policy: None,
application_name_template: "{{ .release.name }}".to_string(),
labels: HashMap::new(),
annotations: HashMap::new(),
release_customization: None,
},
};
let (_temp, source_root, file_path) = create_test_worktree_paths();
let app = create_argocd_application_from_generator(&release, &file_path, &source_root, &generator).unwrap();
assert_eq!(app["spec"]["source"]["path"], "clusters/default/addons");
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, "nginx.yaml");
}
fn make_test_release(override_map: serde_json::Value) -> NylRelease {
NylRelease {
api_version: API_VERSION.to_string(),
kind: "NylRelease".to_string(),
metadata: NylReleaseMetadata {
name: "nginx".to_string(),
namespace: "web".to_string(),
},
spec: NylReleaseSpec {
strip_empty_metadata_labels: None,
argocd: Some(NylReleaseArgoCdSpec {
application_override: Some(serde_json::from_value(override_map).unwrap()),
}),
},
}
}
fn make_test_generator(release_customization: Option<ReleaseCustomizationPolicy>) -> ApplicationGenerator {
use std::collections::HashMap;
ApplicationGenerator {
api_version: API_VERSION_ARGOCD.to_string(),
kind: "ApplicationGenerator".to_string(),
metadata: ApplicationGeneratorMetadata {
name: "apps".to_string(),
namespace: Some("argocd".to_string()),
},
spec: ApplicationGeneratorSpec {
destination: ApplicationDestination {
server: "https://kubernetes.default.svc".to_string(),
namespace: "argocd".to_string(),
},
source: ApplicationSource {
repo_url: "https://github.com/example/repo.git".to_string(),
target_revision: "HEAD".to_string(),
path: Some("clusters/default".to_string()),
paths: None,
include: vec!["*.yaml".to_string()],
exclude: vec![".*".to_string()],
},
project: "default".to_string(),
sync_policy: None,
application_name_template: "{{ .release.name }}".to_string(),
labels: HashMap::new(),
annotations: HashMap::new(),
release_customization,
},
}
}
#[test]
fn test_release_customization_appends_warning_to_existing_info_entries() {
let release = make_test_release(serde_json::json!({
"spec": {
"info": [
{"name": "team-note", "value": "kept"}
],
"syncPolicy": {
"automated": {
"prune": true
}
}
}
}));
let generator = make_test_generator(Some(ReleaseCustomizationPolicy {
allowed_paths: Some(vec!["spec.info.**".to_string(), "spec.syncPolicy.**".to_string()]),
denied_paths: vec!["spec.syncPolicy.automated.prune".to_string()],
}));
let (_temp, source_root, file_path) = create_test_worktree_paths();
let app = create_argocd_application_from_generator(&release, &file_path, &source_root, &generator).unwrap();
assert!(app["spec"]["syncPolicy"]["automated"]["prune"].is_null());
let info = app["spec"]["info"].as_array().unwrap();
assert!(info.iter().any(|entry| entry["name"] == "team-note"));
assert!(info.iter().any(|entry| entry["name"] == NYL_CUSTOMIZATION_WARNING_NAME));
}
#[test]
fn test_release_customization_plus_sync_options_uses_canonical_path_for_denies() {
use crate::resources::{ReleaseCustomizationPolicy, SyncPolicy};
let release = test_release_with_override(serde_json::json!({
"spec": {
"syncPolicy": {
"+syncOptions": ["RespectIgnoreDifferences=false"]
}
}
}));
let generator = test_application_generator(
Some(SyncPolicy {
automated: None,
sync_options: vec!["ServerSideApply=true".to_string()],
}),
Some(ReleaseCustomizationPolicy {
allowed_paths: Some(vec!["spec.syncPolicy.**".to_string()]),
denied_paths: vec!["spec.syncPolicy.syncOptions".to_string()],
}),
);
let (_temp, source_root, file_path) = create_test_worktree_paths();
let app = create_argocd_application_from_generator(&release, &file_path, &source_root, &generator).unwrap();
assert_eq!(
app["spec"]["syncPolicy"]["syncOptions"],
serde_json::json!(["ServerSideApply=true"])
);
let warning = app["spec"]["info"]
.as_array()
.unwrap()
.iter()
.find(|entry| entry["name"] == NYL_CUSTOMIZATION_WARNING_NAME)
.and_then(|entry| entry["value"].as_str())
.unwrap();
assert!(warning.contains("+syncOptions"));
}
#[test]
fn test_release_customization_plus_sync_options_with_non_array_value_warns_and_ignores() {
let release = test_release_with_override(serde_json::json!({
"spec": {
"syncPolicy": {
"+syncOptions": "RespectIgnoreDifferences=false"
}
}
}));
let generator = test_application_generator(None, None);
let (_temp, source_root, file_path) = create_test_worktree_paths();
let app = create_argocd_application_from_generator(&release, &file_path, &source_root, &generator).unwrap();
assert!(app["spec"]["syncPolicy"].is_null());
let warning = app["spec"]["info"]
.as_array()
.unwrap()
.iter()
.find(|entry| entry["name"] == NYL_CUSTOMIZATION_WARNING_NAME)
.and_then(|entry| entry["value"].as_str())
.unwrap();
assert!(warning.contains("invalid"));
assert!(warning.contains("+syncOptions"));
}
#[test]
fn test_release_customization_plus_non_array_field_warns_and_ignores() {
let release = test_release_with_override(serde_json::json!({
"spec": {
"syncPolicy": {
"+automated": [{"prune": true}]
}
}
}));
let generator = test_application_generator(None, None);
let (_temp, source_root, file_path) = create_test_worktree_paths();
let app = create_argocd_application_from_generator(&release, &file_path, &source_root, &generator).unwrap();
assert!(app["spec"]["syncPolicy"]["automated"].is_null());
let warning = app["spec"]["info"]
.as_array()
.unwrap()
.iter()
.find(|entry| entry["name"] == NYL_CUSTOMIZATION_WARNING_NAME)
.and_then(|entry| entry["value"].as_str())
.unwrap();
assert!(warning.contains("+automated"));
}
#[test]
fn test_default_allowed_paths_permit_ignore_differences_and_sync_policy() {
let release = make_test_release(serde_json::json!({
"spec": {
"ignoreDifferences": [
{
"kind": "Deployment",
"jsonPointers": ["/spec/replicas"]
}
],
"syncPolicy": {
"automated": {
"selfHeal": true
}
}
}
}));
let generator = make_test_generator(Some(ReleaseCustomizationPolicy {
allowed_paths: None,
denied_paths: Vec::new(),
}));
let (_temp, source_root, file_path) = create_test_worktree_paths();
let app = create_argocd_application_from_generator(&release, &file_path, &source_root, &generator).unwrap();
assert_eq!(app["spec"]["ignoreDifferences"][0]["kind"], "Deployment");
assert_eq!(app["spec"]["ignoreDifferences"][0]["jsonPointers"][0], "/spec/replicas");
assert_eq!(app["spec"]["syncPolicy"]["automated"]["selfHeal"], true);
}
#[test]
fn test_default_allowed_paths_apply_when_customization_policy_omitted() {
let release = make_test_release(serde_json::json!({
"spec": {
"ignoreDifferences": [
{
"group": "apps",
"kind": "Deployment",
"jsonPointers": ["/spec/replicas"]
}
],
"syncPolicy": {
"automated": {
"selfHeal": true
}
}
}
}));
let generator = make_test_generator(None);
let (_temp, source_root, file_path) = create_test_worktree_paths();
let app = create_argocd_application_from_generator(&release, &file_path, &source_root, &generator).unwrap();
assert_eq!(app["spec"]["ignoreDifferences"][0]["group"], "apps");
assert_eq!(app["spec"]["ignoreDifferences"][0]["kind"], "Deployment");
assert_eq!(app["spec"]["syncPolicy"]["automated"]["selfHeal"], true);
}
#[test]
fn test_release_customization_plus_sync_options_uses_canonical_path_for_policy_checks() {
let release = test_release_with_override(serde_json::json!({
"spec": {
"syncPolicy": {
"+syncOptions": ["RespectIgnoreDifferences=false"]
}
}
}));
let generator = test_application_generator(
Some(crate::resources::SyncPolicy {
automated: None,
sync_options: vec!["ServerSideApply=true".to_string()],
}),
Some(crate::resources::ReleaseCustomizationPolicy {
allowed_paths: Some(vec!["spec.syncPolicy.syncOptions".to_string()]),
denied_paths: Vec::new(),
}),
);
let (_temp, source_root, file_path) = create_test_worktree_paths();
let app = create_argocd_application_from_generator(&release, &file_path, &source_root, &generator).unwrap();
assert_eq!(
app["spec"]["syncPolicy"]["syncOptions"],
serde_json::json!(["ServerSideApply=true", "RespectIgnoreDifferences=false"])
);
assert!(app["spec"]["info"].is_null());
}
#[test]
fn test_release_customization_invalid_plus_sync_options_warns_and_ignores_override() {
let release = test_release_with_override(serde_json::json!({
"spec": {
"syncPolicy": {
"+syncOptions": {
"bad": "value"
}
}
}
}));
let generator = test_application_generator(
Some(crate::resources::SyncPolicy {
automated: None,
sync_options: vec!["ServerSideApply=true".to_string()],
}),
None,
);
let (_temp, source_root, file_path) = create_test_worktree_paths();
let app = create_argocd_application_from_generator(&release, &file_path, &source_root, &generator).unwrap();
assert_eq!(
app["spec"]["syncPolicy"]["syncOptions"],
serde_json::json!(["ServerSideApply=true"])
);
let warning = app["spec"]["info"]
.as_array()
.unwrap()
.iter()
.find(|entry| entry["name"] == NYL_CUSTOMIZATION_WARNING_NAME)
.unwrap();
let warning_value = warning["value"].as_str().unwrap();
assert!(warning_value.contains("invalid=1"));
assert!(warning_value.contains("+syncOptions"));
}
#[test]
fn test_release_customization_plus_sync_policy_warns_when_target_is_not_a_list() {
let release = test_release_with_override(serde_json::json!({
"spec": {
"+syncPolicy": [
{"syncOptions": ["RespectIgnoreDifferences=false"]}
]
}
}));
let generator = test_application_generator(
Some(crate::resources::SyncPolicy {
automated: None,
sync_options: vec!["ServerSideApply=true".to_string()],
}),
None,
);
let (_temp, source_root, file_path) = create_test_worktree_paths();
let app = create_argocd_application_from_generator(&release, &file_path, &source_root, &generator).unwrap();
assert_eq!(
app["spec"]["syncPolicy"]["syncOptions"],
serde_json::json!(["ServerSideApply=true"])
);
let warning = app["spec"]["info"]
.as_array()
.unwrap()
.iter()
.find(|entry| entry["name"] == NYL_CUSTOMIZATION_WARNING_NAME)
.unwrap();
let warning_value = warning["value"].as_str().unwrap();
assert!(warning_value.contains("invalid=1"));
assert!(warning_value.contains("+syncPolicy"));
}
#[test]
fn test_resolve_application_generator_source_path_uses_local_override() {
use crate::resources::{
ApplicationDestination, ApplicationGenerator, ApplicationGeneratorMetadata, ApplicationGeneratorSpec,
ApplicationSource,
};
use std::collections::HashMap;
use std::fs;
use tempfile::TempDir;
let _guard = lock_appgen_override_env();
let temp = TempDir::new().unwrap();
let source_dir = temp.path().join("clusters/default");
fs::create_dir_all(&source_dir).unwrap();
std::env::set_var("NYL_APPGEN_REPO_PATH_OVERRIDE", temp.path());
let generator = ApplicationGenerator {
api_version: API_VERSION_ARGOCD.to_string(),
kind: "ApplicationGenerator".to_string(),
metadata: ApplicationGeneratorMetadata {
name: "apps".to_string(),
namespace: Some("argocd".to_string()),
},
spec: ApplicationGeneratorSpec {
destination: ApplicationDestination {
server: "https://kubernetes.default.svc".to_string(),
namespace: "argocd".to_string(),
},
source: ApplicationSource {
repo_url: "https://github.com/example/repo.git".to_string(),
target_revision: "main".to_string(),
path: Some("clusters/default".to_string()),
paths: None,
include: vec!["*.yaml".to_string()],
exclude: vec![".*".to_string()],
},
project: "default".to_string(),
sync_policy: None,
application_name_template: "{{ .release.name }}".to_string(),
labels: HashMap::new(),
annotations: HashMap::new(),
release_customization: None,
},
};
let resolved = resolve_application_generator_source_path(&generator, None).unwrap();
assert_eq!(resolved, temp.path());
std::env::remove_var("NYL_APPGEN_REPO_PATH_OVERRIDE");
}
#[test]
fn test_resolve_application_generator_source_path_reuses_local_git_repo_for_head() {
let _guard = lock_appgen_override_env();
let _pwd_guard = PwdCwdGuard::new();
std::env::remove_var("NYL_APPGEN_REPO_PATH_OVERRIDE");
let (repo_dir, _repo) = create_local_git_repo("main", "git@gitlab.com:NiklasRosenstein/config.git");
std::env::set_current_dir(repo_dir.path()).unwrap();
std::env::set_var("PWD", repo_dir.path());
let generator = create_test_application_generator("git@gitlab.com:NiklasRosenstein/config.git", "HEAD");
let resolved = resolve_application_generator_source_path(&generator, None).unwrap();
assert_eq!(
resolved.canonicalize().unwrap(),
repo_dir.path().canonicalize().unwrap()
);
}
#[test]
fn test_resolve_application_generator_source_path_reuses_local_git_repo_for_current_branch() {
let _guard = lock_appgen_override_env();
let _pwd_guard = PwdCwdGuard::new();
std::env::remove_var("NYL_APPGEN_REPO_PATH_OVERRIDE");
let (repo_dir, _repo) = create_local_git_repo("main", "git@github.com:example/repo.git");
std::env::set_current_dir(repo_dir.path()).unwrap();
std::env::set_var("PWD", repo_dir.path());
let generator = create_test_application_generator("ssh://git@github.com/example/repo", "main");
let resolved = resolve_application_generator_source_path(&generator, None).unwrap();
assert_eq!(
resolved.canonicalize().unwrap(),
repo_dir.path().canonicalize().unwrap()
);
}
#[test]
fn test_repo_root_path_returns_bare_repo_path() {
let temp = TempDir::new().unwrap();
let bare_repo_path = temp.path().join("repo.git");
let repo = Repository::init_bare(&bare_repo_path).unwrap();
assert_eq!(
repo_root_path(&repo).unwrap().canonicalize().unwrap(),
bare_repo_path.canonicalize().unwrap()
);
}
#[test]
fn test_try_resolve_application_generator_source_from_local_git_repo_skips_on_repo_mismatch() {
let _guard = lock_appgen_override_env();
let _pwd_guard = PwdCwdGuard::new();
let (repo_dir, _repo) = create_local_git_repo("main", "git@github.com:example/repo.git");
std::env::set_current_dir(repo_dir.path()).unwrap();
std::env::set_var("PWD", repo_dir.path());
let generator = create_test_application_generator("git@github.com:example/other.git", "HEAD");
let resolved = try_resolve_application_generator_source_from_local_git_repo(&generator);
assert!(resolved.is_none());
}
#[test]
fn test_try_resolve_application_generator_source_from_local_git_repo_skips_on_target_revision_mismatch() {
let _guard = lock_appgen_override_env();
let _pwd_guard = PwdCwdGuard::new();
let (repo_dir, _repo) = create_local_git_repo("main", "git@github.com:example/repo.git");
std::env::set_current_dir(repo_dir.path()).unwrap();
std::env::set_var("PWD", repo_dir.path());
let generator = create_test_application_generator("git@github.com:example/repo.git", "develop");
let resolved = try_resolve_application_generator_source_from_local_git_repo(&generator);
assert!(resolved.is_none());
}
#[test]
fn test_try_resolve_application_generator_source_from_local_git_repo_skips_on_detached_head() {
let _guard = lock_appgen_override_env();
let _pwd_guard = PwdCwdGuard::new();
let (repo_dir, repo) = create_local_git_repo("main", "git@github.com:example/repo.git");
let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
repo.set_head_detached(head_commit.id()).unwrap();
std::env::set_current_dir(repo_dir.path()).unwrap();
std::env::set_var("PWD", repo_dir.path());
let generator = create_test_application_generator("git@github.com:example/repo.git", "main");
let resolved = try_resolve_application_generator_source_from_local_git_repo(&generator);
assert!(resolved.is_none());
}
#[test]
fn test_try_resolve_application_generator_source_from_local_git_repo_skips_outside_git_repo() {
let _guard = lock_appgen_override_env();
let _pwd_guard = PwdCwdGuard::new();
let temp = TempDir::new().unwrap();
std::env::set_current_dir(temp.path()).unwrap();
std::env::set_var("PWD", temp.path());
let generator = create_test_application_generator("git@github.com:example/repo.git", "HEAD");
let resolved = try_resolve_application_generator_source_from_local_git_repo(&generator);
assert!(resolved.is_none());
}
#[test]
fn test_resolve_application_generator_source_path_errors_when_override_root_missing() {
use crate::resources::{
ApplicationDestination, ApplicationGenerator, ApplicationGeneratorMetadata, ApplicationGeneratorSpec,
ApplicationSource,
};
use std::collections::HashMap;
let _guard = lock_appgen_override_env();
std::env::set_var("NYL_APPGEN_REPO_PATH_OVERRIDE", "/definitely/not/a/real/path");
let generator = ApplicationGenerator {
api_version: API_VERSION_ARGOCD.to_string(),
kind: "ApplicationGenerator".to_string(),
metadata: ApplicationGeneratorMetadata {
name: "apps".to_string(),
namespace: Some("argocd".to_string()),
},
spec: ApplicationGeneratorSpec {
destination: ApplicationDestination {
server: "https://kubernetes.default.svc".to_string(),
namespace: "argocd".to_string(),
},
source: ApplicationSource {
repo_url: "https://github.com/example/repo.git".to_string(),
target_revision: "main".to_string(),
path: Some("clusters/default".to_string()),
paths: None,
include: vec!["*.yaml".to_string()],
exclude: vec![".*".to_string()],
},
project: "default".to_string(),
sync_policy: None,
application_name_template: "{{ .release.name }}".to_string(),
labels: HashMap::new(),
annotations: HashMap::new(),
release_customization: None,
},
};
let err = resolve_application_generator_source_path(&generator, None).unwrap_err();
assert!(err.to_string().contains("NYL_APPGEN_REPO_PATH_OVERRIDE"));
std::env::remove_var("NYL_APPGEN_REPO_PATH_OVERRIDE");
}
#[test]
fn test_find_yaml_files_filtered_errors_when_source_selector_missing_under_override() {
use crate::resources::{
ApplicationDestination, ApplicationGenerator, ApplicationGeneratorMetadata, ApplicationGeneratorSpec,
ApplicationSource,
};
use std::collections::HashMap;
use tempfile::TempDir;
let _guard = lock_appgen_override_env();
let temp = TempDir::new().unwrap();
std::env::set_var("NYL_APPGEN_REPO_PATH_OVERRIDE", temp.path());
let generator = ApplicationGenerator {
api_version: API_VERSION_ARGOCD.to_string(),
kind: "ApplicationGenerator".to_string(),
metadata: ApplicationGeneratorMetadata {
name: "apps".to_string(),
namespace: Some("argocd".to_string()),
},
spec: ApplicationGeneratorSpec {
destination: ApplicationDestination {
server: "https://kubernetes.default.svc".to_string(),
namespace: "argocd".to_string(),
},
source: ApplicationSource {
repo_url: "https://github.com/example/repo.git".to_string(),
target_revision: "main".to_string(),
path: Some("clusters/default".to_string()),
paths: None,
include: vec!["*.yaml".to_string()],
exclude: vec![".*".to_string()],
},
project: "default".to_string(),
sync_policy: None,
application_name_template: "{{ .release.name }}".to_string(),
labels: HashMap::new(),
annotations: HashMap::new(),
release_customization: None,
},
};
let source_root = resolve_application_generator_source_path(&generator, None).unwrap();
let selectors = application_generator_source_selectors(&generator);
let err = find_yaml_files_filtered(
&source_root,
&selectors,
&generator.spec.source.include,
&generator.spec.source.exclude,
)
.unwrap_err();
assert!(err.to_string().contains("does not exist"));
std::env::remove_var("NYL_APPGEN_REPO_PATH_OVERRIDE");
}
#[test]
fn test_resolve_application_generator_source_path_falls_back_to_git_resolution() {
use crate::resources::{
ApplicationDestination, ApplicationGenerator, ApplicationGeneratorMetadata, ApplicationGeneratorSpec,
ApplicationSource,
};
use git2::Repository;
use std::collections::HashMap;
use std::fs;
use tempfile::TempDir;
let _guard = lock_appgen_override_env();
std::env::remove_var("NYL_APPGEN_REPO_PATH_OVERRIDE");
let repo_dir = TempDir::new().unwrap();
let repo = Repository::init(repo_dir.path()).unwrap();
fs::create_dir_all(repo_dir.path().join("clusters/default")).unwrap();
fs::write(
repo_dir.path().join("clusters/default/example.yaml"),
"apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n",
)
.unwrap();
let mut index = repo.index().unwrap();
index
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
.unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]).unwrap();
let generator = ApplicationGenerator {
api_version: API_VERSION_ARGOCD.to_string(),
kind: "ApplicationGenerator".to_string(),
metadata: ApplicationGeneratorMetadata {
name: "apps".to_string(),
namespace: Some("argocd".to_string()),
},
spec: ApplicationGeneratorSpec {
destination: ApplicationDestination {
server: "https://kubernetes.default.svc".to_string(),
namespace: "argocd".to_string(),
},
source: ApplicationSource {
repo_url: repo_dir.path().to_string_lossy().to_string(),
target_revision: "HEAD".to_string(),
path: Some("clusters/default".to_string()),
paths: None,
include: vec!["*.yaml".to_string()],
exclude: vec![".*".to_string()],
},
project: "default".to_string(),
sync_policy: None,
application_name_template: "{{ .release.name }}".to_string(),
labels: HashMap::new(),
annotations: HashMap::new(),
release_customization: None,
},
};
let resolved = resolve_application_generator_source_path(&generator, None).unwrap();
assert!(resolved.exists());
assert!(resolved.join("clusters/default/example.yaml").exists());
}
#[test]
fn test_resolve_override_root_path_prefers_pwd_for_relative_paths() {
use tempfile::TempDir;
let _guard = lock_appgen_override_env();
let temp = TempDir::new().unwrap();
let repo = temp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
std::env::set_var("PWD", temp.path());
let resolved = resolve_override_root_path("NYL_APPGEN_REPO_PATH_OVERRIDE", "repo").unwrap();
assert_eq!(resolved.canonicalize().unwrap(), repo.canonicalize().unwrap());
std::env::remove_var("PWD");
}
#[test]
fn test_resolve_override_root_path_at_git_resolves_repo_root_from_pwd() {
use git2::Repository;
use tempfile::TempDir;
let _guard = lock_appgen_override_env();
let temp = TempDir::new().unwrap();
let repo_root = temp.path().join("repo");
let nested = repo_root.join("nested/path");
std::fs::create_dir_all(&nested).unwrap();
Repository::init(&repo_root).unwrap();
std::env::set_var("PWD", &nested);
let resolved = resolve_override_root_path("NYL_APPGEN_REPO_PATH_OVERRIDE", "@git").unwrap();
assert_eq!(resolved.canonicalize().unwrap(), repo_root.canonicalize().unwrap());
std::env::remove_var("PWD");
}
#[test]
fn test_resolve_override_root_path_at_git_errors_when_pwd_not_in_repo() {
use tempfile::TempDir;
let _guard = lock_appgen_override_env();
let temp = TempDir::new().unwrap();
std::env::set_var("PWD", temp.path());
let err = resolve_override_root_path("NYL_APPGEN_REPO_PATH_OVERRIDE", "@git").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("NYL_APPGEN_REPO_PATH_OVERRIDE"));
assert!(msg.contains("@git"));
std::env::remove_var("PWD");
}
#[cfg(unix)]
#[test]
fn test_find_yaml_files_filtered_ignores_broken_symlink_entries() {
use std::os::unix::fs::symlink;
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let root = temp.path();
let good = root.join("good.yaml");
std::fs::write(&good, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n").unwrap();
let broken_target = root.join("does-not-exist");
let broken_link = root.join("broken-link");
symlink(&broken_target, &broken_link).unwrap();
let files = find_yaml_files_filtered(root, &[".".to_string()], &["*.yaml".to_string()], &[]).unwrap();
assert!(files.contains(&good));
}
#[test]
fn test_find_yaml_files_filtered_directory_selector_is_non_recursive() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let root = temp.path();
std::fs::write(root.join("top.yaml"), "apiVersion: v1\nkind: ConfigMap\n").unwrap();
std::fs::create_dir_all(root.join("nested")).unwrap();
std::fs::write(root.join("nested/deep.yaml"), "apiVersion: v1\nkind: ConfigMap\n").unwrap();
let files = find_yaml_files_filtered(root, &[".".to_string()], &["*.yaml".to_string()], &[]).unwrap();
assert!(files.contains(&root.join("top.yaml")));
assert!(!files.contains(&root.join("nested/deep.yaml")));
}
#[test]
fn test_find_yaml_files_filtered_applies_relative_exclude_pattern() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let root = temp.path();
std::fs::write(root.join("top.yaml"), "apiVersion: v1\nkind: ConfigMap\n").unwrap();
std::fs::create_dir_all(root.join(".nyl/cache")).unwrap();
std::fs::write(
root.join(".nyl/cache/ignored.yaml"),
"apiVersion: v1\nkind: ConfigMap\n",
)
.unwrap();
let files = find_yaml_files_filtered(
root,
&["**/*.yaml".to_string()],
&["*.yaml".to_string()],
&[".nyl/**".to_string()],
)
.unwrap();
assert!(files.contains(&root.join("top.yaml")));
assert!(!files.contains(&root.join(".nyl/cache/ignored.yaml")));
}
#[test]
fn test_path_normalization_posix() {
use std::path::Path;
let rel_dir = Path::new("subdir/nested");
let rel_dir_normalized = normalize_relative_path_to_posix(rel_dir);
assert_eq!(rel_dir_normalized, "subdir/nested");
}
#[test]
fn test_path_normalization_with_join() {
use std::path::Path;
let rel_dir = Path::new("subdir").join("nested");
let rel_dir_normalized = normalize_relative_path_to_posix(&rel_dir);
assert_eq!(rel_dir_normalized, "subdir/nested");
}
#[test]
fn test_path_normalization_root() {
use std::path::Path;
let rel_dir = Path::new("");
let rel_dir_normalized = normalize_relative_path_to_posix(rel_dir);
assert_eq!(rel_dir_normalized, "");
}
#[test]
fn test_levenshtein_distance() {
assert_eq!(levenshtein_distance("", ""), 0);
assert_eq!(levenshtein_distance("abc", "abc"), 0);
assert_eq!(levenshtein_distance("abc", "abd"), 1);
assert_eq!(levenshtein_distance("abc", "abcd"), 1);
assert_eq!(levenshtein_distance("abc", "def"), 3);
assert_eq!(
levenshtein_distance("nyl.niklasrosenstein.github.com", "nyl.niklasrosenstein.github.com"),
0
);
assert_eq!(
levenshtein_distance("nyl.niklasrosenstein.github.com", "nyl.nikolasrosenstein.github.com"),
1
);
}
#[test]
fn test_add_parent_annotations() {
use crate::constants::{
ANNOTATION_PARENT_API_VERSION, ANNOTATION_PARENT_KIND, ANNOTATION_PARENT_NAME, ANNOTATION_PARENT_NAMESPACE,
};
let mut manifest = serde_json::json!({
"apiVersion": "v1",
"kind": "Pod",
"metadata": {
"name": "test-pod",
"namespace": "default"
},
"spec": {
"containers": []
}
});
add_parent_annotations(
&mut manifest,
"nyl.niklasrosenstein.github.com/v1",
"HelmChart",
"my-chart",
Some("default"),
);
let annotations = manifest["metadata"]["annotations"].as_object().unwrap();
assert_eq!(
annotations
.get(ANNOTATION_PARENT_API_VERSION)
.unwrap()
.as_str()
.unwrap(),
"nyl.niklasrosenstein.github.com/v1"
);
assert_eq!(
annotations.get(ANNOTATION_PARENT_KIND).unwrap().as_str().unwrap(),
"HelmChart"
);
assert_eq!(
annotations.get(ANNOTATION_PARENT_NAME).unwrap().as_str().unwrap(),
"my-chart"
);
assert_eq!(
annotations.get(ANNOTATION_PARENT_NAMESPACE).unwrap().as_str().unwrap(),
"default"
);
}
#[test]
fn test_is_nyl_like_api_version_exact_match() {
assert!(is_nyl_like_api_version("nyl.niklasrosenstein.github.com/v1"));
assert!(is_nyl_like_api_version("components.nyl.niklasrosenstein.github.com/v1"));
assert!(is_nyl_like_api_version("argocd.nyl.niklasrosenstein.github.com/v1"));
}
#[test]
fn test_is_nyl_like_api_version_contains() {
assert!(is_nyl_like_api_version("nyl.niklasrosenstein.github.com/v2"));
assert!(is_nyl_like_api_version("nyl.niklasrosenstein.github.com"));
assert!(is_nyl_like_api_version("foo.nyl.niklasrosenstein.github.com/v1"));
}
#[test]
fn test_is_nyl_like_api_version_similar() {
assert!(is_nyl_like_api_version("nyl.nikolasrosenstein.github.com/v1")); assert!(is_nyl_like_api_version("nyl.niklasrosenstein.github.co/v1")); }
#[test]
fn test_is_nyl_like_api_version_not_similar() {
assert!(!is_nyl_like_api_version("v1"));
assert!(!is_nyl_like_api_version("apps/v1"));
assert!(!is_nyl_like_api_version("batch/v1"));
assert!(!is_nyl_like_api_version("argoproj.io/v1alpha1"));
}
#[test]
fn test_is_known_nyl_resource_helm_chart() {
let resource = serde_json::json!({
"apiVersion": "nyl.niklasrosenstein.github.com/v1",
"kind": "HelmChart",
"metadata": {"name": "test"},
"spec": {"chart": {"name": "nginx"}}
});
assert!(is_known_nyl_resource(&resource));
}
#[test]
fn test_is_known_nyl_resource_component() {
let resource = serde_json::json!({
"apiVersion": "components.nyl.niklasrosenstein.github.com/v1",
"kind": "example/v1/MyComponent",
"metadata": {"name": "test"},
"spec": {}
});
assert!(is_known_nyl_resource(&resource));
}
#[test]
fn test_is_known_nyl_resource_nyl_release() {
let resource = serde_json::json!({
"apiVersion": "nyl.niklasrosenstein.github.com/v1",
"kind": "NylRelease",
"metadata": {"name": "test", "namespace": "default"}
});
assert!(is_known_nyl_resource(&resource));
}
#[test]
fn test_is_known_nyl_resource_remote_manifest() {
let resource = serde_json::json!({
"apiVersion": "nyl.niklasrosenstein.github.com/v1",
"kind": "RemoteManifest",
"metadata": {"name": "test"},
"spec": {"url": "https://example.com/manifests.yaml"}
});
assert!(is_known_nyl_resource(&resource));
}
#[test]
fn test_is_known_nyl_resource_application_generator() {
let resource = serde_json::json!({
"apiVersion": "argocd.nyl.niklasrosenstein.github.com/v1",
"kind": "ApplicationGenerator",
"metadata": {"name": "test"},
"spec": {
"destination": {"server": "https://k8s", "namespace": "argocd"},
"source": {"repoURL": "https://github.com/test/repo", "path": "apps"}
}
});
assert!(is_known_nyl_resource(&resource));
}
#[test]
fn test_is_known_nyl_resource_unknown() {
let resource = serde_json::json!({
"apiVersion": "nyl.niklasrosenstein.github.com/v1",
"kind": "UnknownKind",
"metadata": {"name": "test"}
});
assert!(!is_known_nyl_resource(&resource));
}
#[test]
fn test_is_known_nyl_resource_standard_k8s() {
let resource = serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": "test"}
});
assert!(!is_known_nyl_resource(&resource));
}
#[test]
fn test_add_parent_annotations_without_namespace() {
use crate::constants::{
ANNOTATION_PARENT_API_VERSION, ANNOTATION_PARENT_KIND, ANNOTATION_PARENT_NAME, ANNOTATION_PARENT_NAMESPACE,
};
let mut manifest = serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "test-config"
}
});
add_parent_annotations(
&mut manifest,
"nyl.niklasrosenstein.github.com/v1",
"Component",
"my-component",
None,
);
let annotations = manifest["metadata"]["annotations"].as_object().unwrap();
assert_eq!(
annotations
.get(ANNOTATION_PARENT_API_VERSION)
.unwrap()
.as_str()
.unwrap(),
"nyl.niklasrosenstein.github.com/v1"
);
assert_eq!(
annotations.get(ANNOTATION_PARENT_KIND).unwrap().as_str().unwrap(),
"Component"
);
assert_eq!(
annotations.get(ANNOTATION_PARENT_NAME).unwrap().as_str().unwrap(),
"my-component"
);
assert!(annotations.get(ANNOTATION_PARENT_NAMESPACE).is_none());
}
#[test]
fn test_add_parent_annotations_preserves_existing() {
use crate::constants::ANNOTATION_PARENT_API_VERSION;
let mut manifest = serde_json::json!({
"apiVersion": "v1",
"kind": "Service",
"metadata": {
"name": "test-service",
"annotations": {
"existing-annotation": "existing-value"
}
}
});
add_parent_annotations(
&mut manifest,
"nyl.niklasrosenstein.github.com/v1",
"HelmChart",
"my-chart",
None,
);
let annotations = manifest["metadata"]["annotations"].as_object().unwrap();
assert_eq!(
annotations.get("existing-annotation").unwrap().as_str().unwrap(),
"existing-value"
);
assert_eq!(
annotations
.get(ANNOTATION_PARENT_API_VERSION)
.unwrap()
.as_str()
.unwrap(),
"nyl.niklasrosenstein.github.com/v1"
);
}
#[test]
fn test_apply_parent_tracking_annotations_remote_manifest() {
use crate::constants::{ANNOTATION_PARENT_KIND, ANNOTATION_PARENT_NAME, ANNOTATION_PARENT_NAMESPACE};
let manifests = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": "cm"}
})];
let manifests = apply_parent_tracking_annotations(
manifests,
true,
"nyl.niklasrosenstein.github.com/v1",
"RemoteManifest",
"remote-a",
Some("apps"),
);
let annotations = manifests[0]["metadata"]["annotations"].as_object().unwrap();
assert_eq!(
annotations.get(ANNOTATION_PARENT_KIND).unwrap().as_str().unwrap(),
"RemoteManifest"
);
assert_eq!(
annotations.get(ANNOTATION_PARENT_NAME).unwrap().as_str().unwrap(),
"remote-a"
);
assert_eq!(
annotations.get(ANNOTATION_PARENT_NAMESPACE).unwrap().as_str().unwrap(),
"apps"
);
}
#[test]
fn test_needs_helm_rendering_ignores_remote_manifest() {
let config = test_project_config();
let resources = vec![serde_json::json!({
"apiVersion": "nyl.niklasrosenstein.github.com/v1",
"kind": "RemoteManifest",
"metadata": {"name": "remote"},
"spec": {"url": "https://example.com/manifest.yaml"}
})];
assert!(!needs_helm_rendering(&resources, &config));
}
#[test]
fn test_needs_helm_rendering_detects_helm_chart() {
let config = test_project_config();
let resources = vec![serde_json::json!({
"apiVersion": "nyl.niklasrosenstein.github.com/v1",
"kind": "HelmChart",
"metadata": {"name": "chart"},
"spec": {"chart": {"name": "nginx"}}
})];
assert!(needs_helm_rendering(&resources, &config));
}
#[test]
fn test_is_renderable_resource_helm_chart() {
let config = test_project_config();
let resource = serde_json::json!({
"apiVersion": "nyl.niklasrosenstein.github.com/v1",
"kind": "HelmChart",
"metadata": {"name": "test"}
});
assert!(is_renderable_resource(&resource, &config));
}
#[test]
fn test_is_renderable_resource_component() {
let config = test_project_config();
let resource = serde_json::json!({
"apiVersion": "components.nyl.niklasrosenstein.github.com/v1",
"kind": "example/v1/Nginx",
"metadata": {"name": "test"}
});
assert!(is_renderable_resource(&resource, &config));
}
#[test]
fn test_is_renderable_resource_component_shortcut() {
let config = test_project_config();
let resource = serde_json::json!({
"apiVersion": "components.nyl.niklasrosenstein.github.com/v1",
"kind": "https://charts.example.com/repo#nginx@1.0.0",
"metadata": {"name": "test"}
});
assert!(is_renderable_resource(&resource, &config));
}
#[test]
fn test_is_renderable_resource_remote_manifest() {
let config = test_project_config();
let resource = serde_json::json!({
"apiVersion": "nyl.niklasrosenstein.github.com/v1",
"kind": "RemoteManifest",
"metadata": {"name": "test"},
"spec": {"url": "https://example.com/manifests.yaml"}
});
assert!(is_renderable_resource(&resource, &config));
}
#[test]
fn test_is_renderable_resource_alias() {
let mut config = test_project_config();
config.config.project.aliases.insert(
"myapi.io/v1/MyKind".to_string(),
"oci://registry-1.docker.io/bitnamicharts/nginx@18.2.4".to_string(),
);
let resource = serde_json::json!({
"apiVersion": "myapi.io/v1",
"kind": "MyKind",
"metadata": {"name": "test"}
});
assert!(is_renderable_resource(&resource, &config));
}
#[test]
fn test_is_renderable_resource_plain_k8s() {
let config = test_project_config();
let resource = serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": "test"}
});
assert!(!is_renderable_resource(&resource, &config));
}
#[tokio::test]
async fn test_generate_resource_remote_manifest_rejects_http_url() {
let config = test_project_config();
let context = TemplateContext {
values: serde_json::json!({}),
secrets: serde_json::json!({}),
profile: "default".to_string(),
};
let resource = serde_json::json!({
"apiVersion": "nyl.niklasrosenstein.github.com/v1",
"kind": "RemoteManifest",
"metadata": {"name": "remote"},
"spec": {"url": "http://example.com/manifests.yaml"}
});
let result = generate_resource(&resource, &context, &config, "", &[], None, false).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("https://"));
}
#[test]
fn test_override_fetched_manifest_namespaces_overwrites_existing_namespace() {
let mut manifests = vec![
serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": "cm", "namespace": "old"}
}),
serde_json::json!({
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {"name": "dep"}
}),
];
override_fetched_manifest_namespaces(&mut manifests, Some("target"));
assert_eq!(manifests[0]["metadata"]["namespace"], "target");
assert!(manifests[1]["metadata"]["namespace"].is_null());
}
#[test]
fn test_override_fetched_manifest_namespaces_does_not_add_metadata() {
let mut manifests = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap"
})];
override_fetched_manifest_namespaces(&mut manifests, Some("target"));
assert!(manifests[0].get("metadata").is_none());
}
#[test]
fn test_override_fetched_manifest_namespaces_no_namespace_hint_is_noop() {
let original = serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": "cm", "namespace": "old"}
});
let mut manifests = vec![original.clone()];
override_fetched_manifest_namespaces(&mut manifests, None);
assert_eq!(manifests[0], original);
}
#[test]
fn test_override_fetched_manifest_namespaces_rewrites_cluster_role_binding_subject_namespaces() {
let mut manifests = vec![serde_json::json!({
"apiVersion": "rbac.authorization.k8s.io/v1",
"kind": "ClusterRoleBinding",
"metadata": {"name": "bind"},
"subjects": [
{"kind": "ServiceAccount", "name": "sa-a", "namespace": "old-a"},
{"kind": "ServiceAccount", "name": "sa-b"},
{"kind": "User", "name": "alice"}
],
"roleRef": {"apiGroup": "rbac.authorization.k8s.io", "kind": "ClusterRole", "name": "view"}
})];
override_fetched_manifest_namespaces(&mut manifests, Some("target"));
let subjects = manifests[0]["subjects"].as_array().unwrap();
assert_eq!(subjects[0]["namespace"], "target");
assert_eq!(subjects[1]["namespace"], "target");
assert!(subjects[2]["namespace"].is_null());
}
#[test]
fn test_override_fetched_manifest_namespaces_rewrites_role_binding_subject_namespaces() {
let mut manifests = vec![serde_json::json!({
"apiVersion": "rbac.authorization.k8s.io/v1",
"kind": "RoleBinding",
"metadata": {"name": "bind", "namespace": "source"},
"subjects": [
{"kind": "ServiceAccount", "name": "sa-a", "namespace": "old-a"},
{"kind": "ServiceAccount", "name": "sa-b"},
{"kind": "User", "name": "alice"}
],
"roleRef": {"apiGroup": "rbac.authorization.k8s.io", "kind": "Role", "name": "view"}
})];
override_fetched_manifest_namespaces(&mut manifests, Some("target"));
let subjects = manifests[0]["subjects"].as_array().unwrap();
assert_eq!(subjects[0]["namespace"], "target");
assert_eq!(subjects[1]["namespace"], "target");
assert!(subjects[2]["namespace"].is_null());
}
#[test]
fn test_normalize_emitted_manifests_strips_empty_metadata_labels() {
let mut manifests = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "cm",
"labels": {}
}
})];
normalize_emitted_manifests(&mut manifests);
let metadata = manifests[0]["metadata"].as_object().unwrap();
assert!(!metadata.contains_key("labels"));
}
#[test]
fn test_normalize_emitted_manifests_preserves_non_empty_metadata_labels() {
let mut manifests = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "cm",
"labels": {"app": "demo"}
}
})];
normalize_emitted_manifests(&mut manifests);
assert_eq!(manifests[0]["metadata"]["labels"]["app"], "demo");
}
#[test]
fn test_normalize_emitted_manifests_leaves_missing_metadata_unchanged() {
let original = serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap"
});
let mut manifests = vec![original.clone()];
normalize_emitted_manifests(&mut manifests);
assert_eq!(manifests[0], original);
}
#[test]
fn test_normalize_emitted_manifests_leaves_non_object_labels_unchanged() {
let original = serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "cm",
"labels": "unexpected"
}
});
let mut manifests = vec![original.clone()];
normalize_emitted_manifests(&mut manifests);
assert_eq!(manifests[0], original);
}
#[test]
fn test_normalize_emitted_manifests_removes_empty_labels_from_emitted_yaml() {
let original = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "cm",
"labels": {}
}
})];
let manifests = prepare_manifests_for_output(&original, true);
let yaml = crate::yaml::serialize_yaml_document(&manifests[0]).unwrap();
assert!(!yaml.contains("labels: {}"));
assert!(!yaml.contains("labels:\n"));
assert!(yaml.contains("name: cm"));
assert_eq!(original[0]["metadata"]["labels"], serde_json::json!({}));
}
#[test]
fn test_prepare_manifests_for_output_preserves_original_manifests() {
let original = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "cm",
"labels": {}
}
})];
let emitted = prepare_manifests_for_output(&original, true);
assert_eq!(original[0]["metadata"]["labels"], serde_json::json!({}));
assert!(emitted[0]["metadata"]["labels"].is_null());
}
#[test]
fn test_prepare_manifests_for_output_preserves_empty_labels_when_disabled() {
let original = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "cm",
"labels": {}
}
})];
let emitted = prepare_manifests_for_output(&original, false);
assert_eq!(emitted[0]["metadata"]["labels"], serde_json::json!({}));
}
#[test]
fn test_resolve_strip_empty_metadata_labels_mode_uses_project_default() {
assert_eq!(
resolve_strip_empty_metadata_labels_mode(StripEmptyMetadataLabelsMode::Argocd, None),
StripEmptyMetadataLabelsMode::Argocd
);
}
#[test]
fn test_resolve_strip_empty_metadata_labels_mode_release_override_takes_precedence() {
let release = NylRelease {
api_version: API_VERSION.to_string(),
kind: "NylRelease".to_string(),
metadata: NylReleaseMetadata {
name: "nginx".to_string(),
namespace: "web".to_string(),
},
spec: NylReleaseSpec {
strip_empty_metadata_labels: Some(StripEmptyMetadataLabelsMode::Never),
argocd: Some(NylReleaseArgoCdSpec {
application_override: None,
}),
},
};
assert_eq!(
resolve_strip_empty_metadata_labels_mode(StripEmptyMetadataLabelsMode::Always, Some(&release)),
StripEmptyMetadataLabelsMode::Never
);
}
#[test]
fn test_strip_empty_metadata_labels_mode_should_strip_respects_argocd_environment() {
assert!(StripEmptyMetadataLabelsMode::Always.should_strip(false));
assert!(!StripEmptyMetadataLabelsMode::Never.should_strip(true));
assert!(StripEmptyMetadataLabelsMode::Argocd.should_strip(true));
assert!(!StripEmptyMetadataLabelsMode::Argocd.should_strip(false));
}
#[test]
fn test_should_resolve_namespaces_online_with_missing_namespace() {
let manifests = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "ServiceAccount",
"metadata": {"name": "sa"}
})];
assert!(should_resolve_namespaces(&manifests, false));
}
#[test]
fn test_should_not_resolve_namespaces_offline() {
let manifests = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "ServiceAccount",
"metadata": {"name": "sa"}
})];
assert!(!should_resolve_namespaces(&manifests, true));
}
#[test]
fn test_should_not_resolve_namespaces_when_all_namespaces_present() {
let manifests = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "ServiceAccount",
"metadata": {"name": "sa", "namespace": "default"}
})];
assert!(!should_resolve_namespaces(&manifests, false));
}
#[test]
fn test_should_not_resolve_namespaces_for_known_cluster_scoped_resources() {
let manifests = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {"name": "infra"}
})];
assert!(!should_resolve_namespaces(&manifests, false));
}
#[test]
fn test_should_initialize_cluster_clients_offline() {
assert!(!should_initialize_cluster_clients(
true,
ClusterClientRequirement::Required,
true,
&[],
true
));
}
#[test]
fn test_should_initialize_cluster_clients_required() {
assert!(should_initialize_cluster_clients(
false,
ClusterClientRequirement::Required,
false,
&[],
false
));
}
#[test]
fn test_should_initialize_cluster_clients_for_namespace_resolution() {
let manifests = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "ServiceAccount",
"metadata": {"name": "sa"}
})];
assert!(should_initialize_cluster_clients(
false,
ClusterClientRequirement::OnDemand,
true,
&manifests,
false
));
}
#[test]
fn test_should_not_initialize_cluster_clients_for_cluster_scoped_resources() {
let manifests = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {"name": "infra"}
})];
assert!(!should_initialize_cluster_clients(
false,
ClusterClientRequirement::OnDemand,
true,
&manifests,
false
));
}
#[test]
fn test_should_not_initialize_cluster_clients_when_not_needed() {
let manifests = vec![serde_json::json!({
"apiVersion": "v1",
"kind": "ServiceAccount",
"metadata": {"name": "sa", "namespace": "default"}
})];
assert!(!should_initialize_cluster_clients(
false,
ClusterClientRequirement::OnDemand,
true,
&manifests,
false
));
}
#[test]
fn test_should_initialize_cluster_clients_for_duplicate_adjustment() {
assert!(should_initialize_cluster_clients(
false,
ClusterClientRequirement::OnDemand,
false,
&[],
true
));
}
#[test]
fn test_split_yaml_documents_single() {
let raw = "apiVersion: v1\nkind: ConfigMap\n";
let docs = split_yaml_documents(raw);
assert_eq!(docs.len(), 1);
assert!(docs[0].contains("ConfigMap"));
}
#[test]
fn test_split_yaml_documents_multiple() {
let raw = "apiVersion: v1\nkind: ConfigMap\n---\napiVersion: v1\nkind: Service\n";
let docs = split_yaml_documents(raw);
assert_eq!(docs.len(), 2);
assert!(docs[0].contains("ConfigMap"));
assert!(docs[1].contains("Service"));
}
#[test]
fn test_split_yaml_documents_leading_separator() {
let raw = "---\napiVersion: v1\nkind: ConfigMap\n";
let docs = split_yaml_documents(raw);
assert_eq!(docs.len(), 1);
assert!(docs[0].contains("ConfigMap"));
}
#[test]
fn test_best_effort_parse_yaml_documents_valid() {
let raw = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n";
let docs = best_effort_parse_yaml_documents(raw);
assert_eq!(docs.len(), 1);
assert_eq!(docs[0]["kind"], "ConfigMap");
}
#[test]
fn test_best_effort_parse_yaml_documents_skips_jinja() {
let raw = r"apiVersion: nyl.niklasrosenstein.github.com/v1
kind: NylRelease
metadata:
name: my-app
namespace: default
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ values.config_name }}
data:
key: {{ values.some_value }}
";
let docs = best_effort_parse_yaml_documents(raw);
assert_eq!(docs.len(), 1);
assert_eq!(docs[0]["kind"], "NylRelease");
assert_eq!(docs[0]["metadata"]["name"], "my-app");
}
#[test]
fn test_best_effort_parse_yaml_documents_all_invalid() {
let raw = "key: {{ values.foo }}\n---\nother: {{ values.bar }}\n";
let docs = best_effort_parse_yaml_documents(raw);
assert!(docs.is_empty());
}
#[test]
fn test_disable_automated_sync() {
let mut app = serde_json::json!({
"spec": {
"syncPolicy": {
"automated": {
"prune": true,
"selfHeal": true
},
"syncOptions": ["CreateNamespace=true"]
}
}
});
disable_automated_sync(&mut app);
assert!(app["spec"]["syncPolicy"]["automated"].is_null());
assert_eq!(app["spec"]["syncPolicy"]["syncOptions"][0], "CreateNamespace=true");
}
#[test]
fn test_disable_automated_sync_no_sync_policy() {
let mut app = serde_json::json!({
"spec": {}
});
disable_automated_sync(&mut app);
assert!(app["spec"]["syncPolicy"].is_null());
}
#[test]
fn test_append_render_error_info() {
let mut app = serde_json::json!({
"spec": {}
});
let file_path = "test/file.yaml";
append_render_error_info(&mut app, file_path, "undefined variable 'foo'").unwrap();
let info = app["spec"]["info"].as_array().unwrap();
assert_eq!(info.len(), 1);
assert_eq!(info[0]["name"], "nyl-render-error");
assert!(info[0]["value"].as_str().unwrap().contains("undefined variable"));
assert!(info[0]["value"].as_str().unwrap().contains("test/file.yaml"));
}
#[test]
fn test_append_render_error_info_preserves_existing() {
let mut app = serde_json::json!({
"spec": {
"info": [
{"name": "existing", "value": "entry"}
]
}
});
let file_path = "test/file.yaml";
append_render_error_info(&mut app, file_path, "error").unwrap();
let info = app["spec"]["info"].as_array().unwrap();
assert_eq!(info.len(), 2);
assert_eq!(info[0]["name"], "existing");
assert_eq!(info[1]["name"], "nyl-render-error");
}
}