diff --git a/manifest/generate.go b/manifest/generate.go index d76eb00e..f975570b 100644 --- a/manifest/generate.go +++ b/manifest/generate.go @@ -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) + } + + // 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 @@ -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) +} + 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) diff --git a/manifest/generate_test.go b/manifest/generate_test.go index e4fef573..4f04ca34 100644 --- a/manifest/generate_test.go +++ b/manifest/generate_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -807,3 +808,409 @@ func TestClientSideFallbackAlwaysFallsBackOnMethodNotAllowed(t *testing.T) { assert.Equal(t, ThreeWayMergeClient, fallback.currentMode()) assert.False(t, reviewed, "a 405 is unambiguous, so it is not worth an authorization review") } + +// TestCreatePatchForUnstructured tests the three-way merge implementation for +// unstructured objects (CRDs, CRs). This tests the fix for issue #917 where +// manual changes to CRs were not being detected. +func TestCreatePatchForUnstructured(t *testing.T) { + tests := []struct { + name string + original runtime.Object + current runtime.Object + target *resource.Info + expectedChange bool + description string + }{ + { + name: "CR with manual annotation in current (chart doesn't change anything)", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 10, + }, + }, + }, + current: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + "annotations": map[string]interface{}{ + "manual-change": "true", + }, + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 10, + }, + }, + }, + target: &resource.Info{ + Mapping: &meta.RESTMapping{ + GroupVersionKind: schema.GroupVersionKind{ + Group: "keda.sh", + Version: "v1alpha1", + Kind: "ScaledObject", + }, + }, + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 10, + }, + }, + }, + }, + expectedChange: false, + description: "Manual annotation not present in chart should be preserved; no effective change expected from three-way merge", + }, + { + name: "CR with no manual changes", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 10, + }, + }, + }, + current: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 10, + }, + }, + }, + target: &resource.Info{ + Mapping: &meta.RESTMapping{ + GroupVersionKind: schema.GroupVersionKind{ + Group: "keda.sh", + Version: "v1alpha1", + Kind: "ScaledObject", + }, + }, + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 10, + }, + }, + }, + }, + expectedChange: false, + description: "No changes should result in empty patch", + }, + { + name: "CR with chart change and manual change on different fields", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 10, + "minReplicaCount": 1, + }, + }, + }, + current: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 30, + "minReplicaCount": 2, + }, + }, + }, + target: &resource.Info{ + Mapping: &meta.RESTMapping{ + GroupVersionKind: schema.GroupVersionKind{ + Group: "keda.sh", + Version: "v1alpha1", + Kind: "ScaledObject", + }, + }, + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 20, + "minReplicaCount": 1, + }, + }, + }, + }, + expectedChange: true, + description: "Chart changes maxReplicaCount, manual changes minReplicaCount - should merge", + }, + { + name: "CR with field unchanged in chart but modified in live state (issue #917)", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 10, + }, + }, + }, + current: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 30, + }, + }, + }, + target: &resource.Info{ + Mapping: &meta.RESTMapping{ + GroupVersionKind: schema.GroupVersionKind{ + Group: "keda.sh", + Version: "v1alpha1", + Kind: "ScaledObject", + }, + }, + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 10, + }, + }, + }, + }, + expectedChange: true, + description: "Field maxReplicaCount is 10 in both original and target, but 30 in current - should detect drift", + }, + { + name: "CR with chart overriding manual change", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 10, + }, + }, + }, + current: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 30, + }, + }, + }, + target: &resource.Info{ + Mapping: &meta.RESTMapping{ + GroupVersionKind: schema.GroupVersionKind{ + Group: "keda.sh", + Version: "v1alpha1", + Kind: "ScaledObject", + }, + }, + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "keda.sh/v1alpha1", + "kind": "ScaledObject", + "metadata": map[string]interface{}{ + "name": "test-so", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "maxReplicaCount": 20, + }, + }, + }, + }, + expectedChange: true, + description: "Chart explicitly changes maxReplicaCount from 10 to 20, overriding manual value of 30", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + patch, err := createPatch(tt.original, tt.current, tt.target) + require.NoError(t, err, "createPatch should not return an error") + require.Equal(t, types.MergePatchType, patch.patchType, "patch type should be MergePatchType for unstructured objects") + + t.Logf("Patch result: %s", string(patch.data)) + + if tt.expectedChange { + require.NotEmpty(t, patch.data, tt.description+": expected patch to detect changes, got empty patch") + require.NotEqual(t, []byte("{}"), patch.data, tt.description+": expected patch to detect changes, got empty patch") + require.NotEqual(t, []byte("null"), patch.data, tt.description+": expected patch to detect changes, got null patch") + } else { + // No changes expected: patch must be empty or effectively empty ("{}" or "null") + if len(patch.data) == 0 || string(patch.data) == "{}" || string(patch.data) == "null" { + return + } + require.Failf(t, tt.description+": expected no changes, got unexpected patch", "unexpected patch: %s", string(patch.data)) + } + }) + } +} + +// TestCreatePatchForCRD tests the three-way merge implementation for CRD objects. +// This ensures the isCRD branch in createPatch is properly covered. +func TestCreatePatchForCRD(t *testing.T) { + originalCRD := &apiextv1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.k8s.io/v1", + Kind: "CustomResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "crds.example.com", + }, + Spec: apiextv1.CustomResourceDefinitionSpec{ + Group: "example.com", + Names: apiextv1.CustomResourceDefinitionNames{ + Plural: "crds", + Singular: "crd", + Kind: "Crd", + }, + Versions: []apiextv1.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + }, + }, + }, + } + + currentCRD := &apiextv1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.k8s.io/v1", + Kind: "CustomResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "crds.example.com", + }, + Spec: apiextv1.CustomResourceDefinitionSpec{ + Group: "example.com", + Names: apiextv1.CustomResourceDefinitionNames{ + Plural: "crds", + Singular: "crd", + Kind: "Crd", + }, + Versions: []apiextv1.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + }, + }, + }, + } + + targetCRD := &apiextv1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.k8s.io/v1", + Kind: "CustomResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "crds.example.com", + }, + Spec: apiextv1.CustomResourceDefinitionSpec{ + Group: "example.com", + Names: apiextv1.CustomResourceDefinitionNames{ + Plural: "crds", + Singular: "crd", + Kind: "Crd", + }, + Versions: []apiextv1.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + }, + }, + }, + } + + target := &resource.Info{ + Mapping: &meta.RESTMapping{ + GroupVersionKind: schema.GroupVersionKind{ + Group: "apiextensions.k8s.io", + Version: "v1", + Kind: "CustomResourceDefinition", + }, + }, + Object: targetCRD, + } + + patch, err := createPatch(originalCRD, currentCRD, target) + require.NoError(t, err, "createPatch should not return an error for CRD") + require.Equal(t, types.MergePatchType, patch.patchType, "patch type should be MergePatchType for CRD objects") + + t.Logf("CRD Patch result: %s", string(patch.data)) + + require.Equal(t, "{}", string(patch.data), "CRD with no changes should result in empty patch") +} diff --git a/manifest/util.go b/manifest/util.go index 151220b2..4bf7dd81 100644 --- a/manifest/util.go +++ b/manifest/util.go @@ -6,16 +6,23 @@ import ( jsoniter "github.com/json-iterator/go" ) -func deleteStatusAndTidyMetadata(obj []byte) (map[string]interface{}, error) { - var objectMap map[string]interface{} +func deleteStatusAndTidyMetadata(obj []byte) (map[string]any, error) { + var objectMap map[string]any err := jsoniter.Unmarshal(obj, &objectMap) if err != nil { return nil, fmt.Errorf("could not unmarshal byte sequence: %w", err) } + if objectMap == nil { + return nil, nil + } + delete(objectMap, "status") - metadata := objectMap["metadata"].(map[string]interface{}) + metadata, ok := objectMap["metadata"].(map[string]any) + if !ok { + return objectMap, nil + } delete(metadata, "managedFields") delete(metadata, "generation") diff --git a/manifest/util_test.go b/manifest/util_test.go index bfe552f3..86118d11 100644 --- a/manifest/util_test.go +++ b/manifest/util_test.go @@ -10,7 +10,7 @@ func Test_deleteStatusAndTidyMetadata(t *testing.T) { tests := []struct { name string obj []byte - want map[string]interface{} + want map[string]any wantErr bool }{ { @@ -19,6 +19,12 @@ func Test_deleteStatusAndTidyMetadata(t *testing.T) { want: nil, wantErr: true, }, + { + name: "null json", + obj: []byte("null"), + want: nil, + wantErr: false, + }, { name: "valid json", obj: []byte(` @@ -61,11 +67,11 @@ func Test_deleteStatusAndTidyMetadata(t *testing.T) { } } `), - want: map[string]interface{}{ + want: map[string]any{ "apiVersion": "apps/v1", "kind": "Deployment", - "metadata": map[string]interface{}{ - "annotations": map[string]interface{}{ + "metadata": map[string]any{ + "annotations": map[string]any{ "other-annot": "value", }, "labels": map[string]interface{}{ @@ -74,11 +80,11 @@ func Test_deleteStatusAndTidyMetadata(t *testing.T) { "name": "nginx-deployment", "namespace": "test-ns", }, - "spec": map[string]interface{}{ - "template": map[string]interface{}{ - "spec": map[string]interface{}{ - "containers": []interface{}{ - map[string]interface{}{ + "spec": map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ + "containers": []any{ + map[string]any{ "image": "nginx:1.14.2", "imagePullPolicy": "IfNotPresent", "name": "nginx", diff --git a/scripts/issues/917.sh b/scripts/issues/917.sh new file mode 100755 index 00000000..da8c30b6 --- /dev/null +++ b/scripts/issues/917.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# Reproduction test for https://github.com/databus23/helm-diff/issues/917 +# +# Bug: "helm diff --three-way-merge does not detect manual changes made to +# Custom Resources (CRDs and CRs) in the cluster". +# +# Unstructured objects fell back to a JSON merge patch built from the old and +# the new release manifest only, so the live object was ignored and manual +# changes (drift) never showed up in the diff. +# +# Scenarios (each prints its full diff output to the CI log): +# A. --three-way-merge of a clean, unchanged release reports no drift +# B. manual change on a CR field the chart owns is detected +# (also without patch permissions, via --three-way-merge-mode client, +# and NOT detected by a plain diff, which never looks at the cluster) +# C. a chart change is merged with the manual change, not masked by it +# D. manual change on the CRD itself is detected +# +# In addition, no scenario may print an error or a diff entry for +# server-managed fields (resourceVersion), which used to break the dry-run. + +set -euo pipefail + +ISSUE=917 +# shellcheck source=lib.sh +source "$(dirname "$0")/lib.sh" + +# expect +expect() { + local scenario="$1" desc="$2" pattern="$3" want="$4" got + local out="$WORK/${scenario// /_}.out" + strip_ansi < "$out" > "$WORK/stripped.out" + if grep -qE "$pattern" "$WORK/stripped.out"; then + got=present + else + got=absent + fi + if [ "$got" = "$want" ]; then + echo "OK: [$scenario] $desc is $want" + else + echo "FAIL: [$scenario] expected $desc to be $want (#${ISSUE})" + FAIL=1 + fi +} + +kubectl create namespace "$NS" 2>/dev/null || true + +CRD_YAML='apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.example.com +spec: + group: example.com + names: + plural: widgets + singular: widget + kind: Widget + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + x-kubernetes-preserve-unknown-fields: true +' + +CR_TMPL='apiVersion: example.com/v1 +kind: Widget +metadata: + name: w +spec: + size: {{ .Values.size | default "small" }} +' + +# Helm cannot build a manifest whose CR refers to a CRD that is only defined +# in the same chart ("no matches for kind Widget ... ensure CRDs are installed +# first"), so the CRD has to exist in the cluster before the install. It is +# still part of the release manifest via templates/, which is what scenario D +# relies on. Because the CRD then already exists when Helm installs the +# release, it must carry the ownership metadata Helm requires to adopt it. +kubectl apply -f <(printf '%s\n' "$CRD_YAML") >/dev/null +kubectl wait --for=condition=Established crd/widgets.example.com --timeout=60s >/dev/null +kubectl label crd widgets.example.com app.kubernetes.io/managed-by=Helm --overwrite >/dev/null +kubectl annotate crd widgets.example.com meta.helm.sh/release-name=rel meta.helm.sh/release-namespace="$NS" --overwrite >/dev/null + +chart "$WORK/a" "${CRD_YAML}--- +${CR_TMPL}" +helm upgrade -i rel "$WORK/a" -n "$NS" >/dev/null +kubectl get widget w -n "$NS" -o jsonpath='{.spec.size}'; echo " <- live CR size" + +############################################################################### +# Variant A: clean release, unchanged chart -> no drift, no errors +############################################################################### +run_diff "A clean" noassert rel "$WORK/a" -n "$NS" --three-way-merge +expect "A clean" "no diff entry for spec.size" '^[+-][[:space:]]*size:' absent +expect "A clean" "no error" '^Error:' absent + +############################################################################### +# Variant B: manual change on a CR field the chart owns must be detected +############################################################################### +kubectl patch widget w -n "$NS" --type=merge -p '{"spec":{"size":"large"}}' >/dev/null + +run_diff "B drift" noassert rel "$WORK/a" -n "$NS" --three-way-merge +expect "B drift" "diff entry for the live value (size: large)" '^[+-][[:space:]]*size: large' present +expect "B drift" "diff entry for the chart value (size: small)" '^[+-][[:space:]]*size: small' present +expect "B drift" "no error" '^Error:' absent + +# A plain diff never looks at the cluster, so it must stay silent about the +# drift (documents the difference the --three-way-merge flag makes). +run_diff "B plain" noassert rel "$WORK/a" -n "$NS" +expect "B plain" "no drift entry without --three-way-merge" '^[+-][[:space:]]*size:' absent + +# The local merge (no patch permissions needed) must detect the drift as well. +run_diff "B client" noassert rel "$WORK/a" -n "$NS" --three-way-merge --three-way-merge-mode client +expect "B client" "diff entry for the live value (size: large)" '^[+-][[:space:]]*size: large' present +expect "B client" "diff entry for the chart value (size: small)" '^[+-][[:space:]]*size: small' present +expect "B client" "no error" '^Error:' absent + +############################################################################### +# Variant C: chart change merged with the manual change +############################################################################### +run_diff "C chart-change" noassert rel "$WORK/a" -n "$NS" --three-way-merge --set size=medium +expect "C chart-change" "diff entry for the new chart value (size: medium)" '^[+-][[:space:]]*size: medium' present +expect "C chart-change" "no error" '^Error:' absent + +############################################################################### +# Variant D: manual change on the CRD itself must be detected +# +# The drift must not stop the version from being served ("served: false" +# makes the REST mapper unable to resolve kind Widget, so the new manifest +# cannot even be built), so a chart-owned names field is drifted instead. +############################################################################### +kubectl patch crd widgets.example.com --type=merge -p '{"spec":{"names":{"singular":"widg"}}}' >/dev/null + +run_diff "D crd-drift" noassert rel "$WORK/a" -n "$NS" --three-way-merge +expect "D crd-drift" "diff entry for the live value (singular: widg)" '^[+-][[:space:]]*singular: widg' present +expect "D crd-drift" "diff entry for the chart value (singular: widget)" '^[+-][[:space:]]*singular: widget' present +expect "D crd-drift" "no error" '^Error:' absent + +############################################################################### +# Server-managed fields must never show up as diff entries (the dry-run patch +# used to fail with "metadata.resourceVersion: Invalid value: 0"). +############################################################################### +echo "" +for scenario in "A clean" "B drift" "B plain" "B client" "C chart-change" "D crd-drift"; do + out="$WORK/${scenario// /_}.out" + strip_ansi < "$out" > "$WORK/stripped.out" + if grep -qE '^[+-][[:space:]]*resourceVersion:' "$WORK/stripped.out"; then + echo "FAIL: [$scenario] shows a resourceVersion diff entry (#${ISSUE})" + FAIL=1 + fi +done +if [ "$FAIL" -eq 0 ]; then + echo "OK: no resourceVersion diff entries in any scenario" +fi + +finish