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