Ctrl K
ComunidadGuías…

pkg/gitops/insights/drift_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package insights

import (
	"strings"
	"testing"

	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

// liveWith builds an Unstructured with the given last-applied-configuration
// (raw JSON string) and live spec map. Lets each test case express drift
// inputs without boilerplate.
func liveWith(lastApplied string, liveSpec map[string]any) *unstructured.Unstructured {
	obj := &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "v1",
		"kind":       "Resource",
		"metadata": map[string]any{
			"name":      "x",
			"namespace": "y",
			"annotations": map[string]any{
				kubectlLastAppliedAnnotation: lastApplied,
			},
		},
	}}
	if liveSpec != nil {
		obj.Object["spec"] = liveSpec
	}
	return obj
}

func TestComputeDrift_NoAnnotation_ReturnsNil(t *testing.T) {
	obj := &unstructured.Unstructured{Object: map[string]any{"spec": map[string]any{"a": 1}}}
	if got := computeDriftFromLastApplied(obj, nil); got != nil {
		t.Fatalf("expected nil when annotation missing, got %#v", got)
	}
}
func TestComputeDrift_InvalidJSON_ReturnsNil(t *testing.T) {
	if got := computeDriftFromLastApplied(liveWith("not-json", map[string]any{"a": 1}), nil); got != nil {
		t.Fatalf("expected nil on parse failure, got %#v", got)
	}
}

func TestComputeDrift_NoDrift_ReturnsNil(t *testing.T) {
	desired := `{"spec":{"a":1,"b":"two"}}`
	got := computeDriftFromLastApplied(liveWith(desired, map[string]any{"a": int64(1), "b": "two"}), nil)
	if got != nil {
		t.Fatalf("expected nil when desired and live match, got %d entries", len(got.Entries))
	}
}

func TestComputeDrift_KarpenterStyleSchemaMigration(t *testing.T) {
	// This is the actual case the user hit in the cluster: NodePool's
	// expireAfter moved from spec.disruption to spec.template.spec, and
	// budgets got defaulted in. Verify all three drift entries surface.
	desired := `{"spec":{"disruption":{"consolidateAfter":"30s","consolidationPolicy":"WhenEmptyOrUnderutilized","expireAfter":"720h"},"template":{"spec":{"requirements":[]}}}}`
	live := map[string]any{
		"disruption": map[string]any{
			"budgets":             []any{map[string]any{"nodes": "10%"}},
			"consolidateAfter":    "30s",
			"consolidationPolicy": "WhenEmptyOrUnderutilized",
		},
		"template": map[string]any{
			"spec": map[string]any{
				"expireAfter":  "720h",
				"requirements": []any{},
			},
		},
	}
	got := computeDriftFromLastApplied(liveWith(desired, live), nil)
	if got == nil {
		t.Fatal("expected drift, got nil")
	}
	wantPaths := map[string]DriftOp{
		"spec.disruption.expireAfter":    DriftOpRemoved,
		"spec.disruption.budgets":        DriftOpAdded,
		"spec.template.spec.expireAfter": DriftOpAdded,
	}
	for _, e := range got.Entries {
		want, ok := wantPaths[e.Path]
		if !ok {
			continue
		}
		if e.Op != want {
			t.Errorf("path %s: op = %q, want %q", e.Path, e.Op, want)
		}
		delete(wantPaths, e.Path)
	}
	for path, op := range wantPaths {
		t.Errorf("missing expected entry: %s (op=%s); entries=%v", path, op, got.Entries)
	}
}

func TestComputeDrift_ScalarChange(t *testing.T) {
	desired := `{"spec":{"replicas":3}}`
	got := computeDriftFromLastApplied(liveWith(desired, map[string]any{"replicas": int64(5)}), nil)
	if got == nil || len(got.Entries) != 1 {
		t.Fatalf("expected 1 entry, got %v", got)
	}
	e := got.Entries[0]
	if e.Path != "spec.replicas" || e.Op != DriftOpChanged {
		t.Errorf("entry = %+v, want path=spec.replicas op=changed", e)
	}
	if !strings.Contains(e.Desired, "3") || !strings.Contains(e.Live, "5") {
		t.Errorf("expected desired to contain 3 and live to contain 5, got desired=%q live=%q", e.Desired, e.Live)
	}
}

// TestComputeDrift_ArrayElementFieldChange pins that a single field changing
// inside one list element reads as that field's path — not a wall of
// serialized JSON for the whole array. This is the container-resources case:
// only spec.containers[0].resources.limits.memory changed.
func TestComputeDrift_ArrayElementFieldChange(t *testing.T) {
	desired := `{"spec":{"containers":[{"name":"app","image":"app:v1","resources":{"limits":{"memory":"128Mi"}}}]}}`
	live := map[string]any{
		"containers": []any{
			map[string]any{
				"name":      "app",
				"image":     "app:v1",
				"resources": map[string]any{"limits": map[string]any{"memory": "256Mi"}},
			},
		},
	}
	got := computeDriftFromLastApplied(liveWith(desired, live), nil)
	if got == nil || len(got.Entries) != 1 {
		t.Fatalf("expected exactly 1 entry, got %v", got)
	}
	e := got.Entries[0]
	if e.Path != "spec.containers.[0].resources.limits.memory" || e.Op != DriftOpChanged {
		t.Errorf("entry = %+v, want the specific nested array path", e)
	}
	if !strings.Contains(e.Desired, "128Mi") || !strings.Contains(e.Live, "256Mi") {
		t.Errorf("desired/live should hold just the changed scalar, got desired=%q live=%q", e.Desired, e.Live)
	}
}

// TestComputeDrift_ArrayLengthChange pins that adding a list element surfaces
// just the new element at its index, not a whole-array rewrite.
func TestComputeDrift_ArrayLengthChange(t *testing.T) {
	desired := `{"spec":{"ports":[{"port":80}]}}`
	live := map[string]any{
		"ports": []any{
			map[string]any{"port": int64(80)},
			map[string]any{"port": int64(443)},
		},
	}
	got := computeDriftFromLastApplied(liveWith(desired, live), nil)
	if got == nil || len(got.Entries) != 1 {
		t.Fatalf("expected 1 entry for the added element, got %v", got)
	}
	if e := got.Entries[0]; e.Path != "spec.ports.[1]" || e.Op != DriftOpAdded {
		t.Errorf("entry = %+v, want spec.ports.[1] added", e)
	}
}

func TestComputeDrift_TreatsEmptyAsNil(t *testing.T) {
	// Defaulted-in empty maps and arrays shouldn't show as drift.
	desired := `{"spec":{"a":{}}}`
	got := computeDriftFromLastApplied(liveWith(desired, map[string]any{"a": map[string]any{}}), nil)
	if got != nil {
		t.Errorf("empty map vs empty map should not produce drift, got %v", got.Entries)
	}
}

// TestComputeDrift_NilishCrossSide pins the cross-side behavior: "nil/empty
// on one side, real value on the other" emits a single entry at the parent
// path rather than recursing into the other side. This keeps the diff
// readable when an entire subtree was added or removed.
func TestComputeDrift_NilishCrossSide(t *testing.T) {
	cases := []struct {
		name        string
		desired     string
		live        map[string]any
		wantOp      DriftOp
		wantPath    string
		wantInValue string // substring expected in the populated side's JSON
	}{
		{
			name:        "empty desired map vs non-empty live map → added at parent path",
			desired:     `{"spec":{"a":{}}}`,
			live:        map[string]any{"a": map[string]any{"x": "1"}},
			wantOp:      DriftOpAdded,
			wantPath:    "spec.a",
			wantInValue: `"x"`,
		},
		{
			name:        "non-empty desired map vs empty live map → removed at parent path",
			desired:     `{"spec":{"a":{"x":"1"}}}`,
			live:        map[string]any{"a": map[string]any{}},
			wantOp:      DriftOpRemoved,
			wantPath:    "spec.a",
			wantInValue: `"x"`,
		},
		{
			name:        "missing-on-desired vs present-on-live → added at the closest containing path",
			desired:     `{"spec":{}}`,
			live:        map[string]any{"replicas": int64(3)},
			wantOp:      DriftOpAdded,
			wantPath:    "spec",
			wantInValue: `"replicas"`,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := computeDriftFromLastApplied(liveWith(tc.desired, tc.live), nil)
			if got == nil {
				t.Fatalf("expected drift, got nil")
			}
			var entry *DriftEntry
			for i := range got.Entries {
				if got.Entries[i].Path == tc.wantPath {
					entry = &got.Entries[i]
					break
				}
			}
			if entry == nil {
				t.Fatalf("expected entry at %s; got entries=%+v", tc.wantPath, got.Entries)
			}
			if entry.Op != tc.wantOp {
				t.Errorf("path %s: op = %q, want %q", tc.wantPath, entry.Op, tc.wantOp)
			}
			payload := entry.Live + entry.Desired
			if !strings.Contains(payload, tc.wantInValue) {
				t.Errorf("path %s: payload should contain %q; live=%q desired=%q", tc.wantPath, tc.wantInValue, entry.Live, entry.Desired)
			}
		})
	}
}

// TestComputeDrift_DeploymentDefaultsAreNotDrift pins the headline fix:
// when last-applied is a partial Deployment manifest, the K8s API server
// fills in defaults like progressDeadlineSeconds:600, strategy.RollingUpdate,
// container imagePullPolicy:IfNotPresent, dnsPolicy:ClusterFirst,
// restartPolicy:Always, schedulerName, terminationGracePeriodSeconds, etc.
// Before this fix, every one of those defaults surfaced as drift. With the
// scheme-defaulting pass applied to the desired side first, they cancel out.
func TestComputeDrift_DeploymentDefaultsAreNotDrift(t *testing.T) {
	// Minimal user-applied Deployment — exactly what kubectl/Argo would
	// write to last-applied-configuration from a partial manifest.
	desired := `{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"guestbook-ui","namespace":"demo"},"spec":{"replicas":1,"selector":{"matchLabels":{"app":"guestbook-ui"}},"template":{"metadata":{"labels":{"app":"guestbook-ui"}},"spec":{"containers":[{"name":"guestbook-ui","image":"gcr.io/google-samples/gb-frontend:v5","ports":[{"containerPort":80}]}]}}}}`

	// Live Deployment as the API server stores it — same user spec plus
	// all the defaulted fields the user never set.
	live := map[string]any{
		"replicas":                int64(1),
		"progressDeadlineSeconds": int64(600),
		"revisionHistoryLimit":    int64(10),
		"strategy": map[string]any{
			"type": "RollingUpdate",
			"rollingUpdate": map[string]any{
				"maxSurge":       "25%",
				"maxUnavailable": "25%",
			},
		},
		"selector": map[string]any{
			"matchLabels": map[string]any{"app": "guestbook-ui"},
		},
		"template": map[string]any{
			"metadata": map[string]any{"labels": map[string]any{"app": "guestbook-ui"}},
			"spec": map[string]any{
				"containers": []any{
					map[string]any{
						"name":                     "guestbook-ui",
						"image":                    "gcr.io/google-samples/gb-frontend:v5",
						"imagePullPolicy":          "IfNotPresent",
						"terminationMessagePath":   "/dev/termination-log",
						"terminationMessagePolicy": "File",
						"resources":                map[string]any{},
						"ports": []any{
							map[string]any{
								"containerPort": int64(80),
								"protocol":      "TCP",
							},
						},
					},
				},
				"dnsPolicy":                     "ClusterFirst",
				"restartPolicy":                 "Always",
				"schedulerName":                 "default-scheduler",
				"securityContext":               map[string]any{},
				"terminationGracePeriodSeconds": int64(30),
			},
		},
	}

	obj := &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "apps/v1",
		"kind":       "Deployment",
		"metadata": map[string]any{
			"name":      "guestbook-ui",
			"namespace": "demo",
			"annotations": map[string]any{
				kubectlLastAppliedAnnotation: desired,
			},
		},
		"spec": live,
	}}

	got := computeDriftFromLastApplied(obj, nil)
	if got != nil && len(got.Entries) > 0 {
		// We expect no drift: every "live extra" field is an API server default
		// and should cancel out via applySchemeDefaults.
		t.Errorf("expected no drift on a partial Deployment manifest with only API server defaults filled in; got %d entries:\n%v", len(got.Entries), got.Entries)
	}
}

// TestFilterIgnoredPaths_PrefixMatch pins the ignoreDifferences filter
// semantics: an ignore on "/spec/replicas" strips exactly the matching
// entry; an ignore on a parent path strips everything underneath.
func TestFilterIgnoredPaths_PrefixMatch(t *testing.T) {
	entries := []DriftEntry{
		{Path: "spec.replicas", Op: DriftOpChanged},
		{Path: "spec.template.spec.containers", Op: DriftOpChanged},
		{Path: "spec.template.spec.dnsPolicy", Op: DriftOpAdded},
		{Path: "spec.strategy.type", Op: DriftOpChanged},
	}
	out := filterIgnoredPaths(entries, []string{
		"/spec/replicas",      // exact path
		"/spec/template/spec", // parent prefix
	})
	if len(out) != 1 {
		t.Fatalf("expected 1 entry after filter, got %d (%v)", len(out), out)
	}
	if out[0].Path != "spec.strategy.type" {
		t.Errorf("kept entry = %q, want spec.strategy.type", out[0].Path)
	}
}

// TestFilterIgnoredPaths_ArrayIndexPointer pins that an ignoreDifferences JSON
// pointer into a specific array index matches the bracketed drift path that
// array descent now emits — a rule on /spec/containers/0/image must strip
// spec.containers.[0].image.
func TestFilterIgnoredPaths_ArrayIndexPointer(t *testing.T) {
	entries := []DriftEntry{
		{Path: "spec.containers.[0].image", Op: DriftOpChanged},
		{Path: "spec.containers.[0].resources.limits.memory", Op: DriftOpChanged},
		{Path: "spec.replicas", Op: DriftOpChanged},
	}
	out := filterIgnoredPaths(entries, []string{"/spec/containers/0/image"})
	if len(out) != 2 {
		t.Fatalf("expected the indexed image entry stripped, got %d (%v)", len(out), out)
	}
	for _, e := range out {
		if e.Path == "spec.containers.[0].image" {
			t.Errorf("array-index ignore pointer failed to match bracketed drift path")
		}
	}
}

// TestFilterIgnoredPaths_PrefixDoesNotMatchPartialSegment pins that
// "/spec/replic" doesn't accidentally match "spec.replicas". Prefix
// matching must respect path segment boundaries.
func TestFilterIgnoredPaths_PrefixDoesNotMatchPartialSegment(t *testing.T) {
	entries := []DriftEntry{
		{Path: "spec.replicas", Op: DriftOpChanged},
	}
	out := filterIgnoredPaths(entries, []string{"/spec/replic"})
	if len(out) != 1 {
		t.Errorf("expected entry preserved (partial-segment ignore must not match); got %d (%v)", len(out), out)
	}
}

// TestParseArgoIgnoreDifferences_MatchesByGroupKindAndOptionalNameNs pins
// the scoping semantics: a rule with empty name/namespace matches every
// resource of the given group/kind; named/namespaced rules narrow.
func TestParseArgoIgnoreDifferences_MatchesByGroupKindAndOptionalNameNs(t *testing.T) {
	app := &unstructured.Unstructured{Object: map[string]any{
		"spec": map[string]any{
			"ignoreDifferences": []any{
				map[string]any{
					"group":        "apps",
					"kind":         "Deployment",
					"jsonPointers": []any{"/spec/replicas"},
				},
				map[string]any{
					"group":        "apps",
					"kind":         "Deployment",
					"name":         "specific-deploy",
					"namespace":    "ns",
					"jsonPointers": []any{"/spec/template/spec/dnsPolicy"},
				},
			},
		},
	}}
	ig := parseArgoIgnoreDifferences(app)
	// Broad rule matches any Deployment
	if got := ig.pointersFor(Ref{Group: "apps", Kind: "Deployment", Namespace: "other", Name: "anything"}); len(got) != 1 || got[0] != "/spec/replicas" {
		t.Errorf("broad rule should match any Deployment; got %v", got)
	}
	// Narrow rule applies only to the named resource — broad still applies too
	got := ig.pointersFor(Ref{Group: "apps", Kind: "Deployment", Namespace: "ns", Name: "specific-deploy"})
	if len(got) != 2 {
		t.Errorf("named match should pick up both rules; got %v", got)
	}
	// Different kind = no matches
	if got := ig.pointersFor(Ref{Group: "", Kind: "Service", Name: "x"}); got != nil {
		t.Errorf("Service shouldn't match Deployment rules; got %v", got)
	}
}

// TestComputeDrift_ServerAssignedFieldsNotDrift pins the kindServerAssignedPaths
// filter: Service.clusterIP/clusterIPs/ipFamilies/internalTrafficPolicy,
// Pod.nodeName, PVC.volumeName are all *runtime*-assigned and shouldn't
// surface as drift even though they're added on live. A regression to the
// filter (deleting a kind entry, or detaching it from the diff pipeline)
// would silently re-introduce these as "added" entries.
func TestComputeDrift_ServerAssignedFieldsNotDrift(t *testing.T) {
	cases := []struct {
		name     string
		kind     string
		desired  string
		liveSpec map[string]any
	}{
		{
			name:    "Service clusterIP, clusterIPs, ipFamilies, internalTrafficPolicy",
			kind:    "Service",
			desired: `{"apiVersion":"v1","kind":"Service","spec":{"type":"ClusterIP","ports":[{"port":80,"protocol":"TCP"}],"selector":{"app":"x"}}}`,
			liveSpec: map[string]any{
				"type":                  "ClusterIP",
				"sessionAffinity":       "None",
				"ipFamilyPolicy":        "SingleStack",
				"clusterIP":             "10.0.0.5",
				"clusterIPs":            []any{"10.0.0.5"},
				"ipFamilies":            []any{"IPv4"},
				"internalTrafficPolicy": "Cluster",
				"ports":                 []any{map[string]any{"port": int64(80), "protocol": "TCP"}},
				"selector":              map[string]any{"app": "x"},
			},
		},
		{
			name:    "Pod nodeName",
			kind:    "Pod",
			desired: `{"apiVersion":"v1","kind":"Pod","spec":{"containers":[{"name":"c","image":"nginx"}]}}`,
			liveSpec: map[string]any{
				"containers": []any{map[string]any{
					"name":                     "c",
					"image":                    "nginx",
					"imagePullPolicy":          "Always",
					"terminationMessagePath":   "/dev/termination-log",
					"terminationMessagePolicy": "File",
					"resources":                map[string]any{},
				}},
				"dnsPolicy":                     "ClusterFirst",
				"restartPolicy":                 "Always",
				"schedulerName":                 "default-scheduler",
				"securityContext":               map[string]any{},
				"terminationGracePeriodSeconds": int64(30),
				"nodeName":                      "worker-3",
			},
		},
		{
			name:    "PersistentVolumeClaim volumeName",
			kind:    "PersistentVolumeClaim",
			desired: `{"apiVersion":"v1","kind":"PersistentVolumeClaim","spec":{"accessModes":["ReadWriteOnce"],"resources":{"requests":{"storage":"1Gi"}}}}`,
			liveSpec: map[string]any{
				"accessModes": []any{"ReadWriteOnce"},
				"resources":   map[string]any{"requests": map[string]any{"storage": "1Gi"}},
				"volumeName":  "pvc-7a8b9c0d-1234",
			},
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			obj := &unstructured.Unstructured{Object: map[string]any{
				"apiVersion": "v1",
				"kind":       tc.kind,
				"metadata": map[string]any{
					"name":      "x",
					"namespace": "ns",
					"annotations": map[string]any{
						kubectlLastAppliedAnnotation: tc.desired,
					},
				},
				"spec": tc.liveSpec,
			}}
			got := computeDriftFromLastApplied(obj, nil)
			if got != nil && len(got.Entries) > 0 {
				t.Errorf("expected no drift on %s with only server-assigned fields differing; got %d entries:\n%v", tc.kind, len(got.Entries), got.Entries)
			}
		})
	}
}

// TestComputeDrift_AllKindDefaults_NoNoise covers each registered
// kindDefaulters entry. One happy-path test per kind, asserting "manifest
// with the user-required fields only, live with all the API server's
// defaults filled in" → no drift. A typo in any defaulter (e.g.,
// "OrderedReady " with a trailing space) fails the matching kind's case.
func TestComputeDrift_AllKindDefaults_NoNoise(t *testing.T) {
	containerLive := func(name string) map[string]any {
		return map[string]any{
			"name":                     name,
			"image":                    "nginx",
			"imagePullPolicy":          "Always",
			"terminationMessagePath":   "/dev/termination-log",
			"terminationMessagePolicy": "File",
			"resources":                map[string]any{},
		}
	}
	podSpecLive := func(restartPolicy string) map[string]any {
		return map[string]any{
			"containers":                    []any{containerLive("c")},
			"dnsPolicy":                     "ClusterFirst",
			"restartPolicy":                 restartPolicy,
			"schedulerName":                 "default-scheduler",
			"securityContext":               map[string]any{},
			"terminationGracePeriodSeconds": int64(30),
		}
	}
	// Pod template "metadata" gets dropped from comparison via isNilish
	// when both sides have only an empty labels map. Set labels on both
	// sides consistently for the workload kinds that ship them.
	templateLiveBare := func(restartPolicy string) map[string]any {
		return map[string]any{
			"metadata": map[string]any{},
			"spec":     podSpecLive(restartPolicy),
		}
	}
	templateLiveLabeled := func(restartPolicy string) map[string]any {
		return map[string]any{
			"metadata": map[string]any{"labels": map[string]any{"app": "x"}},
			"spec":     podSpecLive(restartPolicy),
		}
	}

	cases := []struct {
		kind     string
		desired  string
		liveSpec map[string]any
	}{
		{
			kind:    "StatefulSet",
			desired: `{"apiVersion":"apps/v1","kind":"StatefulSet","spec":{"selector":{"matchLabels":{"app":"x"}},"serviceName":"x","template":{"metadata":{"labels":{"app":"x"}},"spec":{"containers":[{"name":"c","image":"nginx"}]}}}}`,
			liveSpec: map[string]any{
				"replicas":             int64(1),
				"revisionHistoryLimit": int64(10),
				"podManagementPolicy":  "OrderedReady",
				"updateStrategy":       map[string]any{"type": "RollingUpdate"},
				"selector":             map[string]any{"matchLabels": map[string]any{"app": "x"}},
				"serviceName":          "x",
				"template":             templateLiveLabeled("Always"),
			},
		},
		{
			kind:    "DaemonSet",
			desired: `{"apiVersion":"apps/v1","kind":"DaemonSet","spec":{"selector":{"matchLabels":{"app":"x"}},"template":{"metadata":{"labels":{"app":"x"}},"spec":{"containers":[{"name":"c","image":"nginx"}]}}}}`,
			liveSpec: map[string]any{
				"revisionHistoryLimit": int64(10),
				"updateStrategy": map[string]any{
					"type":          "RollingUpdate",
					"rollingUpdate": map[string]any{"maxUnavailable": int64(1)},
				},
				"selector": map[string]any{"matchLabels": map[string]any{"app": "x"}},
				"template": templateLiveLabeled("Always"),
			},
		},
		{
			kind:    "ReplicaSet",
			desired: `{"apiVersion":"apps/v1","kind":"ReplicaSet","spec":{"selector":{"matchLabels":{"app":"x"}},"template":{"metadata":{"labels":{"app":"x"}},"spec":{"containers":[{"name":"c","image":"nginx"}]}}}}`,
			liveSpec: map[string]any{
				"replicas": int64(1),
				"selector": map[string]any{"matchLabels": map[string]any{"app": "x"}},
				"template": templateLiveLabeled("Always"),
			},
		},
		{
			kind:    "Job",
			desired: `{"apiVersion":"batch/v1","kind":"Job","spec":{"template":{"metadata":{},"spec":{"containers":[{"name":"c","image":"nginx"}],"restartPolicy":"Never"}}}}`,
			liveSpec: map[string]any{
				"backoffLimit":   int64(6),
				"completionMode": "NonIndexed",
				"template":       templateLiveBare("Never"),
			},
		},
		{
			kind:    "CronJob",
			desired: `{"apiVersion":"batch/v1","kind":"CronJob","spec":{"schedule":"* * * * *","jobTemplate":{"spec":{"template":{"metadata":{},"spec":{"containers":[{"name":"c","image":"nginx"}],"restartPolicy":"Never"}}}}}}`,
			liveSpec: map[string]any{
				"schedule":                   "* * * * *",
				"concurrencyPolicy":          "Allow",
				"failedJobsHistoryLimit":     int64(1),
				"successfulJobsHistoryLimit": int64(3),
				"suspend":                    false,
				"jobTemplate": map[string]any{
					"spec": map[string]any{
						"backoffLimit":   int64(6),
						"completionMode": "NonIndexed",
						"template":       templateLiveBare("Never"),
					},
				},
			},
		},
		{
			kind:     "Pod",
			desired:  `{"apiVersion":"v1","kind":"Pod","spec":{"containers":[{"name":"c","image":"nginx"}]}}`,
			liveSpec: podSpecLive("Always"),
		},
		{
			kind:    "Service",
			desired: `{"apiVersion":"v1","kind":"Service","spec":{"ports":[{"port":80}],"selector":{"app":"x"}}}`,
			liveSpec: map[string]any{
				"type":            "ClusterIP",
				"sessionAffinity": "None",
				"ipFamilyPolicy":  "SingleStack",
				"ports":           []any{map[string]any{"port": int64(80), "protocol": "TCP"}},
				"selector":        map[string]any{"app": "x"},
			},
		},
	}
	for _, tc := range cases {
		t.Run(tc.kind, func(t *testing.T) {
			obj := &unstructured.Unstructured{Object: map[string]any{
				"apiVersion": "test/v1",
				"kind":       tc.kind,
				"metadata": map[string]any{
					"name":      "x",
					"namespace": "ns",
					"annotations": map[string]any{
						kubectlLastAppliedAnnotation: tc.desired,
					},
				},
				"spec": tc.liveSpec,
			}}
			got := computeDriftFromLastApplied(obj, nil)
			if got != nil && len(got.Entries) > 0 {
				t.Errorf("expected no drift on %s with only API server defaults filled in; got %d entries:\n%v", tc.kind, len(got.Entries), got.Entries)
			}
		})
	}
}

func TestDefaultImagePullPolicyMatchesKubernetesTagRules(t *testing.T) {
	cases := []struct {
		image string
		want  string
	}{
		{image: "nginx", want: "Always"},
		{image: "nginx:latest", want: "Always"},
		{image: "nginx:1.27", want: "IfNotPresent"},
		{image: "registry.example.com:5000/ns/app:1.0", want: "IfNotPresent"},
		{image: "registry.example.com:5000/ns/app", want: "Always"},
		{image: "nginx@sha256:abcdef", want: "Always"},
	}
	for _, tc := range cases {
		t.Run(tc.image, func(t *testing.T) {
			if got := defaultImagePullPolicy(tc.image); got != tc.want {
				t.Fatalf("defaultImagePullPolicy(%q) = %q, want %q", tc.image, got, tc.want)
			}
		})
	}
}

// TestArgoIgnoreRule_MatchesWildcards pins the empty-string-as-wildcard
// semantics on each scope field. The previous implementation required
// non-empty group/kind and would skip every rule that omitted them,
// breaking the Argo-compatible "ignore field X across all kinds" pattern.
func TestArgoIgnoreRule_MatchesWildcards(t *testing.T) {
	cases := []struct {
		name string
		rule argoIgnoreRule
		ref  Ref
		want bool
	}{
		{
			name: "empty group wildcards group",
			rule: argoIgnoreRule{kind: "Deployment"},
			ref:  Ref{Group: "apps", Kind: "Deployment", Namespace: "x", Name: "y"},
			want: true,
		},
		{
			name: "empty kind wildcards kind",
			rule: argoIgnoreRule{group: "apps"},
			ref:  Ref{Group: "apps", Kind: "StatefulSet", Namespace: "x", Name: "y"},
			want: true,
		},
		{
			name: "all empty matches everything",
			rule: argoIgnoreRule{},
			ref:  Ref{Group: "", Kind: "Pod", Namespace: "x", Name: "y"},
			want: true,
		},
		{
			name: "non-matching kind is rejected",
			rule: argoIgnoreRule{group: "apps", kind: "Deployment"},
			ref:  Ref{Group: "apps", Kind: "StatefulSet"},
			want: false,
		},
		{
			name: "named rule narrows to one resource",
			rule: argoIgnoreRule{kind: "Deployment", name: "specific"},
			ref:  Ref{Kind: "Deployment", Name: "other"},
			want: false,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := tc.rule.matches(tc.ref); got != tc.want {
				t.Errorf("matches(%+v) = %v, want %v", tc.ref, got, tc.want)
			}
		})
	}
}