package upgradereadiness import ( "errors" "slices" "strings" "testing" appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" policyv1 "k8s.io/api/policy/v1" storagev1 "k8s.io/api/storage/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" utilversion "k8s.io/apimachinery/pkg/util/version" bp "github.com/skyhook-io/radar/pkg/audit" ) func completeInput() *Input { return &Input{ Pods: []*corev1.Pod{}, Deployments: []*appsv1.Deployment{}, ReplicaSets: []*appsv1.ReplicaSet{}, StatefulSets: []*appsv1.StatefulSet{}, DaemonSets: []*appsv1.DaemonSet{}, Jobs: []*batchv1.Job{}, CronJobs: []*batchv1.CronJob{}, Services: []*corev1.Service{}, ConfigMaps: []*corev1.ConfigMap{}, WebhookServices: []*corev1.Service{}, PersistentVolumeClaims: []*corev1.PersistentVolumeClaim{}, PersistentVolumes: []*corev1.PersistentVolume{}, Nodes: []*corev1.Node{readyNode("node-a", "v1.35.7")}, Events: []*corev1.Event{}, PodDisruptionBudgets: []*policyv1.PodDisruptionBudget{}, EndpointSlices: []*discoveryv1.EndpointSlice{}, CSIDrivers: []*storagev1.CSIDriver{}, AdmissionWebhookConfigurations: []*unstructured.Unstructured{}, CustomResourceDefinitions: []*unstructured.Unstructured{}, APIServices: []*unstructured.Unstructured{}, NodeRuntimeEvidence: []NodeRuntimeEvidence{{NodeName: "node-a", MetricsAvailable: true, CgroupVersion: 2, CgroupVersionAvailable: true, ConfigAvailable: true, EventRecordQPS: 50, EventRecordQPSAvailable: true, FeatureGates: map[string]bool{}}}, ManifestResources: []ManifestResource{{ APIVersion: "apps/v1", Kind: "Deployment", Namespace: "default", Name: "api", Source: "Helm", Object: &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "apps/v1", "kind": "Deployment", "metadata": map[string]any{"name": "api", "namespace": "default"}, }}, }}, DeprecatedAPIRequests: []DeprecatedAPIRequest{}, PrometheusRules: []*unstructured.Unstructured{}, PrometheusRulesInstalled: false, PrometheusRulesDiscoveryAvailable: true, SchedulingV1Alpha2Objects: []*unstructured.Unstructured{}, SchedulingV1Alpha2DiscoveryAvailable: true, Platform: "generic", } } func TestReviewedThroughMatchesDeprecationCatalog(t *testing.T) { if bp.DeprecationCatalogReviewedThrough != ReviewedThrough { t.Fatalf("deprecation catalog reviewed through %s, upgrade catalog through %s", bp.DeprecationCatalogReviewedThrough, ReviewedThrough) } } func readyNode(name, version string) *corev1.Node { return &corev1.Node{ ObjectMeta: metav1.ObjectMeta{Name: name}, Status: corev1.NodeStatus{ Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, NodeInfo: corev1.NodeSystemInfo{KubeletVersion: version, ContainerRuntimeVersion: "containerd://2.1.0", OperatingSystem: "linux"}, }, } } func checkByID(t *testing.T, result *ScanResults, id string) Check { t.Helper() for _, check := range result.Checks { if check.ID == id { return check } } t.Fatalf("check %q not found", id) return Check{} } func gitRepoSpec() corev1.PodSpec { return corev1.PodSpec{Volumes: []corev1.Volume{{ Name: "source", VolumeSource: corev1.VolumeSource{GitRepo: &corev1.GitRepoVolumeSource{ Repository: "https://example.com/repo.git", }}, }}} } func TestScanBaselineCanPassWithSampledMetricsEvidence(t *testing.T) { got, err := Scan(completeInput(), "1.35", "1.36") if err != nil { t.Fatal(err) } if got.Verdict != VerdictNoKnownBlockers || got.Summary.Blocked != 0 || got.Summary.Passed != 12 || got.Summary.Unknown != 0 || got.Summary.NotApplicable != 4 { t.Fatalf("unexpected result: %+v", got) } if len(got.Checks) != 16 { t.Fatalf("checks = %d, want 16", len(got.Checks)) } metrics := checkByID(t, got, "deprecated-api-requests") if metrics.Status != CheckPassed || metrics.EvidenceNote == "" || metrics.Caveat != "" { t.Fatalf("sampled metrics should pass with an evidence note: %+v", metrics) } } func TestUnknownCheckForcesPartialCoverage(t *testing.T) { input := completeInput() input.DeprecatedAPIRequests = nil got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } if got.Coverage.State != "partial" || got.Verdict != VerdictUnknown { t.Fatalf("coverage=%+v verdict=%q, want partial unknown", got.Coverage, got.Verdict) } } func TestCacheScopedKindsRemainExplicitPerKind(t *testing.T) { input := completeInput() input.CacheScopedKinds = map[string][]string{ "pods": {"team-b", "team-a"}, "services": {"team-a"}, } got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } if got.Coverage.State != "partial" || !slices.Equal(got.Coverage.ScopedKinds["pods"], []string{"team-a", "team-b"}) { t.Fatalf("coverage = %+v, want sorted per-kind namespace ceilings", got.Coverage) } for _, id := range []string{"gitrepo-volume-removed", "service-externalips-deprecated", "node-drain-feasibility"} { check := checkByID(t, got, id) if check.Status != CheckUnknown || !strings.Contains(check.Caveat, "Cached evidence is namespace-limited") { t.Fatalf("%s = %+v, want incomplete per-kind cache evidence", id, check) } } input.CacheScopedKinds["pods"][0] = "mutated" if got.Coverage.ScopedKinds["pods"][1] != "team-b" { t.Fatal("coverage retained the mutable input slice") } } func TestKubeProxyAbsenceRequiresKubeSystemInformerCoverage(t *testing.T) { input := completeInput() input.CacheScopedKinds = map[string][]string{"daemonsets": {"default"}} got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } check := checkByID(t, got, "kube-proxy-version-skew") if check.Status != CheckUnknown || !strings.Contains(check.Summary, "kube-system is outside") { t.Fatalf("kube-proxy check = %+v, want incomplete outside kube-system", check) } input.CacheScopedKinds["daemonsets"] = []string{"kube-system"} got, err = Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } if check = checkByID(t, got, "kube-proxy-version-skew"); check.Status != CheckNotApplicable { t.Fatalf("kube-proxy check = %+v, kube-system coverage can support absence", check) } } func TestScanGitRepoAcrossWorkloadKinds(t *testing.T) { input := completeInput() input.Pods = []*corev1.Pod{{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "ns"}, Spec: gitRepoSpec()}} input.Deployments = []*appsv1.Deployment{{ObjectMeta: metav1.ObjectMeta{Name: "deploy", Namespace: "ns"}, Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: gitRepoSpec()}}}} input.ReplicaSets = []*appsv1.ReplicaSet{{ObjectMeta: metav1.ObjectMeta{Name: "replica", Namespace: "ns"}, Spec: appsv1.ReplicaSetSpec{Template: corev1.PodTemplateSpec{Spec: gitRepoSpec()}}}} input.StatefulSets = []*appsv1.StatefulSet{{ObjectMeta: metav1.ObjectMeta{Name: "stateful", Namespace: "ns"}, Spec: appsv1.StatefulSetSpec{Template: corev1.PodTemplateSpec{Spec: gitRepoSpec()}}}} input.DaemonSets = []*appsv1.DaemonSet{{ObjectMeta: metav1.ObjectMeta{Name: "daemon", Namespace: "ns"}, Spec: appsv1.DaemonSetSpec{Template: corev1.PodTemplateSpec{Spec: gitRepoSpec()}}}} input.Jobs = []*batchv1.Job{{ObjectMeta: metav1.ObjectMeta{Name: "job", Namespace: "ns"}, Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{Spec: gitRepoSpec()}}}} input.CronJobs = []*batchv1.CronJob{{ObjectMeta: metav1.ObjectMeta{Name: "cron", Namespace: "ns"}, Spec: batchv1.CronJobSpec{JobTemplate: batchv1.JobTemplateSpec{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{Spec: gitRepoSpec()}}}}}} got, err := Scan(input, "v1.35.7", "1.36") if err != nil { t.Fatal(err) } check := checkByID(t, got, "gitrepo-volume-removed") if got.Verdict != VerdictBlocked || check.Status != CheckBlocked || len(check.Findings) != 7 || check.Inspected != 7 { t.Fatalf("unexpected gitRepo result: %+v", check) } if check.Summary != "7 workloads use the disabled gitRepo volume driver." { t.Fatalf("gitRepo summary = %q", check.Summary) } wantPaths := map[string]string{ "Pod": "spec.volumes[0].gitRepo", "Deployment": "spec.template.spec.volumes[0].gitRepo", "ReplicaSet": "spec.template.spec.volumes[0].gitRepo", "StatefulSet": "spec.template.spec.volumes[0].gitRepo", "DaemonSet": "spec.template.spec.volumes[0].gitRepo", "Job": "spec.template.spec.volumes[0].gitRepo", "CronJob": "spec.jobTemplate.spec.template.spec.volumes[0].gitRepo", } for _, finding := range check.Findings { if finding.Resource == nil || finding.Evidence.Path != wantPaths[finding.Resource.Kind] { t.Fatalf("unexpected finding: %+v", finding) } } } func TestScanDeprecatedAPIRequests(t *testing.T) { input := completeInput() input.DeprecatedAPIMetricsWindow = "6h" input.DeprecatedAPIRequests = []DeprecatedAPIRequest{{Group: "extensions", Version: "v1beta1", Resource: "ingresses", RemovedRelease: "1.22", Requests: 4}} got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } check := checkByID(t, got, "deprecated-api-requests") if check.Status != CheckBlocked || len(check.Findings) != 1 || check.Findings[0].Resource != nil { t.Fatalf("unexpected API request result: %+v", check) } if !strings.Contains(check.Summary, "6h process lifetime") || !strings.Contains(check.EvidenceNote, "not cluster-wide history") { t.Fatalf("API metrics scope is not explicit: %+v", check) } input.DeprecatedAPIRequests = nil got, _ = Scan(input, "1.35", "1.36") check = checkByID(t, got, "deprecated-api-requests") if check.Status != CheckUnknown || !strings.Contains(check.Caveat, "not collected") || strings.Contains(check.Caveat, "Grant") { t.Fatalf("unavailable metrics should be actionable and unknown: %+v", check) } input.DeprecatedAPIMetricsFailure = APIMetricsForbidden got, err = Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } check = checkByID(t, got, "deprecated-api-requests") if !strings.Contains(check.Caveat, "Grant get") { t.Fatalf("forbidden metrics should explain the required access: %+v", check) } if !slices.Contains(check.References, cloudMetricsPermissionReference) { t.Fatal("forbidden metrics should include the permission reference") } } func TestScanScopedNamespaceDoesNotClaimClusterWideCoverage(t *testing.T) { input := completeInput() input.Namespaces = []string{"default"} input.PrometheusRulesInstalled = true got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } if got.Coverage.State != "partial" || len(got.Coverage.ScopedNamespaces) != 1 || got.Coverage.ScopedNamespaces[0] != "default" { t.Fatalf("scope was not preserved: %+v", got.Coverage) } proxy := checkByID(t, got, "kube-proxy-version-skew") if proxy.Status != CheckUnknown { t.Fatalf("kube-proxy outside namespace scope must be unknown: %+v", proxy) } for _, id := range []string{"manifest-api-compatibility", "gitrepo-volume-removed", "flexvolume-kubeadm-support", "service-externalips-deprecated", "renamed-control-plane-metrics", "strict-ip-cidr-validation", "node-drain-feasibility"} { check := checkByID(t, got, id) if check.Caveat == "" || !strings.Contains(check.Summary, "selected namespace scope") { t.Fatalf("%s must qualify namespace-scoped evidence: %+v", id, check) } } } func TestScanMultiMinorControlPlaneJumpIsBlocked(t *testing.T) { got, err := Scan(completeInput(), "1.32", "1.36") if err != nil { t.Fatal(err) } check := checkByID(t, got, "control-plane-upgrade-path") if check.Status != CheckBlocked || len(check.Findings) != 1 || check.Findings[0].Evidence.Detail != "1.32 → 1.33 → 1.34 → 1.35 → 1.36" || check.Findings[0].AppliesFrom != "" { t.Fatalf("unexpected upgrade path result: %+v", check) } } func TestScanMajorControlPlaneJumpIsBlocked(t *testing.T) { got, err := Scan(completeInput(), "1.35", "2.0") if err != nil { t.Fatal(err) } check := checkByID(t, got, "control-plane-upgrade-path") if check.Status != CheckBlocked || len(check.Findings) != 1 || check.Findings[0].Evidence.Detail != "1.35 → 2.0" || check.Findings[0].AppliesFrom != "" || !strings.Contains(check.Findings[0].Remediation, "1.36") { t.Fatalf("unexpected major-version path result: %+v", check) } } func TestScanManifestCompatibilityFromHelmAndLastApplied(t *testing.T) { input := completeInput() input.ManifestResources = []ManifestResource{{APIVersion: "extensions/v1beta1", Kind: "Ingress", Namespace: "web", Name: "api", Source: "Helm", SourceNamespace: "web", SourceName: "api"}} lastApplied := `{"apiVersion":"batch/v1beta1","kind":"CronJob","metadata":{"name":"cleanup","namespace":"ops"}}` input.SourceObjects = []metav1.Object{&appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "holder", Annotations: map[string]string{"kubectl.kubernetes.io/last-applied-configuration": lastApplied}}}} got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } check := checkByID(t, got, "manifest-api-compatibility") if check.Status != CheckBlocked || len(check.Findings) != 2 || check.Inspected != 2 { t.Fatalf("unexpected manifest result: %+v", check) } if check.Summary != "2 source manifests use an API affected by the target version." { t.Fatalf("manifest summary = %q", check.Summary) } if check.Findings[0].ManagedBy == nil && check.Findings[1].ManagedBy == nil { t.Fatalf("Helm ownership was not preserved: %+v", check.Findings) } } func TestMalformedLastAppliedAnnotationKeepsManifestCoveragePartial(t *testing.T) { input := completeInput() input.SourceObjects = []metav1.Object{&appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{ Name: "broken", Namespace: "default", Annotations: map[string]string{"kubectl.kubernetes.io/last-applied-configuration": "{"}, }}} got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } for _, id := range []string{"manifest-api-compatibility", "strict-ip-cidr-validation"} { check := checkByID(t, got, id) if check.Status != CheckUnknown || !strings.Contains(check.Caveat, "1 kubectl last-applied annotation could not be parsed") { t.Fatalf("%s = %+v, want incomplete malformed-annotation coverage", id, check) } } } func TestUpgradeSourceObjectCandidatesFollowChecksAndDeprecationCatalog(t *testing.T) { for _, candidate := range []struct{ kind, group string }{ {kind: "Node"}, {kind: "Ingress", group: "networking.k8s.io"}, {kind: "Role", group: "rbac.authorization.k8s.io"}, } { if !IsUpgradeSourceObjectCandidate(candidate.kind, candidate.group) { t.Fatalf("%s.%s should be discovered as source evidence", candidate.kind, candidate.group) } } if IsUpgradeSourceObjectCandidate("Widget", "example.io") { t.Fatal("unrelated CRDs must not be listed for source evidence") } if IsUpgradeSourceObjectCandidate("Ingress", "example.io") || IsUpgradeSourceObjectCandidate("Event", "") || IsUpgradeSourceObjectCandidate("Service", "anything.io") { t.Fatal("same-kind resources from unrelated API groups must not become source evidence") } } func TestScanManifestFindingRetainsPartialHelmCoverage(t *testing.T) { input := completeInput() input.ManifestResources = []ManifestResource{{APIVersion: "extensions/v1beta1", Kind: "Ingress", Namespace: "web", Name: "api", Source: "Helm"}} input.HelmUnavailableNamespaces = []string{"payments"} got, _ := Scan(input, "1.35", "1.36") check := checkByID(t, got, "manifest-api-compatibility") if check.Status != CheckBlocked || check.Caveat == "" || got.Coverage.State != "partial" { t.Fatalf("partial Helm coverage was hidden by finding: %+v", check) } } func TestSourceChecksExposeUnavailableKindsWithoutReadableManifest(t *testing.T) { input := completeInput() input.ManifestResources = []ManifestResource{} input.SourceObjects = []metav1.Object{} input.HelmUnavailableNamespaces = []string{"payments"} input.SourceObjectUnavailableKinds = []string{"ingresses"} got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } for _, id := range []string{"manifest-api-compatibility", "strict-ip-cidr-validation"} { check := checkByID(t, got, id) if check.Status != CheckUnknown || !strings.Contains(check.Caveat, "payments") || !strings.Contains(check.Caveat, "ingresses") { t.Fatalf("%s hid partial source coverage without readable manifests: %+v", id, check) } } if got.Coverage.State != "partial" { t.Fatalf("coverage state = %q, want partial", got.Coverage.State) } input.ManifestResources = nil input.SourceObjects = nil input.HelmUnavailableNamespaces = nil got, err = Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } for _, id := range []string{"manifest-api-compatibility", "strict-ip-cidr-validation"} { check := checkByID(t, got, id) if !strings.Contains(check.Caveat, "Helm release manifests and kubectl last-applied configuration were unavailable") || strings.Contains(check.Caveat, "only kubectl") { t.Fatalf("%s misrepresented unreadable source collectors: %+v", id, check) } } } func TestScanHealthAndComponentSkew(t *testing.T) { input := completeInput() input.Nodes = []*corev1.Node{readyNode("old", "v1.32.9"), readyNode("new", "v1.35.2")} input.Nodes[1].Status.Conditions[0].Status = corev1.ConditionFalse input.DaemonSets = []*appsv1.DaemonSet{ { ObjectMeta: metav1.ObjectMeta{Name: "kube-proxy", Namespace: "kube-system"}, Spec: appsv1.DaemonSetSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "kube-proxy", Image: "registry.k8s.io/kube-proxy:v1.32.9"}}}}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "kube-proxy-canary", Namespace: "kube-system", Labels: map[string]string{"k8s-app": "kube-proxy"}}, Spec: appsv1.DaemonSetSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "kube-proxy", Image: "registry.k8s.io/kube-proxy:v1.32.9"}}}}}, }, } got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } if checkByID(t, got, "node-health").Status != CheckWarning || checkByID(t, got, "kubelet-version-skew").Status != CheckBlocked || checkByID(t, got, "kube-proxy-version-skew").Status != CheckBlocked { t.Fatalf("unexpected health/skew checks: %+v", got.Checks) } if got := checkByID(t, got, "kube-proxy-version-skew").Summary; got != "2 kube-proxy installations are outside the supported skew." { t.Fatalf("kube-proxy summary = %q", got) } } func TestKubeletNewerThanTargetHasCorrectRemediation(t *testing.T) { input := completeInput() input.Nodes = []*corev1.Node{readyNode("newer", "v1.37.2")} got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } check := checkByID(t, got, "kubelet-version-skew") if check.Status != CheckBlocked || len(check.Findings) != 1 || !strings.Contains(check.Findings[0].Remediation, "newer than the Kubernetes 1.36 target") { t.Fatalf("too-new kubelet remediation = %+v", check) } } func TestScanSkewFindingRetainsUnparsedVersionCaveat(t *testing.T) { input := completeInput() input.Nodes = []*corev1.Node{readyNode("old", "v1.31.9"), readyNode("unknown", "vendor-build")} got, _ := Scan(input, "1.35", "1.36") check := checkByID(t, got, "kubelet-version-skew") if check.Status != CheckBlocked || check.Caveat == "" { t.Fatalf("unparsed kubelet caveat was hidden by blocker: %+v", check) } } func TestScanKubernetes136ConfigurationChanges(t *testing.T) { input := completeInput() input.Deployments = []*appsv1.Deployment{{ObjectMeta: metav1.ObjectMeta{Name: "api", Namespace: "default"}, Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Volumes: []corev1.Volume{{Name: "legacy", VolumeSource: corev1.VolumeSource{FlexVolume: &corev1.FlexVolumeSource{Driver: "example.com/driver"}}}}}}}}} input.Services = []*corev1.Service{ {ObjectMeta: metav1.ObjectMeta{Name: "legacy", Namespace: "default"}, Spec: corev1.ServiceSpec{ExternalIPs: []string{"203.0.113.10"}}}, {ObjectMeta: metav1.ObjectMeta{Name: "legacy-secondary", Namespace: "default"}, Spec: corev1.ServiceSpec{ExternalIPs: []string{"203.0.113.11"}}}, } input.PersistentVolumes = []*corev1.PersistentVolume{{ObjectMeta: metav1.ObjectMeta{Name: "flex"}, Spec: corev1.PersistentVolumeSpec{PersistentVolumeSource: corev1.PersistentVolumeSource{FlexVolume: &corev1.FlexPersistentVolumeSource{Driver: "example.com/driver"}}}}} input.PrometheusRulesInstalled = true input.PrometheusRules = []*unstructured.Unstructured{ {Object: map[string]any{ "apiVersion": "monitoring.coreos.com/v1", "kind": "PrometheusRule", "metadata": map[string]any{"name": "kubernetes", "namespace": "monitoring"}, "spec": map[string]any{"groups": []any{map[string]any{"rules": []any{map[string]any{"alert": "VolumeErrors", "expr": "rate(volume_operation_total_errors[5m]) > 0"}}}}}, }}, {Object: map[string]any{ "apiVersion": "monitoring.coreos.com/v1", "kind": "PrometheusRule", "metadata": map[string]any{"name": "kubernetes-secondary", "namespace": "monitoring"}, "spec": map[string]any{"groups": []any{map[string]any{"rules": []any{map[string]any{"alert": "VolumeErrorsSecondary", "expr": "rate(volume_operation_total_errors[5m]) > 0"}}}}}, }}, } got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } for _, id := range []string{"flexvolume-kubeadm-support", "service-externalips-deprecated", "renamed-control-plane-metrics"} { check := checkByID(t, got, id) wantFindings := 1 if id == "flexvolume-kubeadm-support" || id == "renamed-control-plane-metrics" || id == "service-externalips-deprecated" { wantFindings = 2 } wantStatus := CheckWarning if id == "service-externalips-deprecated" { wantStatus = CheckReview } if check.Status != wantStatus || len(check.Findings) != wantFindings { t.Fatalf("%s = %+v", id, check) } } flex := checkByID(t, got, "flexvolume-kubeadm-support") if flex.Findings[0].Title == flex.Findings[1].Title { t.Fatalf("FlexVolume issue types must distinguish workload and PersistentVolume exposure: %+v", flex.Findings) } if got := checkByID(t, got, "service-externalips-deprecated").Summary; got != "2 Services use deprecated spec.externalIPs." { t.Fatalf("Service summary = %q", got) } if got := checkByID(t, got, "renamed-control-plane-metrics").Summary; got != "2 PrometheusRules reference a metric renamed in Kubernetes 1.36." { t.Fatalf("PrometheusRule summary = %q", got) } } func TestScanPrometheusFindingRetainsPartialNamespaceCoverage(t *testing.T) { input := completeInput() input.PrometheusRulesInstalled = true input.PrometheusRuleUnavailableNamespaces = []string{"payments"} input.PrometheusRules = []*unstructured.Unstructured{{Object: map[string]any{ "metadata": map[string]any{"name": "kubernetes", "namespace": "monitoring"}, "spec": map[string]any{"groups": []any{map[string]any{"rules": []any{map[string]any{"expr": "volume_operation_total_errors"}}}}}, }}} got, _ := Scan(input, "1.35", "1.36") check := checkByID(t, got, "renamed-control-plane-metrics") if check.Status != CheckWarning || check.Caveat == "" || got.Coverage.State != "partial" { t.Fatalf("partial PrometheusRule coverage was hidden by finding: %+v", check) } } func TestScanKubeadmFlexVolumeChangeIsNotApplicableToManagedClusters(t *testing.T) { input := completeInput() input.Platform = "eks" input.Nodes[0].Labels = map[string]string{"eks.amazonaws.com/nodegroup": "workers"} input.PersistentVolumes = []*corev1.PersistentVolume{{ObjectMeta: metav1.ObjectMeta{Name: "flex"}, Spec: corev1.PersistentVolumeSpec{PersistentVolumeSource: corev1.PersistentVolumeSource{FlexVolume: &corev1.FlexPersistentVolumeSource{Driver: "example.com/driver"}}}}} got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } check := checkByID(t, got, "flexvolume-kubeadm-support") if check.Status != CheckNotApplicable || len(check.Findings) != 0 { t.Fatalf("managed-cluster FlexVolume check = %+v", check) } } func TestScanCloudProviderIDDoesNotImplyManagedControlPlane(t *testing.T) { input := completeInput() input.Platform = "eks" input.Nodes[0].Spec.ProviderID = "aws://us-east-1a/i-0123456789" input.PersistentVolumes = []*corev1.PersistentVolume{{ObjectMeta: metav1.ObjectMeta{Name: "flex"}, Spec: corev1.PersistentVolumeSpec{PersistentVolumeSource: corev1.PersistentVolumeSource{FlexVolume: &corev1.FlexPersistentVolumeSource{Driver: "example.com/driver"}}}}} got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } check := checkByID(t, got, "flexvolume-kubeadm-support") if check.Status != CheckWarning || len(check.Findings) != 1 { t.Fatalf("cloud provider ID must not suppress kubeadm review: %+v", check) } } func TestScanFlexVolumeApplicabilityRequiresNodeEvidence(t *testing.T) { input := completeInput() input.Platform = "eks" input.Nodes = []*corev1.Node{nil} input.PersistentVolumes = []*corev1.PersistentVolume{{ObjectMeta: metav1.ObjectMeta{Name: "flex"}, Spec: corev1.PersistentVolumeSpec{PersistentVolumeSource: corev1.PersistentVolumeSource{FlexVolume: &corev1.FlexPersistentVolumeSource{Driver: "example.com/driver"}}}}} got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } check := checkByID(t, got, "flexvolume-kubeadm-support") if check.Status != CheckUnknown || len(check.Findings) != 0 { t.Fatalf("FlexVolume check without node evidence = %+v, want unknown applicability", check) } } func TestScanCoverageAndVerdictPrecedence(t *testing.T) { input := completeInput() input.CronJobs = nil got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } if got.Verdict != VerdictUnknown || got.Coverage.State != "partial" || got.Summary.Unknown == 0 { t.Fatalf("unexpected partial result: %+v", got) } input.Pods = []*corev1.Pod{{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "ns"}, Spec: gitRepoSpec()}} got, _ = Scan(input, "1.35", "1.36") if got.Verdict != VerdictBlocked { t.Fatalf("definite blocker must win over unknown coverage: %+v", got) } complete137 := completeInput() complete137.Pods = []*corev1.Pod{{ ObjectMeta: metav1.ObjectMeta{Name: "control-plane-node-a", Namespace: "kube-system", Annotations: map[string]string{corev1.MirrorPodAnnotationKey: "mirror"}}, Spec: corev1.PodSpec{Containers: []corev1.Container{ {Name: "kube-apiserver"}, {Name: "kube-controller-manager"}, {Name: "kube-scheduler"}, }}, }} got, _ = Scan(complete137, "1.36", "1.37") if got.Verdict != VerdictReview || got.ReviewedThrough != "1.37" { t.Fatalf("reviewed 1.37 target should surface the manual cAdvisor review with otherwise clean evidence: %+v", got) } } func TestScanWarningAndReviewVerdictsRemainDistinct(t *testing.T) { reviewInput := completeInput() reviewInput.Services = []*corev1.Service{{ObjectMeta: metav1.ObjectMeta{Name: "legacy", Namespace: "default"}, Spec: corev1.ServiceSpec{ExternalIPs: []string{"203.0.113.10"}}}} review, err := Scan(reviewInput, "1.35", "1.36") if err != nil { t.Fatal(err) } if review.Verdict != VerdictReview || review.Summary.Reviews != 1 || review.Summary.Warnings != 0 { t.Fatalf("review-only result = %+v", review) } warningInput := completeInput() warningInput.Nodes[0].Status.Conditions[0].Status = corev1.ConditionFalse warning, err := Scan(warningInput, "1.35", "1.36") if err != nil { t.Fatal(err) } if warning.Verdict != VerdictWarning || warning.Summary.Warnings != 1 || warning.Summary.Reviews != 0 { t.Fatalf("warning result = %+v", warning) } } func TestFinalizeCheckUsesHighestActionLevel(t *testing.T) { for _, tc := range []struct { name string findings []Finding want CheckStatus wantLevel Level }{ {name: "review", findings: []Finding{{Level: LevelReview}}, want: CheckReview, wantLevel: LevelReview}, {name: "warning over review", findings: []Finding{{Level: LevelReview}, {Level: LevelWarning}}, want: CheckWarning, wantLevel: LevelWarning}, {name: "blocker over warning", findings: []Finding{{Level: LevelWarning}, {Level: LevelBlocker}}, want: CheckBlocked, wantLevel: LevelBlocker}, } { t.Run(tc.name, func(t *testing.T) { check := Check{Status: CheckPassed, Findings: tc.findings} finalizeCheck(&check) if check.Status != tc.want || check.Findings[0].Level != tc.wantLevel { t.Fatalf("finalized check = %+v, want status %s and highest action first", check, tc.want) } }) } } func TestFinalizeCheckSortsEquivalentResourcesByEvidence(t *testing.T) { resource := &ResourceRef{Kind: "Deployment", Namespace: "default", Name: "api"} check := Check{Status: CheckPassed, Findings: []Finding{ {Level: LevelReview, Resource: resource, Evidence: Evidence{Source: "live", Path: "spec.z"}}, {Level: LevelReview, Resource: resource, Evidence: Evidence{Source: "live", Path: "spec.a"}}, }} finalizeCheck(&check) if check.Findings[0].Evidence.Path != "spec.a" || check.Findings[1].Evidence.Path != "spec.z" { t.Fatalf("finding order = %+v, want deterministic evidence-path order", check.Findings) } } func TestFormatBoundedList(t *testing.T) { got := formatBoundedList([]string{"z", "e", "d", "c", "b", "a", "a"}, ", ") if got != "a, b, c, d, e, and 1 more" { t.Fatalf("formatted list = %q", got) } if got := scopedCoverageNote([]string{}, "source manifests"); got != "" { t.Fatalf("empty scope note = %q, want empty", got) } } func TestScanDeduplicatesControllerOwnedPods(t *testing.T) { controller := true input := completeInput() input.Pods = []*corev1.Pod{{ObjectMeta: metav1.ObjectMeta{Name: "api-abc", Namespace: "ns", OwnerReferences: []metav1.OwnerReference{{APIVersion: "apps/v1", Kind: "ReplicaSet", Name: "api-abc", Controller: &controller}}}, Spec: gitRepoSpec()}} input.ReplicaSets = []*appsv1.ReplicaSet{{ObjectMeta: metav1.ObjectMeta{Name: "api-abc", Namespace: "ns", OwnerReferences: []metav1.OwnerReference{{APIVersion: "apps/v1", Kind: "Deployment", Name: "api", Controller: &controller}}}, Spec: appsv1.ReplicaSetSpec{Template: corev1.PodTemplateSpec{Spec: gitRepoSpec()}}}} input.Deployments = []*appsv1.Deployment{{ObjectMeta: metav1.ObjectMeta{Name: "api", Namespace: "ns", Annotations: map[string]string{"meta.helm.sh/release-name": "api", "meta.helm.sh/release-namespace": "ns"}}, Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: gitRepoSpec()}}}} got, err := Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } check := checkByID(t, got, "gitrepo-volume-removed") if len(check.Findings) != 1 || check.Inspected != 1 || check.Findings[0].ManagedBy == nil || check.Findings[0].ManagedBy.Kind != "HelmRelease" { t.Fatalf("controller chain was not deduplicated: %+v", check) } } func TestScanValidatesVersions(t *testing.T) { for _, tc := range []struct { current string target string want error }{{"nope", "1.36", ErrInvalidCurrentVersion}, {"1.35", "1.36.1", ErrInvalidTargetVersion}, {"1.35", "1.99999", ErrInvalidTargetVersion}, {"1.35", "1.35", ErrNonForwardTarget}} { _, err := Scan(completeInput(), tc.current, tc.target) if !errors.Is(err, tc.want) { t.Fatalf("Scan(%q, %q) error = %v, want %v", tc.current, tc.target, err, tc.want) } } if got, err := EffectiveTarget("v1.36.4", ""); err != nil || got != "1.37" { t.Fatalf("EffectiveTarget default = %q, %v, want 1.37", got, err) } if got, err := EffectiveTarget("v1.36.4", "v1.38"); err != nil || got != "1.38" { t.Fatalf("EffectiveTarget explicit = %q, %v, want 1.38", got, err) } if got, err := UpgradePathIncludesRelease("v1.36.4", "v1.38", "1.37"); err != nil || !got { t.Fatalf("UpgradePathIncludesRelease crossing = %v, %v, want true", got, err) } if got, err := UpgradePathIncludesRelease("v1.37.1", "1.38", "1.37"); err != nil || got { t.Fatalf("UpgradePathIncludesRelease already crossed = %v, %v, want false", got, err) } if got, err := UpgradePathIncludesKubeProxyModeTransition("v1.37.1", "1.38"); err != nil || !got { t.Fatalf("UpgradePathIncludesKubeProxyModeTransition before 1.40 = %v, %v, want true", got, err) } if got, err := UpgradePathIncludesKubeProxyModeTransition("v1.40.0", "1.41"); err != nil || got { t.Fatalf("UpgradePathIncludesKubeProxyModeTransition after 1.40 = %v, %v, want false", got, err) } } func TestUnavailableKindsIncludes137CachedEvidence(t *testing.T) { input := completeInput() input.ConfigMaps = nil input.PersistentVolumeClaims = nil input.Events = nil got, err := Scan(input, "1.36", "1.37") if err != nil { t.Fatal(err) } for _, kind := range []string{"configmaps", "persistentvolumeclaims", "events"} { if !slices.Contains(got.Coverage.UnavailableKinds, kind) { t.Fatalf("unavailable kinds = %v, want %s", got.Coverage.UnavailableKinds, kind) } } got, err = Scan(input, "1.35", "1.36") if err != nil { t.Fatal(err) } for _, kind := range []string{"configmaps", "persistentvolumeclaims", "events"} { if slices.Contains(got.Coverage.UnavailableKinds, kind) { t.Fatalf("unavailable kinds = %v, did not want unused %s evidence", got.Coverage.UnavailableKinds, kind) } } got, err = Scan(input, "1.34", "1.36") if err != nil { t.Fatal(err) } if !slices.Contains(got.Coverage.UnavailableKinds, "events") { t.Fatalf("unavailable kinds = %v, want Events for the crossed 1.35 check", got.Coverage.UnavailableKinds) } for _, kind := range []string{"configmaps", "persistentvolumeclaims"} { if slices.Contains(got.Coverage.UnavailableKinds, kind) { t.Fatalf("unavailable kinds = %v, did not want unused %s evidence", got.Coverage.UnavailableKinds, kind) } } got, err = Scan(input, "1.37", "1.38") if err != nil { t.Fatal(err) } if !slices.Contains(got.Coverage.UnavailableKinds, "configmaps") { t.Fatalf("unavailable kinds = %v, want configmaps while the kube-proxy transition review remains active", got.Coverage.UnavailableKinds) } } func TestScanUpgradePathCapsRenderedSequence(t *testing.T) { check := scanUpgradePath(utilversion.MustParseGeneric("1.34"), utilversion.MustParseGeneric("1.9999")) if len(check.Findings) != 1 { t.Fatalf("findings = %+v", check.Findings) } sequence := check.Findings[0].Evidence.Detail if !strings.Contains(sequence, "…") || !strings.HasSuffix(sequence, "1.9999") || strings.Count(sequence, "→") != 9 || len(sequence) > 100 { t.Fatalf("uncapped upgrade sequence = %q", sequence) } } func TestEffectiveTargetNormalizesEquivalentSpellings(t *testing.T) { cases := []struct { current, target, want string }{ {"v1.33.4-gke.1", "", "1.34"}, {"v1.33.4-gke.1", "1.34", "1.34"}, {"v1.33.4-gke.1", "v1.34", "1.34"}, } for _, c := range cases { got, err := EffectiveTarget(c.current, c.target) if err != nil || got != c.want { t.Fatalf("EffectiveTarget(%q, %q) = (%q, %v), want %q — equivalent spellings must share one memo key", c.current, c.target, got, err, c.want) } } if _, err := EffectiveTarget("v1.33.4", "1.33"); err == nil { t.Fatal("EffectiveTarget accepted a non-forward target") } if _, err := EffectiveTarget("bogus", "1.34"); err == nil { t.Fatal("EffectiveTarget accepted an unparseable current version") } }