1use kube::api::{Api, DynamicObject, GroupVersionKind, ListParams};
36use kube::discovery::{ApiResource, Scope};
37use kube::{Client, Discovery};
38use serde::{Deserialize, Serialize};
39use serde_json::Value as JsonValue;
40use similar::{ChangeTag, TextDiff};
41use std::collections::{BTreeMap, HashMap, HashSet};
42
43use crate::error::{KubeError, Result};
44use crate::release::StoredRelease;
45
46const SERVER_MANAGED_FIELDS: &[&str] = &[
49 "metadata.managedFields",
50 "metadata.resourceVersion",
51 "metadata.uid",
52 "metadata.generation",
53 "metadata.creationTimestamp",
54 "metadata.selfLink",
55 "metadata.deletionTimestamp",
56 "metadata.deletionGracePeriodSeconds",
57 "metadata.ownerReferences", ];
59
60const IGNORED_ANNOTATIONS: &[&str] = &[
62 "kubectl.kubernetes.io/last-applied-configuration",
63 "deployment.kubernetes.io/revision",
64 "meta.helm.sh/release-name",
65 "meta.helm.sh/release-namespace",
66];
67
68const OPTIONALLY_IGNORED_LABELS: &[&str] = &["app.kubernetes.io/managed-by", "helm.sh/chart"];
70
71pub struct DiffEngine {
73 pub context_lines: usize,
75 pub ignore_status: bool,
77 pub ignore_paths: HashSet<String>,
79 pub ignore_management_labels: bool,
81}
82
83impl DiffEngine {
84 pub fn new() -> Self {
86 Self {
87 context_lines: 3,
88 ignore_status: true,
89 ignore_paths: HashSet::new(),
90 ignore_management_labels: true,
91 }
92 }
93
94 pub fn with_context(mut self, lines: usize) -> Self {
96 self.context_lines = lines;
97 self
98 }
99
100 pub fn include_status(mut self) -> Self {
102 self.ignore_status = false;
103 self
104 }
105
106 pub fn ignore_path(mut self, path: &str) -> Self {
108 self.ignore_paths.insert(path.to_string());
109 self
110 }
111
112 pub fn diff_releases(&self, old: &StoredRelease, new: &StoredRelease) -> DiffResult {
114 let old_resources = parse_manifest_resources(&old.manifest);
115 let new_resources = parse_manifest_resources(&new.manifest);
116
117 let mut changes = Vec::new();
118
119 for (key, new_content) in &new_resources {
121 match old_resources.get(key) {
122 Some(old_content) => {
123 let old_normalized = self.normalize_resource(old_content);
125 let new_normalized = self.normalize_resource(new_content);
126
127 if old_normalized != new_normalized {
128 changes.push(ResourceChange {
129 kind: key.kind.clone(),
130 api_version: key.api_version.clone(),
131 name: key.name.clone(),
132 namespace: key.namespace.clone(),
133 change_type: ChangeType::Modified,
134 diff: Some(self.compute_text_diff(&old_normalized, &new_normalized)),
135 is_drift: false,
136 source: DiffSource::ReleaseComparison,
137 });
138 }
139 }
140 None => {
141 changes.push(ResourceChange {
142 kind: key.kind.clone(),
143 api_version: key.api_version.clone(),
144 name: key.name.clone(),
145 namespace: key.namespace.clone(),
146 change_type: ChangeType::Added,
147 diff: Some(DiffContent::new_addition(new_content)),
148 is_drift: false,
149 source: DiffSource::ReleaseComparison,
150 });
151 }
152 }
153 }
154
155 for (key, old_content) in &old_resources {
157 if !new_resources.contains_key(key) {
158 changes.push(ResourceChange {
159 kind: key.kind.clone(),
160 api_version: key.api_version.clone(),
161 name: key.name.clone(),
162 namespace: key.namespace.clone(),
163 change_type: ChangeType::Removed,
164 diff: Some(DiffContent::new_removal(old_content)),
165 is_drift: false,
166 source: DiffSource::ReleaseComparison,
167 });
168 }
169 }
170
171 changes.sort_by(|a, b| {
173 a.kind
174 .cmp(&b.kind)
175 .then_with(|| a.namespace.cmp(&b.namespace))
176 .then_with(|| a.name.cmp(&b.name))
177 });
178
179 DiffResult {
180 old_version: old.version,
181 new_version: new.version,
182 changes,
183 has_drift: false,
184 }
185 }
186
187 pub async fn detect_drift(
192 &self,
193 release: &StoredRelease,
194 client: &Client,
195 ) -> Result<DiffResult> {
196 let manifest_resources = parse_manifest_resources(&release.manifest);
197 let mut changes = Vec::new();
198
199 let discovery = Discovery::new(client.clone())
201 .run()
202 .await
203 .map_err(|e| KubeError::Storage(format!("API discovery failed: {}", e)))?;
204
205 for (key, manifest_content) in &manifest_resources {
206 match self
207 .fetch_live_resource(client, &discovery, key, &release.namespace)
208 .await
209 {
210 Ok(Some(live_yaml)) => {
211 let manifest_normalized = self.normalize_resource(manifest_content);
213 let live_normalized = self.normalize_resource(&live_yaml);
214
215 if manifest_normalized != live_normalized {
216 changes.push(ResourceChange {
217 kind: key.kind.clone(),
218 api_version: key.api_version.clone(),
219 name: key.name.clone(),
220 namespace: key.namespace.clone(),
221 change_type: ChangeType::Modified,
222 diff: Some(
223 self.compute_text_diff(&manifest_normalized, &live_normalized),
224 ),
225 is_drift: true, source: DiffSource::ClusterDrift,
227 });
228 }
229 }
230 Ok(None) => {
231 changes.push(ResourceChange {
233 kind: key.kind.clone(),
234 api_version: key.api_version.clone(),
235 name: key.name.clone(),
236 namespace: key.namespace.clone(),
237 change_type: ChangeType::Missing,
238 diff: Some(DiffContent::new_removal(manifest_content)),
239 is_drift: true,
240 source: DiffSource::ClusterDrift,
241 });
242 }
243 Err(e) => {
244 changes.push(ResourceChange {
246 kind: key.kind.clone(),
247 api_version: key.api_version.clone(),
248 name: key.name.clone(),
249 namespace: key.namespace.clone(),
250 change_type: ChangeType::Unknown,
251 diff: None,
252 is_drift: false,
253 source: DiffSource::Error(e.to_string()),
254 });
255 }
256 }
257 }
258
259 let extra_resources = self
262 .find_extra_cluster_resources(client, &discovery, release, &manifest_resources)
263 .await?;
264
265 for (key, live_yaml) in extra_resources {
266 changes.push(ResourceChange {
267 kind: key.kind.clone(),
268 api_version: key.api_version.clone(),
269 name: key.name.clone(),
270 namespace: key.namespace.clone(),
271 change_type: ChangeType::Extra,
272 diff: Some(DiffContent::new_addition(&live_yaml)),
273 is_drift: true,
274 source: DiffSource::ClusterDrift,
275 });
276 }
277
278 changes.sort_by(|a, b| {
280 a.kind
281 .cmp(&b.kind)
282 .then_with(|| a.namespace.cmp(&b.namespace))
283 .then_with(|| a.name.cmp(&b.name))
284 });
285
286 let has_drift = changes.iter().any(|c| c.is_drift);
287
288 Ok(DiffResult {
289 old_version: release.version,
290 new_version: release.version,
291 changes,
292 has_drift,
293 })
294 }
295
296 pub async fn three_way_diff(
305 &self,
306 desired_manifest: &str,
307 last_applied: &StoredRelease,
308 client: &Client,
309 ) -> Result<ThreeWayDiffResult> {
310 let desired_resources = parse_manifest_resources(desired_manifest);
311 let last_applied_resources = parse_manifest_resources(&last_applied.manifest);
312
313 let discovery = Discovery::new(client.clone())
314 .run()
315 .await
316 .map_err(|e| KubeError::Storage(format!("API discovery failed: {}", e)))?;
317
318 let mut changes = Vec::new();
319
320 let mut all_keys: HashSet<&ResourceKey> = HashSet::new();
322 all_keys.extend(desired_resources.keys());
323 all_keys.extend(last_applied_resources.keys());
324
325 for key in all_keys {
326 let desired = desired_resources.get(key);
327 let last = last_applied_resources.get(key);
328 let live = self
329 .fetch_live_resource(client, &discovery, key, &last_applied.namespace)
330 .await
331 .ok()
332 .flatten();
333
334 let change = self.compute_three_way_change(key, desired, last, live.as_deref());
335 if let Some(c) = change {
336 changes.push(c);
337 }
338 }
339
340 changes.sort_by(|a, b| {
342 a.kind
343 .cmp(&b.kind)
344 .then_with(|| a.namespace.cmp(&b.namespace))
345 .then_with(|| a.name.cmp(&b.name))
346 });
347
348 let has_pending_changes = changes.iter().any(|c| {
349 matches!(
350 c.change_type,
351 ChangeType::Added | ChangeType::Modified | ChangeType::Removed
352 )
353 });
354 let has_drift = changes.iter().any(|c| c.is_drift);
355
356 Ok(ThreeWayDiffResult {
357 changes,
358 has_pending_changes,
359 has_drift,
360 })
361 }
362
363 fn compute_three_way_change(
365 &self,
366 key: &ResourceKey,
367 desired: Option<&String>,
368 last_applied: Option<&String>,
369 live: Option<&str>,
370 ) -> Option<ResourceChange> {
371 match (desired, last_applied, live) {
372 (Some(d), None, None) => Some(ResourceChange {
374 kind: key.kind.clone(),
375 api_version: key.api_version.clone(),
376 name: key.name.clone(),
377 namespace: key.namespace.clone(),
378 change_type: ChangeType::Added,
379 diff: Some(DiffContent::new_addition(d)),
380 is_drift: false,
381 source: DiffSource::ThreeWay,
382 }),
383
384 (None, Some(l), _) => Some(ResourceChange {
386 kind: key.kind.clone(),
387 api_version: key.api_version.clone(),
388 name: key.name.clone(),
389 namespace: key.namespace.clone(),
390 change_type: ChangeType::Removed,
391 diff: Some(DiffContent::new_removal(l)),
392 is_drift: false,
393 source: DiffSource::ThreeWay,
394 }),
395
396 (Some(d), Some(l), live_opt) => {
398 let d_norm = self.normalize_resource(d);
399 let l_norm = self.normalize_resource(l);
400
401 let will_change = d_norm != l_norm;
402 let has_drift = live_opt
403 .map(|live| self.normalize_resource(live) != l_norm)
404 .unwrap_or(false);
405
406 if will_change || has_drift {
407 let diff_target = live_opt.unwrap_or(l.as_str());
408 Some(ResourceChange {
409 kind: key.kind.clone(),
410 api_version: key.api_version.clone(),
411 name: key.name.clone(),
412 namespace: key.namespace.clone(),
413 change_type: if will_change {
414 ChangeType::Modified
415 } else {
416 ChangeType::Unchanged
417 },
418 diff: if will_change {
419 Some(
420 self.compute_text_diff(
421 &self.normalize_resource(diff_target),
422 &d_norm,
423 ),
424 )
425 } else {
426 None
427 },
428 is_drift: has_drift,
429 source: DiffSource::ThreeWay,
430 })
431 } else {
432 None }
434 }
435
436 (None, None, Some(live)) => Some(ResourceChange {
438 kind: key.kind.clone(),
439 api_version: key.api_version.clone(),
440 name: key.name.clone(),
441 namespace: key.namespace.clone(),
442 change_type: ChangeType::Extra,
443 diff: Some(DiffContent::new_addition(live)),
444 is_drift: true,
445 source: DiffSource::ThreeWay,
446 }),
447
448 (None, None, None) => None,
450
451 (Some(d), None, Some(_)) => Some(ResourceChange {
453 kind: key.kind.clone(),
454 api_version: key.api_version.clone(),
455 name: key.name.clone(),
456 namespace: key.namespace.clone(),
457 change_type: ChangeType::Added,
458 diff: Some(DiffContent::new_addition(d)),
459 is_drift: false, source: DiffSource::ThreeWay,
461 }),
462 }
463 }
464
465 async fn fetch_live_resource(
467 &self,
468 client: &Client,
469 discovery: &Discovery,
470 key: &ResourceKey,
471 default_namespace: &str,
472 ) -> Result<Option<String>> {
473 let (group, version) = parse_api_version(&key.api_version);
475
476 let gvk = GroupVersionKind::gvk(&group, &version, &key.kind);
478
479 let (ar, caps) = match discovery.resolve_gvk(&gvk) {
480 Some(r) => r,
481 None => {
482 return Err(KubeError::Storage(format!(
484 "API {}/{} {} not found in cluster",
485 group, version, key.kind
486 )));
487 }
488 };
489
490 let namespace = key.namespace.as_deref().unwrap_or(default_namespace);
491
492 let api: Api<DynamicObject> = if caps.scope == Scope::Cluster {
494 Api::all_with(client.clone(), &ar)
495 } else {
496 Api::namespaced_with(client.clone(), namespace, &ar)
497 };
498
499 match api.get(&key.name).await {
501 Ok(obj) => {
502 let yaml = serde_yaml::to_string(&obj)
504 .map_err(|e| KubeError::Serialization(e.to_string()))?;
505 Ok(Some(yaml))
506 }
507 Err(kube::Error::Api(e)) if e.code == 404 => Ok(None),
508 Err(e) => Err(KubeError::Storage(format!(
509 "Failed to fetch {}/{}: {}",
510 key.kind, key.name, e
511 ))),
512 }
513 }
514
515 async fn find_extra_cluster_resources(
517 &self,
518 client: &Client,
519 _discovery: &Discovery,
520 release: &StoredRelease,
521 manifest_resources: &HashMap<ResourceKey, String>,
522 ) -> Result<Vec<(ResourceKey, String)>> {
523 let mut extra = Vec::new();
524
525 let label_selector = format!(
527 "app.kubernetes.io/managed-by=sherpack,sherpack.io/release-name={}",
528 release.name
529 );
530
531 let resource_types = [
534 ("v1", "ConfigMap"),
535 ("v1", "Secret"),
536 ("v1", "Service"),
537 ("apps/v1", "Deployment"),
538 ("apps/v1", "StatefulSet"),
539 ("apps/v1", "DaemonSet"),
540 ("batch/v1", "Job"),
541 ("batch/v1", "CronJob"),
542 ];
543
544 for (api_version, kind) in resource_types {
545 let ar = match kind {
546 "ConfigMap" => ApiResource::erase::<k8s_openapi::api::core::v1::ConfigMap>(&()),
547 "Secret" => ApiResource::erase::<k8s_openapi::api::core::v1::Secret>(&()),
548 "Service" => ApiResource::erase::<k8s_openapi::api::core::v1::Service>(&()),
549 "Deployment" => ApiResource::erase::<k8s_openapi::api::apps::v1::Deployment>(&()),
550 "StatefulSet" => ApiResource::erase::<k8s_openapi::api::apps::v1::StatefulSet>(&()),
551 "DaemonSet" => ApiResource::erase::<k8s_openapi::api::apps::v1::DaemonSet>(&()),
552 "Job" => ApiResource::erase::<k8s_openapi::api::batch::v1::Job>(&()),
553 "CronJob" => ApiResource::erase::<k8s_openapi::api::batch::v1::CronJob>(&()),
554 _ => continue,
555 };
556
557 let api: Api<DynamicObject> =
558 Api::namespaced_with(client.clone(), &release.namespace, &ar);
559
560 let lp = ListParams::default().labels(&label_selector);
561
562 match api.list(&lp).await {
563 Ok(list) => {
564 for obj in list.items {
565 let name = obj.metadata.name.clone().unwrap_or_default();
566 let namespace = obj.metadata.namespace.clone();
567
568 let key = ResourceKey {
569 api_version: api_version.to_string(),
570 kind: kind.to_string(),
571 name: name.clone(),
572 namespace,
573 };
574
575 if !manifest_resources.contains_key(&key) {
577 let yaml = serde_yaml::to_string(&obj).unwrap_or_default();
578 extra.push((key, yaml));
579 }
580 }
581 }
582 Err(_) => continue, }
584 }
585
586 Ok(extra)
587 }
588
589 fn normalize_resource(&self, content: &str) -> String {
591 let mut value: JsonValue = match serde_yaml::from_str(content) {
593 Ok(v) => v,
594 Err(_) => return content.to_string(),
595 };
596
597 self.strip_server_managed_fields(&mut value);
599
600 self.strip_ignored_annotations(&mut value);
602
603 if self.ignore_management_labels {
605 self.strip_management_labels(&mut value);
606 }
607
608 if self.ignore_status
610 && let Some(obj) = value.as_object_mut()
611 {
612 obj.remove("status");
613 }
614
615 for path in &self.ignore_paths {
617 self.remove_json_path(&mut value, path);
618 }
619
620 serde_yaml::to_string(&value).unwrap_or_else(|_| content.to_string())
622 }
623
624 fn strip_server_managed_fields(&self, value: &mut JsonValue) {
626 for path in SERVER_MANAGED_FIELDS {
627 self.remove_json_path(value, path);
628 }
629 }
630
631 fn strip_ignored_annotations(&self, value: &mut JsonValue) {
633 if let Some(metadata) = value.get_mut("metadata").and_then(|m| m.as_object_mut())
634 && let Some(annotations) = metadata
635 .get_mut("annotations")
636 .and_then(|a| a.as_object_mut())
637 {
638 for annotation in IGNORED_ANNOTATIONS {
639 annotations.remove(*annotation);
640 }
641 if annotations.is_empty() {
643 metadata.remove("annotations");
644 }
645 }
646 }
647
648 fn strip_management_labels(&self, value: &mut JsonValue) {
650 if let Some(metadata) = value.get_mut("metadata").and_then(|m| m.as_object_mut())
651 && let Some(labels) = metadata.get_mut("labels").and_then(|l| l.as_object_mut())
652 {
653 for label in OPTIONALLY_IGNORED_LABELS {
654 labels.remove(*label);
655 }
656 }
657 }
658
659 fn remove_json_path(&self, value: &mut JsonValue, path: &str) {
661 let parts: Vec<&str> = path.split('.').collect();
662 Self::remove_path_recursive(value, &parts);
663 }
664
665 fn remove_path_recursive(value: &mut JsonValue, path: &[&str]) {
666 if path.is_empty() {
667 return;
668 }
669
670 if path.len() == 1 {
671 if let Some(obj) = value.as_object_mut() {
672 obj.remove(path[0]);
673 }
674 return;
675 }
676
677 if let Some(obj) = value.as_object_mut()
678 && let Some(child) = obj.get_mut(path[0])
679 {
680 Self::remove_path_recursive(child, &path[1..]);
681 }
682 }
683
684 fn compute_text_diff(&self, old: &str, new: &str) -> DiffContent {
686 let diff = TextDiff::from_lines(old, new);
687 let mut lines = Vec::new();
688
689 for change in diff.iter_all_changes() {
690 let line_type = match change.tag() {
691 ChangeTag::Delete => LineType::Removed,
692 ChangeTag::Insert => LineType::Added,
693 ChangeTag::Equal => LineType::Context,
694 };
695
696 lines.push(DiffLine {
697 line_type,
698 content: change.value().trim_end().to_string(),
699 old_line_no: change.old_index(),
700 new_line_no: change.new_index(),
701 });
702 }
703
704 DiffContent { lines }
705 }
706
707 pub fn summary(&self, result: &DiffResult) -> String {
709 let added = result
710 .changes
711 .iter()
712 .filter(|c| c.change_type == ChangeType::Added)
713 .count();
714 let modified = result
715 .changes
716 .iter()
717 .filter(|c| c.change_type == ChangeType::Modified)
718 .count();
719 let removed = result
720 .changes
721 .iter()
722 .filter(|c| c.change_type == ChangeType::Removed)
723 .count();
724 let drift = result.changes.iter().filter(|c| c.is_drift).count();
725 let missing = result
726 .changes
727 .iter()
728 .filter(|c| c.change_type == ChangeType::Missing)
729 .count();
730 let extra = result
731 .changes
732 .iter()
733 .filter(|c| c.change_type == ChangeType::Extra)
734 .count();
735
736 let mut parts = Vec::new();
737
738 if added > 0 {
739 parts.push(format!("{} added", added));
740 }
741 if modified > 0 {
742 parts.push(format!("{} modified", modified));
743 }
744 if removed > 0 {
745 parts.push(format!("{} removed", removed));
746 }
747 if missing > 0 {
748 parts.push(format!("{} missing", missing));
749 }
750 if extra > 0 {
751 parts.push(format!("{} extra", extra));
752 }
753 if drift > 0 {
754 parts.push(format!("{} drifted", drift));
755 }
756
757 if parts.is_empty() {
758 "No changes".to_string()
759 } else {
760 parts.join(", ")
761 }
762 }
763
764 pub fn format_colored(&self, result: &DiffResult) -> String {
766 use console::{Style, style};
767
768 let mut output = String::new();
769
770 if result.old_version != result.new_version {
772 output.push_str(&format!(
773 "{}\n\n",
774 style(format!(
775 "Comparing release v{} → v{}",
776 result.old_version, result.new_version
777 ))
778 .bold()
779 ));
780 } else if result.has_drift {
781 output.push_str(&format!(
782 "{}\n\n",
783 style(format!(
784 "Drift detected in release v{} vs cluster state",
785 result.old_version
786 ))
787 .bold()
788 .yellow()
789 ));
790 }
791
792 output.push_str(&format!("{}\n\n", self.summary(result)));
794
795 let mut by_type: BTreeMap<ChangeType, Vec<&ResourceChange>> = BTreeMap::new();
797 for change in &result.changes {
798 by_type.entry(change.change_type).or_default().push(change);
799 }
800
801 for (change_type, changes) in by_type {
803 let (header_style, symbol) = match change_type {
804 ChangeType::Added => (Style::new().green().bold(), "+"),
805 ChangeType::Modified => (Style::new().yellow().bold(), "~"),
806 ChangeType::Removed => (Style::new().red().bold(), "-"),
807 ChangeType::Missing => (Style::new().red().bold(), "?"),
808 ChangeType::Extra => (Style::new().cyan().bold(), "!"),
809 ChangeType::Unchanged => (Style::new().dim(), "="),
810 ChangeType::Unknown => (Style::new().dim(), "?"),
811 };
812
813 output.push_str(&format!(
814 "{}\n",
815 header_style.apply_to(format!("=== {} ({}) ===", change_type, changes.len()))
816 ));
817
818 for change in changes {
819 let drift_marker = if change.is_drift {
820 style(" [DRIFT]").yellow().to_string()
821 } else {
822 String::new()
823 };
824
825 output.push_str(&format!(
826 " {} {}{}\n",
827 style(symbol).bold(),
828 change.display_name(),
829 drift_marker
830 ));
831
832 if let Some(diff) = &change.diff {
834 if diff.lines.len() <= 50 {
835 for line in &diff.lines {
836 let (prefix, line_style) = match line.line_type {
837 LineType::Added => ("+", Style::new().green()),
838 LineType::Removed => ("-", Style::new().red()),
839 LineType::Context => (" ", Style::new().dim()),
840 };
841 output.push_str(&format!(
842 " {}{}\n",
843 prefix,
844 line_style.apply_to(&line.content)
845 ));
846 }
847 } else {
848 output.push_str(&format!(
849 " {} ({} lines, use --verbose for full diff)\n",
850 style("...").dim(),
851 diff.lines.len()
852 ));
853 }
854 }
855 }
856 output.push('\n');
857 }
858
859 output
860 }
861}
862
863impl Default for DiffEngine {
864 fn default() -> Self {
865 Self::new()
866 }
867}
868
869fn parse_api_version(api_version: &str) -> (String, String) {
871 if let Some((group, version)) = api_version.split_once('/') {
872 (group.to_string(), version.to_string())
873 } else {
874 (String::new(), api_version.to_string())
876 }
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize)]
881pub struct DiffResult {
882 pub old_version: u32,
884
885 pub new_version: u32,
887
888 pub changes: Vec<ResourceChange>,
890
891 pub has_drift: bool,
893}
894
895impl DiffResult {
896 pub fn has_changes(&self) -> bool {
898 !self.changes.is_empty()
899 }
900
901 pub fn changes_by_type(&self, change_type: ChangeType) -> Vec<&ResourceChange> {
903 self.changes
904 .iter()
905 .filter(|c| c.change_type == change_type)
906 .collect()
907 }
908
909 pub fn drift_changes(&self) -> Vec<&ResourceChange> {
911 self.changes.iter().filter(|c| c.is_drift).collect()
912 }
913}
914
915#[derive(Debug, Clone, Serialize, Deserialize)]
917pub struct ThreeWayDiffResult {
918 pub changes: Vec<ResourceChange>,
920
921 pub has_pending_changes: bool,
923
924 pub has_drift: bool,
926}
927
928#[derive(Debug, Clone, Serialize, Deserialize)]
930pub struct ResourceChange {
931 pub kind: String,
933
934 pub api_version: String,
936
937 pub name: String,
939
940 pub namespace: Option<String>,
942
943 pub change_type: ChangeType,
945
946 pub diff: Option<DiffContent>,
948
949 pub is_drift: bool,
951
952 pub source: DiffSource,
954}
955
956impl ResourceChange {
957 pub fn display_name(&self) -> String {
959 match &self.namespace {
960 Some(ns) => format!("{}/{}/{}", ns, self.kind, self.name),
961 None => format!("{}/{}", self.kind, self.name),
962 }
963 }
964}
965
966#[derive(Debug, Clone, Serialize, Deserialize)]
968pub enum DiffSource {
969 ReleaseComparison,
971 ClusterDrift,
973 ThreeWay,
975 Error(String),
977}
978
979#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
981#[serde(rename_all = "lowercase")]
982pub enum ChangeType {
983 Added,
985
986 Modified,
988
989 Removed,
991
992 Missing,
994
995 Extra,
997
998 Unchanged,
1000
1001 Unknown,
1003}
1004
1005impl std::fmt::Display for ChangeType {
1006 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1007 match self {
1008 ChangeType::Added => write!(f, "Added"),
1009 ChangeType::Modified => write!(f, "Modified"),
1010 ChangeType::Removed => write!(f, "Removed"),
1011 ChangeType::Missing => write!(f, "Missing"),
1012 ChangeType::Extra => write!(f, "Extra"),
1013 ChangeType::Unchanged => write!(f, "Unchanged"),
1014 ChangeType::Unknown => write!(f, "Unknown"),
1015 }
1016 }
1017}
1018
1019#[derive(Debug, Clone, Serialize, Deserialize)]
1021pub struct DiffContent {
1022 pub lines: Vec<DiffLine>,
1024}
1025
1026impl DiffContent {
1027 pub fn new_addition(content: &str) -> Self {
1029 let lines = content
1030 .lines()
1031 .enumerate()
1032 .map(|(i, line)| DiffLine {
1033 line_type: LineType::Added,
1034 content: line.to_string(),
1035 old_line_no: None,
1036 new_line_no: Some(i),
1037 })
1038 .collect();
1039
1040 Self { lines }
1041 }
1042
1043 pub fn new_removal(content: &str) -> Self {
1045 let lines = content
1046 .lines()
1047 .enumerate()
1048 .map(|(i, line)| DiffLine {
1049 line_type: LineType::Removed,
1050 content: line.to_string(),
1051 old_line_no: Some(i),
1052 new_line_no: None,
1053 })
1054 .collect();
1055
1056 Self { lines }
1057 }
1058
1059 pub fn added_count(&self) -> usize {
1061 self.lines
1062 .iter()
1063 .filter(|l| l.line_type == LineType::Added)
1064 .count()
1065 }
1066
1067 pub fn removed_count(&self) -> usize {
1069 self.lines
1070 .iter()
1071 .filter(|l| l.line_type == LineType::Removed)
1072 .count()
1073 }
1074
1075 pub fn to_unified_diff(&self) -> String {
1077 let mut output = String::new();
1078
1079 for line in &self.lines {
1080 let prefix = match line.line_type {
1081 LineType::Added => "+",
1082 LineType::Removed => "-",
1083 LineType::Context => " ",
1084 };
1085 output.push_str(prefix);
1086 output.push_str(&line.content);
1087 output.push('\n');
1088 }
1089
1090 output
1091 }
1092}
1093
1094#[derive(Debug, Clone, Serialize, Deserialize)]
1096pub struct DiffLine {
1097 pub line_type: LineType,
1099
1100 pub content: String,
1102
1103 pub old_line_no: Option<usize>,
1105
1106 pub new_line_no: Option<usize>,
1108}
1109
1110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1112#[serde(rename_all = "lowercase")]
1113pub enum LineType {
1114 Added,
1116
1117 Removed,
1119
1120 Context,
1122}
1123
1124#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1126pub struct ResourceKey {
1127 pub api_version: String,
1129 pub kind: String,
1131 pub name: String,
1133 pub namespace: Option<String>,
1135}
1136
1137pub fn parse_manifest_resources(manifest: &str) -> HashMap<ResourceKey, String> {
1139 let mut resources = HashMap::new();
1140
1141 for doc in manifest.split("---") {
1142 let doc = doc.trim();
1143 if doc.is_empty() {
1144 continue;
1145 }
1146
1147 let yaml: serde_yaml::Value = match serde_yaml::from_str(doc) {
1149 Ok(v) => v,
1150 Err(_) => continue,
1151 };
1152
1153 let api_version = yaml
1154 .get("apiVersion")
1155 .and_then(|v| v.as_str())
1156 .unwrap_or("v1")
1157 .to_string();
1158
1159 let kind = yaml
1160 .get("kind")
1161 .and_then(|v| v.as_str())
1162 .unwrap_or("Unknown")
1163 .to_string();
1164
1165 let name = yaml
1166 .get("metadata")
1167 .and_then(|m| m.get("name"))
1168 .and_then(|n| n.as_str())
1169 .unwrap_or("unnamed")
1170 .to_string();
1171
1172 let namespace = yaml
1173 .get("metadata")
1174 .and_then(|m| m.get("namespace"))
1175 .and_then(|n| n.as_str())
1176 .map(String::from);
1177
1178 let key = ResourceKey {
1179 api_version,
1180 kind,
1181 name,
1182 namespace,
1183 };
1184
1185 resources.insert(key, doc.to_string());
1186 }
1187
1188 resources
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193 use super::*;
1194
1195 fn test_release(manifest: &str) -> StoredRelease {
1196 StoredRelease {
1197 name: "test".to_string(),
1198 namespace: "default".to_string(),
1199 version: 1,
1200 state: crate::release::ReleaseState::Deployed,
1201 pack: sherpack_core::PackMetadata {
1202 name: "test".to_string(),
1203 version: semver::Version::new(1, 0, 0),
1204 description: None,
1205 app_version: None,
1206 kube_version: None,
1207 home: None,
1208 icon: None,
1209 sources: vec![],
1210 keywords: vec![],
1211 maintainers: vec![],
1212 annotations: Default::default(),
1213 },
1214 values: sherpack_core::Values::new(),
1215 values_provenance: Default::default(),
1216 manifest: manifest.to_string(),
1217 hooks: vec![],
1218 labels: Default::default(),
1219 created_at: chrono::Utc::now(),
1220 updated_at: chrono::Utc::now(),
1221 notes: None,
1222 }
1223 }
1224
1225 #[test]
1226 fn test_parse_manifest_resources() {
1227 let manifest = r#"
1228apiVersion: v1
1229kind: ConfigMap
1230metadata:
1231 name: my-config
1232 namespace: default
1233data:
1234 key: value
1235---
1236apiVersion: apps/v1
1237kind: Deployment
1238metadata:
1239 name: my-app
1240 namespace: default
1241spec:
1242 replicas: 1
1243"#;
1244
1245 let resources = parse_manifest_resources(manifest);
1246 assert_eq!(resources.len(), 2);
1247
1248 let cm_key = ResourceKey {
1249 api_version: "v1".to_string(),
1250 kind: "ConfigMap".to_string(),
1251 name: "my-config".to_string(),
1252 namespace: Some("default".to_string()),
1253 };
1254 assert!(resources.contains_key(&cm_key));
1255
1256 let deploy_key = ResourceKey {
1257 api_version: "apps/v1".to_string(),
1258 kind: "Deployment".to_string(),
1259 name: "my-app".to_string(),
1260 namespace: Some("default".to_string()),
1261 };
1262 assert!(resources.contains_key(&deploy_key));
1263 }
1264
1265 #[test]
1266 fn test_diff_releases_addition() {
1267 let engine = DiffEngine::new();
1268
1269 let old = test_release("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1");
1270 let mut new = old.clone();
1271 new.version = 2;
1272 new.manifest = format!(
1273 "{}\n---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm2",
1274 old.manifest
1275 );
1276
1277 let diff = engine.diff_releases(&old, &new);
1278
1279 assert_eq!(diff.changes.len(), 1);
1280 assert_eq!(diff.changes[0].change_type, ChangeType::Added);
1281 assert_eq!(diff.changes[0].name, "cm2");
1282 }
1283
1284 #[test]
1285 fn test_diff_releases_removal() {
1286 let engine = DiffEngine::new();
1287
1288 let old = test_release(
1289 "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1\n---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm2",
1290 );
1291 let mut new = old.clone();
1292 new.version = 2;
1293 new.manifest = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1".to_string();
1294
1295 let diff = engine.diff_releases(&old, &new);
1296
1297 assert_eq!(diff.changes.len(), 1);
1298 assert_eq!(diff.changes[0].change_type, ChangeType::Removed);
1299 assert_eq!(diff.changes[0].name, "cm2");
1300 }
1301
1302 #[test]
1303 fn test_diff_releases_modification() {
1304 let engine = DiffEngine::new();
1305
1306 let old = test_release(
1307 "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1\ndata:\n key: old-value",
1308 );
1309 let mut new = old.clone();
1310 new.version = 2;
1311 new.manifest =
1312 "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1\ndata:\n key: new-value"
1313 .to_string();
1314
1315 let diff = engine.diff_releases(&old, &new);
1316
1317 assert_eq!(diff.changes.len(), 1);
1318 assert_eq!(diff.changes[0].change_type, ChangeType::Modified);
1319 assert!(diff.changes[0].diff.is_some());
1320 }
1321
1322 #[test]
1323 fn test_normalize_strips_managed_fields() {
1324 let engine = DiffEngine::new();
1325
1326 let resource_with_managed = r#"
1327apiVersion: v1
1328kind: ConfigMap
1329metadata:
1330 name: test
1331 resourceVersion: "12345"
1332 uid: "abc-123"
1333 generation: 1
1334 creationTimestamp: "2024-01-01T00:00:00Z"
1335 managedFields:
1336 - manager: kubectl
1337 operation: Apply
1338data:
1339 key: value
1340"#;
1341
1342 let normalized = engine.normalize_resource(resource_with_managed);
1343
1344 assert!(!normalized.contains("resourceVersion"));
1346 assert!(!normalized.contains("uid"));
1347 assert!(!normalized.contains("generation"));
1348 assert!(!normalized.contains("creationTimestamp"));
1349 assert!(!normalized.contains("managedFields"));
1350
1351 assert!(normalized.contains("ConfigMap"));
1353 assert!(normalized.contains("key: value"));
1354 }
1355
1356 #[test]
1357 fn test_normalize_strips_ignored_annotations() {
1358 let engine = DiffEngine::new();
1359
1360 let resource_with_annotations = r#"
1361apiVersion: v1
1362kind: ConfigMap
1363metadata:
1364 name: test
1365 annotations:
1366 kubectl.kubernetes.io/last-applied-configuration: "{}"
1367 my-custom-annotation: "keep-this"
1368data:
1369 key: value
1370"#;
1371
1372 let normalized = engine.normalize_resource(resource_with_annotations);
1373
1374 assert!(!normalized.contains("last-applied-configuration"));
1375 assert!(normalized.contains("my-custom-annotation"));
1376 }
1377
1378 #[test]
1379 fn test_normalize_strips_status() {
1380 let engine = DiffEngine::new();
1381
1382 let resource_with_status = r#"
1383apiVersion: apps/v1
1384kind: Deployment
1385metadata:
1386 name: test
1387spec:
1388 replicas: 1
1389status:
1390 availableReplicas: 1
1391 readyReplicas: 1
1392"#;
1393
1394 let normalized = engine.normalize_resource(resource_with_status);
1395
1396 assert!(!normalized.contains("status:"));
1397 assert!(!normalized.contains("availableReplicas"));
1398 assert!(normalized.contains("replicas: 1"));
1399 }
1400
1401 #[test]
1402 fn test_diff_summary() {
1403 let engine = DiffEngine::new();
1404 let result = DiffResult {
1405 old_version: 1,
1406 new_version: 2,
1407 changes: vec![
1408 ResourceChange {
1409 kind: "ConfigMap".to_string(),
1410 api_version: "v1".to_string(),
1411 name: "cm1".to_string(),
1412 namespace: Some("default".to_string()),
1413 change_type: ChangeType::Added,
1414 diff: None,
1415 is_drift: false,
1416 source: DiffSource::ReleaseComparison,
1417 },
1418 ResourceChange {
1419 kind: "Deployment".to_string(),
1420 api_version: "apps/v1".to_string(),
1421 name: "app".to_string(),
1422 namespace: Some("default".to_string()),
1423 change_type: ChangeType::Modified,
1424 diff: None,
1425 is_drift: true,
1426 source: DiffSource::ClusterDrift,
1427 },
1428 ],
1429 has_drift: true,
1430 };
1431
1432 let summary = engine.summary(&result);
1433 assert!(summary.contains("1 added"));
1434 assert!(summary.contains("1 modified"));
1435 assert!(summary.contains("1 drifted"));
1436 }
1437
1438 #[test]
1439 fn test_parse_api_version() {
1440 assert_eq!(
1441 parse_api_version("apps/v1"),
1442 ("apps".to_string(), "v1".to_string())
1443 );
1444 assert_eq!(parse_api_version("v1"), (String::new(), "v1".to_string()));
1445 assert_eq!(
1446 parse_api_version("networking.k8s.io/v1"),
1447 ("networking.k8s.io".to_string(), "v1".to_string())
1448 );
1449 }
1450
1451 #[test]
1452 fn test_diff_content_counts() {
1453 let content = DiffContent {
1454 lines: vec![
1455 DiffLine {
1456 line_type: LineType::Removed,
1457 content: "old".to_string(),
1458 old_line_no: Some(0),
1459 new_line_no: None,
1460 },
1461 DiffLine {
1462 line_type: LineType::Added,
1463 content: "new1".to_string(),
1464 old_line_no: None,
1465 new_line_no: Some(0),
1466 },
1467 DiffLine {
1468 line_type: LineType::Added,
1469 content: "new2".to_string(),
1470 old_line_no: None,
1471 new_line_no: Some(1),
1472 },
1473 ],
1474 };
1475
1476 assert_eq!(content.added_count(), 2);
1477 assert_eq!(content.removed_count(), 1);
1478 }
1479
1480 #[test]
1481 fn test_unified_diff_output() {
1482 let content = DiffContent {
1483 lines: vec![
1484 DiffLine {
1485 line_type: LineType::Context,
1486 content: "unchanged".to_string(),
1487 old_line_no: Some(0),
1488 new_line_no: Some(0),
1489 },
1490 DiffLine {
1491 line_type: LineType::Removed,
1492 content: "old".to_string(),
1493 old_line_no: Some(1),
1494 new_line_no: None,
1495 },
1496 DiffLine {
1497 line_type: LineType::Added,
1498 content: "new".to_string(),
1499 old_line_no: None,
1500 new_line_no: Some(1),
1501 },
1502 ],
1503 };
1504
1505 let unified = content.to_unified_diff();
1506 assert!(unified.contains(" unchanged\n"));
1507 assert!(unified.contains("-old\n"));
1508 assert!(unified.contains("+new\n"));
1509 }
1510
1511 #[test]
1512 fn test_resource_display_name() {
1513 let namespaced = ResourceChange {
1514 kind: "Deployment".to_string(),
1515 api_version: "apps/v1".to_string(),
1516 name: "my-app".to_string(),
1517 namespace: Some("production".to_string()),
1518 change_type: ChangeType::Modified,
1519 diff: None,
1520 is_drift: false,
1521 source: DiffSource::ReleaseComparison,
1522 };
1523 assert_eq!(namespaced.display_name(), "production/Deployment/my-app");
1524
1525 let cluster_scoped = ResourceChange {
1526 kind: "ClusterRole".to_string(),
1527 api_version: "rbac.authorization.k8s.io/v1".to_string(),
1528 name: "admin".to_string(),
1529 namespace: None,
1530 change_type: ChangeType::Added,
1531 diff: None,
1532 is_drift: false,
1533 source: DiffSource::ReleaseComparison,
1534 };
1535 assert_eq!(cluster_scoped.display_name(), "ClusterRole/admin");
1536 }
1537
1538 #[test]
1539 fn test_changes_by_type() {
1540 let result = DiffResult {
1541 old_version: 1,
1542 new_version: 2,
1543 changes: vec![
1544 ResourceChange {
1545 kind: "ConfigMap".to_string(),
1546 api_version: "v1".to_string(),
1547 name: "cm1".to_string(),
1548 namespace: None,
1549 change_type: ChangeType::Added,
1550 diff: None,
1551 is_drift: false,
1552 source: DiffSource::ReleaseComparison,
1553 },
1554 ResourceChange {
1555 kind: "ConfigMap".to_string(),
1556 api_version: "v1".to_string(),
1557 name: "cm2".to_string(),
1558 namespace: None,
1559 change_type: ChangeType::Added,
1560 diff: None,
1561 is_drift: false,
1562 source: DiffSource::ReleaseComparison,
1563 },
1564 ResourceChange {
1565 kind: "Secret".to_string(),
1566 api_version: "v1".to_string(),
1567 name: "sec1".to_string(),
1568 namespace: None,
1569 change_type: ChangeType::Removed,
1570 diff: None,
1571 is_drift: false,
1572 source: DiffSource::ReleaseComparison,
1573 },
1574 ],
1575 has_drift: false,
1576 };
1577
1578 assert_eq!(result.changes_by_type(ChangeType::Added).len(), 2);
1579 assert_eq!(result.changes_by_type(ChangeType::Removed).len(), 1);
1580 assert_eq!(result.changes_by_type(ChangeType::Modified).len(), 0);
1581 }
1582
1583 #[test]
1584 fn test_ignore_custom_path() {
1585 let engine = DiffEngine::new().ignore_path("spec.replicas");
1586
1587 let resource = r#"
1588apiVersion: apps/v1
1589kind: Deployment
1590metadata:
1591 name: test
1592spec:
1593 replicas: 3
1594 template:
1595 spec:
1596 containers: []
1597"#;
1598
1599 let normalized = engine.normalize_resource(resource);
1600
1601 assert!(!normalized.contains("replicas"));
1603 assert!(normalized.contains("template"));
1605 }
1606}