Ctrl K
ComunidadGuías…

pkg/karpenter/karpenter_test.go

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

Archivo original ↗Preguntar a KiBot
package karpenter

import (
	"testing"
	"time"

	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/types"
)

func TestNodeClassRefForNodePool(t *testing.T) {
	tests := []struct {
		name       string
		apiVersion string
		ref        map[string]any
		want       NodeClassRef
		wantOK     bool
	}{
		{
			name:       "v1 group reference",
			apiVersion: APIVersionV1,
			ref:        map[string]any{"group": "karpenter.k8s.aws", "kind": "EC2NodeClass", "name": "default"},
			want:       NodeClassRef{Group: "karpenter.k8s.aws", Kind: "EC2NodeClass", Name: "default"},
			wantOK:     true,
		},
		{
			name:       "v1beta1 apiVersion reference",
			apiVersion: APIVersionV1Beta1,
			ref:        map[string]any{"apiVersion": "karpenter.k8s.aws/v1beta1", "kind": "EC2NodeClass", "name": "default"},
			want: NodeClassRef{
				Group:      "karpenter.k8s.aws",
				Version:    "v1beta1",
				Kind:       "EC2NodeClass",
				Name:       "default",
				APIVersion: "karpenter.k8s.aws/v1beta1",
			},
			wantOK: true,
		},
		{
			name:       "v1 does not fall back to legacy apiVersion",
			apiVersion: APIVersionV1,
			ref:        map[string]any{"apiVersion": "karpenter.k8s.aws/v1beta1", "kind": "EC2NodeClass", "name": "default"},
		},
		{
			name:       "v1beta1 does not fall back to v1 group",
			apiVersion: APIVersionV1Beta1,
			ref:        map[string]any{"group": "karpenter.k8s.aws", "kind": "EC2NodeClass", "name": "default"},
		},
		{
			name:       "v1beta1 rejects version without group",
			apiVersion: APIVersionV1Beta1,
			ref:        map[string]any{"apiVersion": "v1beta1", "kind": "EC2NodeClass", "name": "default"},
		},
		{
			name:       "missing name",
			apiVersion: APIVersionV1,
			ref:        map[string]any{"group": "karpenter.k8s.aws", "kind": "EC2NodeClass"},
		},
		{
			name:       "unknown NodePool API version",
			apiVersion: "karpenter.sh/v2",
			ref:        map[string]any{"group": "karpenter.k8s.aws", "kind": "EC2NodeClass", "name": "default"},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			pool := nodePool(tt.apiVersion, "default", "pool-uid", map[string]any{
				"spec": map[string]any{
					"template": map[string]any{
						"spec": map[string]any{"nodeClassRef": tt.ref},
					},
				},
			})
			got, ok := NodeClassRefForNodePool(pool)
			if ok != tt.wantOK {
				t.Fatalf("NodeClassRefForNodePool() ok = %v, want %v", ok, tt.wantOK)
			}
			if got != tt.want {
				t.Fatalf("NodeClassRefForNodePool() = %+v, want %+v", got, tt.want)
			}
		})
	}
}

func TestNodeClassRefForNodeClaim(t *testing.T) {
	claim := object(APIVersionV1, NodeClaimKind, "claim-a", "claim-uid", map[string]any{
		"spec": map[string]any{
			"nodeClassRef": map[string]any{"group": "eks.amazonaws.com", "kind": "NodeClass", "name": "general-purpose"},
		},
	})
	got, ok := NodeClassRefForNodeClaim(claim)
	if !ok {
		t.Fatal("NodeClassRefForNodeClaim() did not find the v1 reference")
	}
	want := NodeClassRef{Group: "eks.amazonaws.com", Kind: "NodeClass", Name: "general-purpose"}
	if got != want {
		t.Fatalf("NodeClassRefForNodeClaim() = %+v, want %+v", got, want)
	}
}

func TestNodePoolConditionsAndReadiness(t *testing.T) {
	transition := "2026-07-13T08:00:00Z"
	pool := nodePool(APIVersionV1, "default", "pool-uid", map[string]any{
		"status": map[string]any{
			"conditions": []any{
				map[string]any{
					"type":               "NodeClassReady",
					"status":             "False",
					"observedGeneration": int64(4),
					"lastTransitionTime": transition,
					"reason":             "NodeClassNotReady",
					"message":            "dependency is not ready",
				},
				map[string]any{
					"type":               "Ready",
					"status":             "False",
					"observedGeneration": int64(4),
					"lastTransitionTime": transition,
					"reason":             "NodeClassNotReady",
					"message":            "NodeClass dependency is not ready",
				},
			},
		},
	})

	conditions := NodePoolConditions(pool)
	if len(conditions) != 2 {
		t.Fatalf("NodePoolConditions() returned %d conditions, want 2", len(conditions))
	}
	ready := NodePoolReadyCondition(pool)
	if ready == nil {
		t.Fatal("NodePoolReadyCondition() returned nil")
	}
	if ready.Reason != "NodeClassNotReady" || ready.Message != "NodeClass dependency is not ready" || ready.ObservedGeneration != 4 {
		t.Fatalf("NodePoolReadyCondition() lost condition fields: %+v", ready)
	}
	wantTime, err := time.Parse(time.RFC3339, transition)
	if err != nil {
		t.Fatal(err)
	}
	if !ready.LastTransitionTime.Time.Equal(wantTime) {
		t.Fatalf("LastTransitionTime = %s, want %s", ready.LastTransitionTime.Time, wantTime)
	}
	if got := NodePoolReadiness(pool); got != ReadinessNotReady {
		t.Fatalf("NodePoolReadiness() = %q, want %q", got, ReadinessNotReady)
	}

	pool.Object["status"].(map[string]any)["conditions"].([]any)[1].(map[string]any)["status"] = "True"
	if got := NodePoolReadiness(pool); got != ReadinessReady {
		t.Fatalf("NodePoolReadiness() = %q, want %q", got, ReadinessReady)
	}
	pool.Object["status"].(map[string]any)["conditions"].([]any)[1].(map[string]any)["status"] = "Unknown"
	if got := NodePoolReadiness(pool); got != ReadinessUnknown {
		t.Fatalf("NodePoolReadiness() = %q, want %q", got, ReadinessUnknown)
	}
	delete(pool.Object, "status")
	if got := NodePoolReadiness(pool); got != ReadinessUnknown {
		t.Fatalf("NodePoolReadiness() without conditions = %q, want %q", got, ReadinessUnknown)
	}
}

func TestNodePoolReadinessDoesNotTrustAStaleGeneration(t *testing.T) {
	pool := nodePool(APIVersionV1, "default", "pool-uid", map[string]any{
		"status": map[string]any{
			"conditions": []any{
				map[string]any{"type": "Ready", "status": "True", "observedGeneration": int64(2)},
			},
		},
	})
	pool.SetGeneration(3)
	if got := NodePoolReadiness(pool); got != ReadinessUnknown {
		t.Fatalf("NodePoolReadiness() = %q, want unknown for a stale Ready condition", got)
	}
}

func TestNodeClaimConditionsAndReadiness(t *testing.T) {
	claim := object(APIVersionV1, NodeClaimKind, "claim-a", "claim-uid", map[string]any{
		"status": map[string]any{
			"conditions": []any{
				map[string]any{"type": "Initialized", "status": "True", "observedGeneration": int64(3)},
				map[string]any{"type": "Ready", "status": "True", "observedGeneration": int64(3), "reason": "Ready"},
				"malformed",
			},
		},
	})
	claim.SetGeneration(3)

	conditions := NodeClaimConditions(claim)
	if len(conditions) != 2 {
		t.Fatalf("NodeClaimConditions() returned %d conditions, want 2", len(conditions))
	}
	ready := NodeClaimReadyCondition(claim)
	if ready == nil || ready.Reason != "Ready" {
		t.Fatalf("NodeClaimReadyCondition() = %+v", ready)
	}
	if got := NodeClaimReadiness(claim); got != ReadinessReady {
		t.Fatalf("NodeClaimReadiness() = %q, want %q", got, ReadinessReady)
	}

	claim.SetGeneration(4)
	if got := NodeClaimReadiness(claim); got != ReadinessUnknown {
		t.Fatalf("NodeClaimReadiness() = %q, want unknown for a stale condition", got)
	}
}

func TestKarpenterStatusResourceLists(t *testing.T) {
	pool := nodePool(APIVersionV1, "default", "pool-uid", map[string]any{
		"status": map[string]any{
			"resources": map[string]any{
				"cpu":            "1250m",
				"memory":         "8Gi",
				"nvidia.com/gpu": int64(2),
				"invalid":        map[string]any{"quantity": "nope"},
			},
		},
	})
	claim := object(APIVersionV1, NodeClaimKind, "claim-a", "claim-uid", map[string]any{
		"status": map[string]any{
			"capacity": map[string]any{
				"cpu":               float64(2.5),
				"memory":            "16Gi",
				"ephemeral-storage": "100Gi",
			},
		},
	})

	poolResources := NodePoolStatusResources(pool)
	gpu := poolResources[corev1.ResourceName("nvidia.com/gpu")]
	if poolResources.Cpu().String() != "1250m" || poolResources.Memory().String() != "8Gi" || gpu.String() != "2" {
		t.Fatalf("NodePoolStatusResources() = %#v", poolResources)
	}
	if _, ok := poolResources[corev1.ResourceName("invalid")]; ok {
		t.Fatal("NodePoolStatusResources() retained an invalid quantity")
	}

	claimCapacity := NodeClaimCapacity(claim)
	ephemeral := claimCapacity[corev1.ResourceEphemeralStorage]
	if claimCapacity.Cpu().String() != "2500m" || claimCapacity.Memory().String() != "16Gi" || ephemeral.String() != "100Gi" {
		t.Fatalf("NodeClaimCapacity() = %#v", claimCapacity)
	}
	if NodePoolStatusResources(nil) != nil || NodeClaimCapacity(nil) != nil {
		t.Fatal("nil resources fabricated status capacity")
	}
}

func TestNormalizeNodePoolSpecV1(t *testing.T) {
	pool := nodePool(APIVersionV1, "default", "pool-uid", map[string]any{
		"spec": map[string]any{
			"replicas": int64(3),
			"weight":   int64(50),
			"limits": map[string]any{
				"cpu":            int64(1000),
				"memory":         "64Gi",
				"nvidia.com/gpu": "8",
				"invalid":        map[string]any{"not": "a quantity"},
			},
			"template": map[string]any{
				"metadata": map[string]any{
					"labels": map[string]any{"team": "platform", "workload": "batch"},
				},
				"spec": map[string]any{
					"nodeClassRef": map[string]any{"group": "karpenter.k8s.aws", "kind": "EC2NodeClass", "name": "default"},
					"requirements": []any{
						map[string]any{"key": "kubernetes.io/arch", "operator": "In", "values": []any{"amd64", "arm64"}, "minValues": int64(2)},
					},
					"taints": []any{
						map[string]any{"key": "dedicated", "value": "batch", "effect": "NoSchedule"},
					},
					"startupTaints": []any{
						map[string]any{"key": "node.cilium.io/agent-not-ready", "value": "true", "effect": "NoExecute"},
					},
					"expireAfter":            "720h",
					"terminationGracePeriod": "24h",
				},
			},
			"disruption": map[string]any{
				"consolidationPolicy": "WhenEmptyOrUnderutilized",
				"consolidateAfter":    "30s",
				"expireAfter":         "legacy-path-must-not-win",
				"budgets": []any{
					map[string]any{"nodes": "20%", "schedule": "0 9 * * mon-fri", "duration": "8h", "reasons": []any{"Drifted", "Underutilized"}},
					map[string]any{"nodes": int64(2)},
				},
			},
		},
	})

	got := NormalizeNodePoolSpec(pool)
	if got.Replicas == nil || *got.Replicas != 3 {
		t.Fatalf("Replicas = %v, want 3", got.Replicas)
	}
	if got.Weight == nil || *got.Weight != 50 {
		t.Fatalf("Weight = %v, want 50", got.Weight)
	}
	gpu := got.Limits[corev1.ResourceName("nvidia.com/gpu")]
	if got.Limits.Cpu().String() != "1k" || got.Limits.Memory().String() != "64Gi" || gpu.String() != "8" {
		t.Fatalf("Limits were not normalized: %#v", got.Limits)
	}
	if _, exists := got.Limits[corev1.ResourceName("invalid")]; exists {
		t.Fatal("invalid quantity was retained")
	}
	if got.TemplateLabels["team"] != "platform" || got.TemplateLabels["workload"] != "batch" {
		t.Fatalf("TemplateLabels = %#v", got.TemplateLabels)
	}
	if got.NodeClassRef == nil || *got.NodeClassRef != (NodeClassRef{Group: "karpenter.k8s.aws", Kind: "EC2NodeClass", Name: "default"}) {
		t.Fatalf("NodeClassRef = %+v", got.NodeClassRef)
	}
	if len(got.Requirements) != 1 || got.Requirements[0].Operator != corev1.NodeSelectorOpIn || got.Requirements[0].MinValues == nil || *got.Requirements[0].MinValues != 2 {
		t.Fatalf("Requirements = %+v", got.Requirements)
	}
	if len(got.Taints) != 1 || got.Taints[0] != (corev1.Taint{Key: "dedicated", Value: "batch", Effect: corev1.TaintEffectNoSchedule}) {
		t.Fatalf("Taints = %+v", got.Taints)
	}
	if len(got.StartupTaints) != 1 || got.StartupTaints[0].Effect != corev1.TaintEffectNoExecute {
		t.Fatalf("StartupTaints = %+v", got.StartupTaints)
	}
	if got.ExpireAfter != "720h" || got.TerminationGracePeriod != "24h" {
		t.Fatalf("lifecycle fields = expire %q, termination grace %q", got.ExpireAfter, got.TerminationGracePeriod)
	}
	if got.Disruption.ConsolidationPolicy != "WhenEmptyOrUnderutilized" || got.Disruption.ConsolidateAfter != "30s" {
		t.Fatalf("Disruption = %+v", got.Disruption)
	}
	if len(got.Disruption.Budgets) != 2 || len(got.Disruption.Budgets[0].Reasons) != 2 || got.Disruption.Budgets[0].Nodes != "20%" || got.Disruption.Budgets[1].Nodes != "2" {
		t.Fatalf("Budgets = %+v", got.Disruption.Budgets)
	}
}

func TestNormalizeNodePoolSpecV1Beta1UsesDocumentedPaths(t *testing.T) {
	pool := nodePool(APIVersionV1Beta1, "default", "pool-uid", map[string]any{
		"spec": map[string]any{
			"template": map[string]any{
				"spec": map[string]any{
					"nodeClassRef": map[string]any{"apiVersion": "karpenter.k8s.aws/v1beta1", "kind": "EC2NodeClass", "name": "default"},
					"expireAfter":  "v1-path-must-not-win",
				},
			},
			"disruption": map[string]any{"expireAfter": "720h"},
		},
	})

	got := NormalizeNodePoolSpec(pool)
	if got.ExpireAfter != "720h" {
		t.Fatalf("ExpireAfter = %q, want documented v1beta1 disruption path", got.ExpireAfter)
	}
	if got.NodeClassRef == nil || got.NodeClassRef.Group != "karpenter.k8s.aws" || got.NodeClassRef.Version != "v1beta1" {
		t.Fatalf("NodeClassRef = %+v", got.NodeClassRef)
	}
}

func TestResolveNodePoolForClaim(t *testing.T) {
	defaultPool := nodePool(APIVersionV1, "default", "pool-uid", nil)
	otherPool := nodePool(APIVersionV1, "other", "other-uid", nil)
	pools := map[string]*unstructured.Unstructured{"default": defaultPool, "other": otherPool}

	t.Run("matching owner reference wins", func(t *testing.T) {
		claim := claimWith("claim-a", map[string]string{NodePoolLabelKey: "other"}, metav1.OwnerReference{
			APIVersion: APIVersionV1,
			Kind:       NodePoolKind,
			Name:       "default",
			UID:        types.UID("pool-uid"),
		})
		got, source := ResolveNodePoolForClaim(claim, pools)
		if got != defaultPool || source != RelationshipOwnerRef {
			t.Fatalf("ResolveNodePoolForClaim() = %v, %q; want default, owner_ref", objectName(got), source)
		}
	})

	t.Run("UID mismatch blocks label fallback", func(t *testing.T) {
		claim := claimWith("claim-a", map[string]string{NodePoolLabelKey: "default"}, metav1.OwnerReference{
			APIVersion: APIVersionV1,
			Kind:       NodePoolKind,
			Name:       "default",
			UID:        types.UID("deleted-pool-uid"),
		})
		got, source := ResolveNodePoolForClaim(claim, pools)
		if got != nil || source != RelationshipNone {
			t.Fatalf("ResolveNodePoolForClaim() = %v, %q; want no stale relationship", objectName(got), source)
		}
	})

	t.Run("missing owner target blocks label fallback", func(t *testing.T) {
		claim := claimWith("claim-a", map[string]string{NodePoolLabelKey: "default"}, metav1.OwnerReference{
			APIVersion: APIVersionV1,
			Kind:       NodePoolKind,
			Name:       "deleted",
			UID:        types.UID("deleted-pool-uid"),
		})
		got, source := ResolveNodePoolForClaim(claim, pools)
		if got != nil || source != RelationshipNone {
			t.Fatalf("ResolveNodePoolForClaim() = %v, %q; want no relationship", objectName(got), source)
		}
	})

	t.Run("label is used only without a Karpenter owner", func(t *testing.T) {
		claim := claimWith("claim-a", map[string]string{NodePoolLabelKey: "default"})
		got, source := ResolveNodePoolForClaim(claim, pools)
		if got != defaultPool || source != RelationshipLabel {
			t.Fatalf("ResolveNodePoolForClaim() = %v, %q; want default, label", objectName(got), source)
		}
	})

	t.Run("same kind in another API group is not authoritative", func(t *testing.T) {
		claim := claimWith("claim-a", map[string]string{NodePoolLabelKey: "default"}, metav1.OwnerReference{
			APIVersion: "example.com/v1",
			Kind:       NodePoolKind,
			Name:       "other",
			UID:        types.UID("other-uid"),
		})
		got, source := ResolveNodePoolForClaim(claim, pools)
		if got != defaultPool || source != RelationshipLabel {
			t.Fatalf("ResolveNodePoolForClaim() = %v, %q; want default, label", objectName(got), source)
		}
	})
}

func TestClaimAccessors(t *testing.T) {
	claim := object(APIVersionV1, NodeClaimKind, "claim-a", "claim-uid", map[string]any{
		"status": map[string]any{
			"nodeName":   "ip-10-0-0-1.ec2.internal",
			"providerID": "aws:///us-east-1a/i-0123456789",
		},
	})
	if got := ClaimNodeName(claim); got != "ip-10-0-0-1.ec2.internal" {
		t.Fatalf("ClaimNodeName() = %q", got)
	}
	if got := ClaimProviderID(claim); got != "aws:///us-east-1a/i-0123456789" {
		t.Fatalf("ClaimProviderID() = %q", got)
	}
	delete(claim.Object, "status")
	if ClaimNodeName(claim) != "" || ClaimProviderID(claim) != "" {
		t.Fatal("claim accessors fabricated values for missing status")
	}
}

func nodePool(apiVersion, name, uid string, fields map[string]any) *unstructured.Unstructured {
	return object(apiVersion, NodePoolKind, name, uid, fields)
}

func claimWith(name string, labels map[string]string, owners ...metav1.OwnerReference) *unstructured.Unstructured {
	claim := object(APIVersionV1, NodeClaimKind, name, "claim-uid", nil)
	claim.SetLabels(labels)
	claim.SetOwnerReferences(owners)
	return claim
}

func object(apiVersion, kind, name, uid string, fields map[string]any) *unstructured.Unstructured {
	obj := &unstructured.Unstructured{Object: map[string]any{}}
	for key, value := range fields {
		obj.Object[key] = value
	}
	obj.SetAPIVersion(apiVersion)
	obj.SetKind(kind)
	obj.SetName(name)
	obj.SetUID(types.UID(uid))
	return obj
}

func objectName(obj *unstructured.Unstructured) string {
	if obj == nil {
		return "<nil>"
	}
	return obj.GetName()
}

// TestNodeClaimFailureReasonVocabulary pins the catalogue every capacity
// surface reads: Karpenter keeps a FAILING lifecycle stage at
// status=Unknown with a provider reason, so a reason that falls out of this
// table silently reclassifies a dead NodeClaim as "still provisioning".
func TestNodeClaimFailureReasonVocabulary(t *testing.T) {
	// Every reason the AWS provider's ToReasonMessage can return, plus the
	// Karpenter core reasons. Reasons carrying "fail"/"error" text are listed
	// too — they must stay classified even if the substring rule changes.
	documented := []string{
		// karpenter core
		"LaunchFailed", "CreateError", "InsufficientCapacityError", "NodeClassNotReady",
		"MultipleNodesFound", "UnregisteredTaintMissing", "InsufficientInstanceCapacity",
		// aws provider (pkg/errors ToReasonMessage)
		"SpotSLRCreationFailed", "Unauthorized", "AMIAuthorizationFailure",
		"SecurityGroupSubnetVPCMismatch", "InstanceProfileNameInvalid",
		"LaunchTemplateNotFound", "InvalidAMIID", "AMINotFound", "RequestLimitExceeded",
		"InternalError", "FreeTierIneligible", "FleetQuotaExceeded",
		"AccountPendingVerification", "SpotQuotaExceeded", "VCPULimitExceeded",
		"InsufficientFreeAddressesInSubnet",
	}
	reasons := append([]string{}, documented...)
	for reason := range nodeClaimFailureReasons {
		reasons = append(reasons, reason)
	}

	for _, reason := range reasons {
		t.Run(reason, func(t *testing.T) {
			if !IsFailureReason(reason) {
				t.Fatalf("IsFailureReason(%q) = false — a failing stage would read as in progress", reason)
			}
			condition := metav1.Condition{Type: "Launched", Status: metav1.ConditionUnknown, Reason: reason}
			if !IsFailedLifecycleCondition(condition) {
				t.Fatalf("IsFailedLifecycleCondition(Unknown/%q) = false", reason)
			}
			if !IsFailedLifecycleConditionForVersion(APIVersionV1Beta1, condition) {
				t.Fatalf("IsFailedLifecycleConditionForVersion(v1beta1, Unknown/%q) = false", reason)
			}
		})
	}

	// The complement: ordinary in-progress reasons must NOT be classified as
	// failures, or every provisioning claim would render as broken.
	for _, reason := range []string{"NodeNotFound", "StartupTaintsExist", "Launched", "Registered", ""} {
		if IsFailureReason(reason) {
			t.Fatalf("IsFailureReason(%q) = true — normal in-progress states must not read as failures", reason)
		}
	}
}