Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 77 additions & 3 deletions manifest/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -581,10 +581,69 @@ func createPatch(originalObj, currentObj runtime.Object, target *resource.Info)
}

if isUnstructured || isCRD {
// fall back to generic JSON merge patch
patch.data, err = jsonpatch.CreateMergePatch(oldData, newData)
// For unstructured objects (CRDs, CRs), we need to perform a three-way merge
// to detect manual changes made in the cluster.
//
// The approach is:
// 1. Create a patch from old -> new (chart changes)
// 2. Apply this patch to current (live state with manual changes)
// 3. Create a patch from current -> merged result
// 4. If chart changed (old != new), return step 3's patch
// 5. If chart unchanged (old == new), build desired state by applying new onto current
// (preserving live-only fields), then diff current -> desired to detect drift

// Clean metadata fields that shouldn't be compared (they're server-managed)
// This prevents "resourceVersion: Invalid value: 0" errors when dry-running patches
cleanedOldData, err := cleanMetadataForPatch(oldData)
if err != nil {
return nil, err
return nil, fmt.Errorf("cleaning old metadata: %w", err)
}
cleanedNewData, err := cleanMetadataForPatch(newData)
if err != nil {
return nil, fmt.Errorf("cleaning new metadata: %w", err)
}
cleanedCurrentData, err := cleanMetadataForPatch(currentData)
if err != nil {
return nil, fmt.Errorf("cleaning current metadata: %w", err)
}
Comment on lines +610 to +614

Copilot AI Feb 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The three-way merge implementation for unstructured/CRD objects here will still fail to detect drift when a field is unchanged between oldData and newData but modified only in the live object (the scenario described in #917 for values explicitly set in the chart). In that case jsonpatch.CreateMergePatch(oldData, newData) returns an empty patch, mergedData ends up identical to currentData, and CreateMergePatch(currentData, mergedData) also returns an empty patch, so no diff is produced even though the live state diverges from the chart. This contradicts the PR description that manual changes to CRDs/CRs are now detected "just like" built-in resources, and it means the fix does not yet cover the core bug scenario. To align behavior with the strategic three-way merge path used for built-in types, the unstructured/CRD branch should compute a patch that also captures differences between currentData and newData for chart-owned fields, even when newData equals oldData, rather than only replaying old -> new changes onto currentData.

Copilot uses AI. Check for mistakes.

// Step 1: Create patch from old -> new (what the chart wants to change)
chartChanges, err := jsonpatch.CreateMergePatch(cleanedOldData, cleanedNewData)
if err != nil {
return nil, fmt.Errorf("creating chart changes patch: %w", err)
}

// Check if chart actually changed anything
chartChanged := !isPatchEmpty(chartChanges)

if chartChanged {
// Step 2: Apply chart changes to current (merge chart changes with live state)
mergedData, err := jsonpatch.MergePatch(cleanedCurrentData, chartChanges)
if err != nil {
return nil, fmt.Errorf("applying chart changes to current: %w", err)
}

// Step 3: Create patch from current -> merged (what to apply to current)
// This patch, when applied to current, will produce the merged result
patch.data, err = jsonpatch.CreateMergePatch(cleanedCurrentData, mergedData)
if err != nil {
return nil, fmt.Errorf("creating patch from current to merged: %w", err)
}
patch.patchType = types.MergePatchType
return patch, nil
}

// Chart didn't change (old == new), but we need to detect if current diverges
// from the chart state on chart-owned fields.
// Build desired state by applying new onto current (preserves live-only additions),
// then diff current -> desired to detect drift on chart-owned fields.
desiredData, err := jsonpatch.MergePatch(cleanedCurrentData, cleanedNewData)
if err != nil {
return nil, fmt.Errorf("building desired state: %w", err)
}
patch.data, err = jsonpatch.CreateMergePatch(cleanedCurrentData, desiredData)
if err != nil {
return nil, fmt.Errorf("creating patch from current to desired: %w", err)
}
patch.patchType = types.MergePatchType
return patch, nil
Expand All @@ -604,6 +663,21 @@ func createPatch(originalObj, currentObj runtime.Object, target *resource.Info)
return patch, nil
}

func isPatchEmpty(patch []byte) bool {
return len(patch) == 0 || string(patch) == "{}" || string(patch) == "null"
}

func cleanMetadataForPatch(data []byte) ([]byte, error) {
objMap, err := deleteStatusAndTidyMetadata(data)
if err != nil {
return nil, err
}
if objMap == nil {
return []byte("null"), nil
}
return json.Marshal(objMap)
}
Comment on lines +670 to +679

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanMetadataForPatch largely overlaps with deleteStatusAndTidyMetadata (manifest/util.go) but removes a different set of metadata fields/annotations (e.g., meta.helm.sh/* and deployment.kubernetes.io/revision are handled in the other helper but not here). Having two slightly different “tidy metadata” implementations risks inconsistent behavior between patch generation and manifest rendering. Suggest reusing/extracting the existing helper (or at least aligning the exact fields removed) so the same metadata is ignored everywhere.

Copilot uses AI. Check for mistakes.

func objectKey(r *resource.Info) string {
gvk := r.Object.GetObjectKind().GroupVersionKind()
return fmt.Sprintf("%s/%s/%s/%s", gvk.GroupVersion().String(), gvk.Kind, r.Namespace, r.Name)
Expand Down
Loading
Loading