use clap::{Args, ValueEnum};
use colored::Colorize;
use std::collections::{HashMap, HashSet};
use crate::{
cli::{
commands::render::{run_render_preflight, ClusterClientRequirement, RenderOptions, RenderPreflightOptions},
namespace_resolution::{adjust_duplicate_keys_for_namespace_resolution, resolve_manifest_namespaces},
},
kubernetes::{
extract_name, DiffEngine, KubeClient, KubernetesReleaseStorage, ReleaseStatus, ReleaseStorage, ResourceKey,
},
NylError, Result,
};
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub enum DiffMode {
#[default]
Normalized,
Raw,
}
#[derive(Args, Debug)]
pub struct DiffArgs {
#[command(flatten)]
pub common: RenderOptions,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub namespace: Option<String>,
#[arg(long)]
pub context: Option<String>,
#[arg(long)]
pub summary: bool,
#[arg(long, default_value = "normalized")]
pub mode: DiffMode,
#[arg(long)]
pub append_release: bool,
#[arg(long)]
pub exit_code: bool,
}
pub async fn execute(args: DiffArgs) -> Result<()> {
let preflight = run_render_preflight(RenderPreflightOptions {
common: &args.common,
offline: false,
kube_version: None,
kube_api_versions: &[],
context_override: args.context.as_deref(),
cluster_client_requirement: ClusterClientRequirement::Required,
resolve_namespaces: false,
release_namespace_hint: None,
adjust_duplicate_keys: false,
})
.await?;
let mut desired_manifests = preflight.manifests;
let nyl_release = preflight.nyl_release;
let mut duplicates = preflight.duplicates;
let kube_client = preflight
.kube_client
.ok_or_else(|| NylError::Config("Kubernetes client unavailable in online mode".to_string()))?;
let client = preflight
.raw_client
.ok_or_else(|| NylError::Config("Raw Kubernetes client unavailable in online mode".to_string()))?;
if desired_manifests.is_empty() {
tracing::info!("No manifests to diff");
return Ok(());
}
let (release_name, release_namespace) = if let Some(ref release) = nyl_release {
(release.metadata.name.clone(), release.metadata.namespace.clone())
} else {
let name = args.name.ok_or_else(|| {
NylError::Config("No NylRelease resource found. Specify --name and --namespace".to_string())
})?;
let namespace = args.namespace.ok_or_else(|| {
NylError::Config("No NylRelease resource found. Specify --name and --namespace".to_string())
})?;
(name, namespace)
};
resolve_manifest_namespaces(&kube_client, &mut desired_manifests, Some(&release_namespace)).await?;
duplicates =
adjust_duplicate_keys_for_namespace_resolution(&kube_client, &duplicates, Some(&release_namespace)).await?;
if !duplicates.is_empty() {
print_duplicate_warning(&duplicates);
}
let storage = KubernetesReleaseStorage::new(client);
let previous_release = storage.get_latest_release(&release_name, &release_namespace).await?;
if previous_release.is_none() {
tracing::warn!("{}", missing_release_warning_message(&release_namespace, &release_name));
}
let desired_manifests = if args.append_release {
if let Some(ref prev_release) = previous_release {
if prev_release.status != ReleaseStatus::Deployed {
return Err(NylError::Config(format!(
"Cannot use --append-release when previous release (revision {}) is in {:?} state. \
The previous release must be in Deployed state to safely merge resources.",
prev_release.revision, prev_release.status
)));
}
merge_with_previous_release(&kube_client, desired_manifests, prev_release).await?
} else {
tracing::info!("Append-release mode: no previous release found, showing diff as initial release");
desired_manifests
}
} else {
desired_manifests
};
let diff_result =
compute_diff_from_live(&kube_client, &desired_manifests, previous_release.as_ref(), args.mode).await?;
if !diff_result.errors.is_empty() {
for (key, error) in &diff_result.errors {
println!("{} {} {}", "✗".red().bold(), key, format!("({})", error).red());
}
println!();
}
if args.summary {
display_summary(&diff_result, &duplicates);
} else {
display_diff(&diff_result, &duplicates);
}
let total_errors = diff_result.total_error_count();
if total_errors > 0 {
tracing::error!("Diff completed with {} error(s)", total_errors);
std::process::exit(2);
}
if args.exit_code {
let has_changes =
!diff_result.added.is_empty() || !diff_result.modified.is_empty() || !diff_result.deleted.is_empty();
if has_changes {
std::process::exit(1);
}
}
Ok(())
}
pub fn extract_component_name(manifests: &[serde_json::Value]) -> Result<String> {
if manifests.is_empty() {
return Err(NylError::Config("No manifests to diff".to_string()));
}
let first = &manifests[0];
let name = extract_name(first)?;
Ok(name)
}
#[derive(Debug)]
struct DiffResult {
added: Vec<ResourceKey>,
modified: Vec<(ResourceKey, String, Option<String>)>, deleted: Vec<ResourceKey>,
unchanged: Vec<ResourceKey>,
errors: Vec<(ResourceKey, String)>, }
impl DiffResult {
fn total_error_count(&self) -> usize {
let normalization_errors = self.modified.iter().filter(|(_, _, err)| err.is_some()).count();
self.errors.len() + normalization_errors
}
}
async fn compute_diff_from_live(
client: &dyn KubeClient,
desired_manifests: &[serde_json::Value],
previous_state: Option<&crate::kubernetes::ReleaseState>,
mode: DiffMode,
) -> Result<DiffResult> {
let desired_keys: HashSet<ResourceKey> = desired_manifests
.iter()
.map(ResourceKey::from_json_value)
.collect::<Result<_>>()?;
let mut live_resources = HashMap::new();
for manifest in desired_manifests {
let key = ResourceKey::from_json_value(manifest)?;
if let Some(resource) = client
.get_resource(&key.gvk, key.namespace.as_deref(), &key.name)
.await?
{
let live_json = serde_json::to_value(&resource)?;
live_resources.insert(key.clone(), live_json);
}
}
let previous_keys: HashSet<ResourceKey> =
previous_state.map_or_else(HashSet::new, |s| s.resource_keys.iter().cloned().collect());
for key in &previous_keys {
if !desired_keys.contains(key) {
if let Some(resource) = client
.get_resource(&key.gvk, key.namespace.as_deref(), &key.name)
.await?
{
let live_json = serde_json::to_value(&resource)?;
live_resources.insert(key.clone(), live_json);
}
}
}
let mut added = Vec::new();
let mut modified = Vec::new();
let mut deleted = Vec::new();
let mut unchanged = Vec::new();
let mut errors = Vec::new();
for manifest in desired_manifests {
let key = ResourceKey::from_json_value(manifest)?;
if let Some(live) = live_resources.get(&key) {
match mode {
DiffMode::Normalized => {
match DiffEngine::are_equivalent_with_server(manifest, live, client).await {
Ok(true) => {
unchanged.push(key);
}
Ok(false) => match DiffEngine::diff_yaml_with_server(manifest, live, client).await {
Ok(diff_text) => {
modified.push((key, diff_text, None));
}
Err(e) => {
let diff_text = DiffEngine::diff_yaml(manifest, live)?;
let error_msg = format!("failed to normalize resource: {}", e);
modified.push((key, diff_text, Some(error_msg)));
}
},
Err(e) => {
let error_msg = format!("failed to normalize resource: {}", e);
match DiffEngine::are_equivalent(manifest, live) {
Ok(true) => unchanged.push(key),
Ok(false) => match DiffEngine::diff_yaml(manifest, live) {
Ok(diff_text) => modified.push((key, diff_text, Some(error_msg))),
Err(_diff_err) => {
errors.push((key, error_msg));
}
},
Err(_eq_err) => {
errors.push((key, error_msg));
}
}
}
}
}
DiffMode::Raw => {
if DiffEngine::are_equivalent(manifest, live)? {
unchanged.push(key);
} else {
let diff_text = DiffEngine::diff_yaml(manifest, live)?;
modified.push((key, diff_text, None));
}
}
}
} else {
added.push(key);
}
}
for key in previous_keys {
if !desired_keys.contains(&key) {
deleted.push(key);
}
}
Ok(DiffResult {
added,
modified,
deleted,
unchanged,
errors,
})
}
fn print_summary(diff: &DiffResult, duplicates: &HashMap<ResourceKey, usize>) {
let total_errors = diff.total_error_count();
let total_duplicates_ignored: usize = duplicates.values().map(|count| count - 1).sum();
let mut parts = vec![
format!("{} to add", diff.added.len().to_string().green()),
format!("{} to modify", diff.modified.len().to_string().yellow()),
format!("{} to delete", diff.deleted.len().to_string().red()),
format!("{} unchanged", diff.unchanged.len()),
];
if total_duplicates_ignored > 0 {
let plural = if total_duplicates_ignored == 1 {
"duplicate"
} else {
"duplicates"
};
parts.push(format!(
"{} {} ignored",
total_duplicates_ignored.to_string().bright_black(),
plural
));
}
if total_errors > 0 {
parts.push(format!("{} failed", total_errors.to_string().red()));
}
println!("Summary: {}", parts.join(", "));
}
fn display_diff(diff: &DiffResult, duplicates: &HashMap<ResourceKey, usize>) {
for key in &diff.added {
let dup_annotation = get_duplicate_annotation_for_key(key, duplicates);
println!("{} {}{}", "+".green().bold(), key, dup_annotation);
}
if !diff.added.is_empty() {
println!();
}
for (key, unified_diff, error) in &diff.modified {
let dup_annotation = get_duplicate_annotation_for_key(key, duplicates);
let error_annotation = if let Some(err) = error {
format!(" {}", format!("({})", err).red())
} else {
String::new()
};
println!("{} {}{}{}", "~".yellow().bold(), key, dup_annotation, error_annotation);
for line in unified_diff.lines() {
if line.starts_with('+') && !line.starts_with("+++") {
println!("{}", line.green());
} else if line.starts_with('-') && !line.starts_with("---") {
println!("{}", line.red());
} else if line.starts_with("@@") {
println!("{}", line.cyan());
} else {
println!("{}", line);
}
}
println!();
}
for key in &diff.deleted {
let dup_annotation = get_duplicate_annotation_for_key(key, duplicates);
println!("{} {}{}", "-".red().bold(), key, dup_annotation);
}
if !diff.deleted.is_empty() {
println!();
}
for key in &diff.unchanged {
let dup_annotation = get_duplicate_annotation_for_key(key, duplicates);
println!("{} {}{}", "=".bright_black().bold(), key, dup_annotation);
}
if !diff.unchanged.is_empty() {
println!();
}
print_summary(diff, duplicates);
}
fn display_summary(diff: &DiffResult, duplicates: &HashMap<ResourceKey, usize>) {
print_summary(diff, duplicates);
}
fn print_duplicate_warning(duplicates: &HashMap<ResourceKey, usize>) {
if duplicates.is_empty() {
return;
}
let total_unique = duplicates.len();
let total_ignored: usize = duplicates.values().map(|count| count - 1).sum();
tracing::warn!(
"Found {} unique resources with duplicates ({} total duplicates ignored, keeping last occurrence)",
total_unique,
total_ignored
);
}
fn get_duplicate_annotation_for_key(key: &ResourceKey, duplicates: &HashMap<ResourceKey, usize>) -> String {
if let Some(count) = duplicates.get(key) {
let ignored_count = count - 1;
let plural = if ignored_count == 1 { "duplicate" } else { "duplicates" };
return format!(" {}", format!("({} {} ignored)", ignored_count, plural).yellow());
}
String::new()
}
fn missing_release_warning_message(namespace: &str, release_name: &str) -> String {
format!(
"No previous release state found for {}/{}. Diff can only compare current desired resources; prune candidates cannot be determined.",
namespace, release_name
)
}
async fn merge_with_previous_release(
client: &dyn KubeClient,
mut current_manifests: Vec<serde_json::Value>,
previous_release: &crate::kubernetes::ReleaseState,
) -> Result<Vec<serde_json::Value>> {
let current_keys: HashSet<ResourceKey> = current_manifests
.iter()
.map(ResourceKey::from_json_value)
.collect::<Result<_>>()?;
let mut added_count = 0;
let mut missing_count = 0;
for prev_key in &previous_release.resource_keys {
if !current_keys.contains(prev_key) {
if let Some(resource) = client
.get_resource(&prev_key.gvk, prev_key.namespace.as_deref(), &prev_key.name)
.await?
{
let resource_json = serde_json::to_value(&resource)?;
current_manifests.push(resource_json);
added_count += 1;
} else {
tracing::debug!("Previous resource {} no longer exists in cluster, skipping", prev_key);
missing_count += 1;
}
}
}
let overlap = previous_release.resource_keys.len() - added_count - missing_count;
if overlap > 0 {
tracing::info!(
"Append-release mode: merged {} from previous + {} current ({} overlap, {} total)",
added_count,
current_keys.len(),
overlap,
current_manifests.len()
);
} else {
tracing::info!(
"Append-release mode: merged {} from previous + {} current ({} total)",
added_count,
current_keys.len(),
current_manifests.len()
);
}
Ok(current_manifests)
}
#[cfg(test)]
mod tests {
use super::missing_release_warning_message;
#[test]
fn test_missing_release_warning_message_mentions_prune_limitation() {
let msg = missing_release_warning_message("default", "demo");
assert!(msg.contains("default/demo"));
assert!(msg.contains("prune candidates cannot be determined"));
}
}