pkg/gitops/insights/drift_test.go
Rama main · réplica de lectura del repositorio original.
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)
}
})
}
}