From 22ed6c7fa557aca231ed7f06b4e205093870462f Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 1 Sep 2026 15:53:00 +0300 Subject: [PATCH 1/2] fix(kubescape): report health correctly and make the level filter work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_health reported `healthy: false` on a fully working Kubescape install, and the `level` filter on list_vulnerability_manifests silently did nothing. Both were verified on a live cluster running the kubescape-operator chart 1.40.4. Three fixes: 1. The spdx.softwarecomposition.kubescape.io resources are served by an aggregated API server (the storage service, wired via an APIService), not by CRDs. The four CustomResourceDefinitions().Get() lookups therefore returned IsNotFound on every install, forever, so check_health always reported vulnerability_crd and configuration_crd as errors and failed the whole check. Availability is now probed by listing each resource through the storage client the data tools already use, which also proves the path those tools depend on rather than a proxy for it. A single list per resource answers both "is the API reachable?" and "is there data?", so this makes four fewer API calls than before. The apiextensions client is no longer needed and is removed. Check keys are unchanged (*_crd) so existing consumers of the output are unaffected; only the lookup and the message wording change. 2. The pod label selectors matched the wrong pods. app.kubernetes.io/name=kubescape-operator is the chart-wide label carried by every pod the chart creates, so "operator_pods: 7/7" was really an all-kubescape-pods count, and app.kubernetes.io/name=storage matched nothing at all — storage_pods reported "No storage pods found" while the storage pod was Running. Per-component identity lives in the plain `app` label, so the selectors are now app=operator and app=storage. A missing storage pod is now an error that fails the health check rather than a warning: every data tool in this provider reads through the storage service. 3. list_vulnerability_manifests advertised a `level` filter but passed it as a labelSelector, and the storage API server ignores labelSelector on list (kubescape/storage#363) — all three values returned byte-identical output. Filtering now happens client-side on the same image-level/workload-level predicate the response already reports, so the filter and the output cannot disagree. An unrecognised level is now a validation error instead of silently meaning "both". Tests are fake-client based and need no cluster. The level-filter test installs a reactor that reproduces the storage server's actual behaviour of ignoring labelSelector, so a server-side filter cannot pass it. Signed-off-by: Ben Hirschberg Docs-exempt: bug fix; no existing doc describes check_health or the level filter Signed-off-by: Ben --- pkg/kubescape/kubescape.go | 443 +++++++++++++++----------------- pkg/kubescape/kubescape_test.go | 396 +++++++++++++++++++++------- pkg/kubescape/testing.go | 9 +- 3 files changed, 514 insertions(+), 334 deletions(-) diff --git a/pkg/kubescape/kubescape.go b/pkg/kubescape/kubescape.go index e2227fa..fefded8 100644 --- a/pkg/kubescape/kubescape.go +++ b/pkg/kubescape/kubescape.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "strings" "time" "github.com/kagent-dev/tools/internal/errors" @@ -15,8 +14,8 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" corev1 "k8s.io/api/core/v1" - apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" @@ -26,24 +25,31 @@ import ( const ( defaultKubescapeNamespace = "kubescape" - // CRD names - vulnerabilityManifestsCRD = "vulnerabilitymanifests.spdx.softwarecomposition.kubescape.io" - workloadConfigurationScansCRD = "workloadconfigurationscans.spdx.softwarecomposition.kubescape.io" - applicationProfilesCRD = "applicationprofiles.spdx.softwarecomposition.kubescape.io" - networkNeighborhoodsCRD = "networkneighborhoods.spdx.softwarecomposition.kubescape.io" - sbomSyftsCRD = "sbomsyfts.spdx.softwarecomposition.kubescape.io" - - // Pod labels - operatorPodLabel = "app.kubernetes.io/name=kubescape-operator" - storagePodLabel = "app.kubernetes.io/name=storage" + // Pod labels. + // + // Every pod the kubescape-operator chart creates carries the chart-wide + // label app.kubernetes.io/name=kubescape-operator, so it cannot identify a + // single component. Per-component identity lives in the plain `app` label. + operatorPodLabel = "app=operator" + storagePodLabel = "app=storage" + + // Vulnerability manifest levels accepted by the `level` argument of + // kubescape_list_vulnerability_manifests. + levelImage = "image" + levelWorkload = "workload" + levelBoth = "both" + + // Helm remediation offered when a capability looks disabled. + enableVulnerabilityScan = "Enable vulnerability scanning: helm upgrade --install kubescape kubescape/kubescape-operator -n kubescape --set capabilities.vulnerabilityScan=enable" + enableContinuousScan = "Enable configuration scanning: helm upgrade --install kubescape kubescape/kubescape-operator -n kubescape --set capabilities.continuousScan=enable" + enableRuntimeObservability = "Enable runtime observability for workload behavior and network analysis: helm upgrade kubescape kubescape/kubescape-operator -n kubescape --set capabilities.runtimeObservability=enable" ) // KubescapeTool holds the clients for Kubescape and Kubernetes APIs type KubescapeTool struct { - spdxClient spdxv1beta1.SpdxV1beta1Interface - k8sClient kubernetes.Interface - apiExtClient apiextensionsclientset.Interface - initError error + spdxClient spdxv1beta1.SpdxV1beta1Interface + k8sClient kubernetes.Interface + initError error } // NewKubescapeTool creates a new KubescapeTool with Kubernetes clients @@ -64,14 +70,6 @@ func NewKubescapeTool(kubeconfig string) *KubescapeTool { } tool.k8sClient = k8sClient - // Create API extensions client for CRD checks - apiExtClient, err := apiextensionsclientset.NewForConfig(config) - if err != nil { - tool.initError = fmt.Errorf("failed to create apiextensions client: %w", err) - return tool - } - tool.apiExtClient = apiExtClient - // Create Kubescape storage client spdxClient, err := spdxv1beta1.NewForConfig(config) if err != nil { @@ -114,6 +112,123 @@ type CheckStatus struct { Details interface{} `json:"details,omitempty"` } +// storageResource describes one resource served by the Kubescape storage +// service through the aggregated API server, and how check_health reports it. +type storageResource struct { + // apiCheckKey and dataCheckKey are the keys this resource contributes to + // the health result. They keep their historical *_crd names so existing + // consumers of this tool's output are unaffected; the resources themselves + // are not CRDs. + apiCheckKey string + dataCheckKey string + // displayName is the resource kind as users see it, e.g. "VulnerabilityManifests". + displayName string + // dataNoun names the objects in prose, e.g. "vulnerability manifests". + dataNoun string + // capability is the Kubescape capability that produces this data. + capability string + // required marks resources whose absence makes Kubescape unusable and so + // fails the health check. Runtime-observability resources only warn. + required bool + // apiRecommendation is offered when the API itself is unreachable, + // dataRecommendation when it responds but holds no data. + apiRecommendation string + dataRecommendation string + // list reports how many objects exist, or why they could not be read. + list func(ctx context.Context) (int, error) +} + +// storageResources returns the resources check_health probes, in report order. +func (k *KubescapeTool) storageResources() []storageResource { + return []storageResource{ + { + apiCheckKey: "vulnerability_crd", + dataCheckKey: "vulnerability_scan_data", + displayName: "VulnerabilityManifests", + dataNoun: "vulnerability manifests", + capability: "vulnerability scanning", + required: true, + apiRecommendation: enableVulnerabilityScan, + dataRecommendation: enableVulnerabilityScan, + list: func(ctx context.Context) (int, error) { + list, err := k.spdxClient.VulnerabilityManifests(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) + if err != nil { + return 0, err + } + return len(list.Items), nil + }, + }, + { + apiCheckKey: "configuration_crd", + dataCheckKey: "configuration_scan_data", + displayName: "WorkloadConfigurationScans", + dataNoun: "configuration scans", + capability: "configuration scanning", + required: true, + apiRecommendation: enableContinuousScan, + dataRecommendation: enableContinuousScan, + list: func(ctx context.Context) (int, error) { + list, err := k.spdxClient.WorkloadConfigurationScans(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) + if err != nil { + return 0, err + } + return len(list.Items), nil + }, + }, + { + apiCheckKey: "application_profiles_crd", + dataCheckKey: "application_profiles_data", + displayName: "ApplicationProfiles", + dataNoun: "application profiles", + capability: "runtime observability", + apiRecommendation: enableRuntimeObservability, + list: func(ctx context.Context) (int, error) { + list, err := k.spdxClient.ApplicationProfiles(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) + if err != nil { + return 0, err + } + return len(list.Items), nil + }, + }, + { + apiCheckKey: "network_neighborhoods_crd", + dataCheckKey: "network_neighborhoods_data", + displayName: "NetworkNeighborhoods", + dataNoun: "network neighborhoods", + capability: "runtime observability", + apiRecommendation: enableRuntimeObservability, + list: func(ctx context.Context) (int, error) { + list, err := k.spdxClient.NetworkNeighborhoods(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) + if err != nil { + return 0, err + } + return len(list.Items), nil + }, + }, + } +} + +// isStorageAPIUnavailable reports whether err means the aggregated API could +// not be reached at all, as opposed to a request that reached it and failed. +// The API server returns 503 when the storage service is down, and 404 when the +// API group is not registered. +func isStorageAPIUnavailable(err error) bool { + return k8serrors.IsNotFound(err) || + k8serrors.IsServiceUnavailable(err) || + meta.IsNoMatchError(err) +} + +// appendUnique adds rec unless it is already present, so resources that share a +// capability do not recommend the same fix twice. +func appendUnique(recommendations []string, rec string) []string { + for _, existing := range recommendations { + if existing == rec { + return recommendations + } + } + return append(recommendations, rec) +} + // handleCheckHealth verifies Kubescape operator installation and readiness func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { if k.initError != nil { @@ -211,10 +326,14 @@ func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request mcp.CallT } result.Healthy = false } else if len(storagePods.Items) == 0 { + // The storage service backs every read this provider makes, so its + // absence is fatal rather than advisory. result.Checks["storage_pods"] = CheckStatus{ - Status: "warning", - Message: "No storage pods found (may be using external storage)", + Status: "error", + Message: "No storage pods found - every Kubescape data tool depends on the storage service", } + result.Healthy = false + recommendations = append(recommendations, fmt.Sprintf("Check the storage deployment: kubectl get pods -n %s -l %s", namespace, storagePodLabel)) } else { runningCount := 0 for _, pod := range storagePods.Items { @@ -235,210 +354,66 @@ func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request mcp.CallT } } - // Check 4: VulnerabilityManifests CRD exists - _, err = k.apiExtClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, vulnerabilityManifestsCRD, metav1.GetOptions{}) - if err != nil { - if k8serrors.IsNotFound(err) { - result.Checks["vulnerability_crd"] = CheckStatus{ - Status: "error", - Message: "VulnerabilityManifests CRD not installed - vulnerability scanning may not be enabled", - } - result.Healthy = false - recommendations = append(recommendations, - "Enable vulnerability scanning in Kubescape Helm chart: helm upgrade --install kubescape kubescape/kubescape-operator -n kubescape --set capabilities.vulnerabilityScan=enable") - } else { - result.Checks["vulnerability_crd"] = CheckStatus{ - Status: "error", - Message: fmt.Sprintf("Failed to check CRD: %v", err), - } - result.Healthy = false - } - } else { - result.Checks["vulnerability_crd"] = CheckStatus{ - Status: "ok", - Message: "CRD installed", - } - } - - // Check 5: WorkloadConfigurationScans CRD exists - _, err = k.apiExtClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, workloadConfigurationScansCRD, metav1.GetOptions{}) - if err != nil { - if k8serrors.IsNotFound(err) { - result.Checks["configuration_crd"] = CheckStatus{ - Status: "error", - Message: "WorkloadConfigurationScans CRD not installed - configuration scanning may not be enabled", - } - result.Healthy = false - recommendations = append(recommendations, - "Enable configuration scanning in Kubescape Helm chart: helm upgrade --install kubescape kubescape/kubescape-operator -n kubescape --set capabilities.continuousScan=enable") - } else { - result.Checks["configuration_crd"] = CheckStatus{ - Status: "error", - Message: fmt.Sprintf("Failed to check CRD: %v", err), - } - result.Healthy = false - } - } else { - result.Checks["configuration_crd"] = CheckStatus{ - Status: "ok", - Message: "CRD installed", - } - } - - // Check 6: Vulnerability scan data available - manifests, err := k.spdxClient.VulnerabilityManifests(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1}) - if err != nil { - result.Checks["vulnerability_scan_data"] = CheckStatus{ - Status: "warning", - Message: fmt.Sprintf("Failed to list vulnerability manifests: %v", err), - } - } else if len(manifests.Items) == 0 { - result.Checks["vulnerability_scan_data"] = CheckStatus{ - Status: "warning", - Message: "No vulnerability manifests found - scans may not have completed yet or vulnerability scanning may be disabled", - } - recommendations = append(recommendations, - "If vulnerability scanning is not working, ensure it is enabled: helm upgrade kubescape kubescape/kubescape-operator -n kubescape --set capabilities.vulnerabilityScan=enable") - } else { - // Get actual count - allManifests, _ := k.spdxClient.VulnerabilityManifests(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) - count := 0 - if allManifests != nil { - count = len(allManifests.Items) - } - result.Checks["vulnerability_scan_data"] = CheckStatus{ - Status: "ok", - Message: fmt.Sprintf("%d vulnerability manifests found", count), - } - } - - // Check 7: Configuration scan data available - configScans, err := k.spdxClient.WorkloadConfigurationScans(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1}) - if err != nil { - result.Checks["configuration_scan_data"] = CheckStatus{ - Status: "warning", - Message: fmt.Sprintf("Failed to list configuration scans: %v", err), - } - } else if len(configScans.Items) == 0 { - result.Checks["configuration_scan_data"] = CheckStatus{ - Status: "warning", - Message: "No configuration scans found - scans may not have completed yet or continuous scanning may be disabled", - } - recommendations = append(recommendations, - "If configuration scanning is not working, ensure it is enabled: helm upgrade kubescape kubescape/kubescape-operator -n kubescape --set capabilities.continuousScan=enable") - } else { - // Get actual count - allConfigScans, _ := k.spdxClient.WorkloadConfigurationScans(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) - count := 0 - if allConfigScans != nil { - count = len(allConfigScans.Items) - } - result.Checks["configuration_scan_data"] = CheckStatus{ - Status: "ok", - Message: fmt.Sprintf("%d configuration scans found", count), - } - } - - // Check 8: ApplicationProfiles CRD exists (runtime observability) - _, err = k.apiExtClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, applicationProfilesCRD, metav1.GetOptions{}) - if err != nil { - if k8serrors.IsNotFound(err) { - result.Checks["application_profiles_crd"] = CheckStatus{ - Status: "warning", - Message: "ApplicationProfiles CRD not installed - runtime observability may not be enabled", + // Checks 4-9: the storage-backed resources. + // + // These resources are served by the Kubescape storage service through an + // aggregated API server, NOT by CRDs -- so their availability is probed by + // listing them, which is also exactly what the data tools do. A single list + // per resource answers both "is the API there?" and "is there any data?". + for _, res := range k.storageResources() { + count, listErr := res.list(ctx) + switch { + case listErr == nil: + result.Checks[res.apiCheckKey] = CheckStatus{ + Status: "ok", + Message: fmt.Sprintf("%s API available", res.displayName), } - recommendations = append(recommendations, - "Enable runtime observability for workload behavior analysis: helm upgrade kubescape kubescape/kubescape-operator -n kubescape --set capabilities.runtimeObservability=enable") - } else { - result.Checks["application_profiles_crd"] = CheckStatus{ - Status: "error", - Message: fmt.Sprintf("Failed to check CRD: %v", err), + if count == 0 { + result.Checks[res.dataCheckKey] = CheckStatus{ + Status: "warning", + Message: fmt.Sprintf("No %s found - scans may not have completed yet", res.dataNoun), + } + if res.dataRecommendation != "" { + recommendations = appendUnique(recommendations, res.dataRecommendation) + } + } else { + result.Checks[res.dataCheckKey] = CheckStatus{ + Status: "ok", + Message: fmt.Sprintf("%d %s found", count, res.dataNoun), + } } - } - } else { - result.Checks["application_profiles_crd"] = CheckStatus{ - Status: "ok", - Message: "CRD installed", - } - // Check for ApplicationProfile data - profiles, listErr := k.spdxClient.ApplicationProfiles(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1}) - if listErr != nil { - result.Checks["application_profiles_data"] = CheckStatus{ - Status: "warning", - Message: fmt.Sprintf("Failed to list application profiles: %v", listErr), + case isStorageAPIUnavailable(listErr): + status := "warning" + if res.required { + status = "error" + result.Healthy = false } - } else if len(profiles.Items) == 0 { - result.Checks["application_profiles_data"] = CheckStatus{ - Status: "warning", - Message: "No application profiles found - runtime learning may not have completed yet", + result.Checks[res.apiCheckKey] = CheckStatus{ + Status: status, + Message: fmt.Sprintf( + "%s API not available - the Kubescape storage service may be unavailable, or %s may not be enabled", + res.displayName, res.capability), } - } else { - allProfiles, _ := k.spdxClient.ApplicationProfiles(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) - count := 0 - if allProfiles != nil { - count = len(allProfiles.Items) + result.Checks[res.dataCheckKey] = CheckStatus{ + Status: status, + Message: fmt.Sprintf("Cannot read %s while the %s API is unavailable", res.dataNoun, res.displayName), } - result.Checks["application_profiles_data"] = CheckStatus{ - Status: "ok", - Message: fmt.Sprintf("%d application profiles found", count), + if res.apiRecommendation != "" { + recommendations = appendUnique(recommendations, res.apiRecommendation) } - } - } - // Check 9: NetworkNeighborhoods CRD exists (runtime observability) - _, err = k.apiExtClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, networkNeighborhoodsCRD, metav1.GetOptions{}) - if err != nil { - if k8serrors.IsNotFound(err) { - result.Checks["network_neighborhoods_crd"] = CheckStatus{ - Status: "warning", - Message: "NetworkNeighborhoods CRD not installed - runtime observability may not be enabled", - } - // Only add recommendation if not already added from ApplicationProfiles check - hasRuntimeRecommendation := false - for _, r := range recommendations { - if strings.Contains(r, "runtimeObservability") { - hasRuntimeRecommendation = true - break - } - } - if !hasRuntimeRecommendation { - recommendations = append(recommendations, - "Enable runtime observability for network analysis: helm upgrade kubescape kubescape/kubescape-operator -n kubescape --set capabilities.runtimeObservability=enable") - } - } else { - result.Checks["network_neighborhoods_crd"] = CheckStatus{ + default: + result.Checks[res.apiCheckKey] = CheckStatus{ Status: "error", - Message: fmt.Sprintf("Failed to check CRD: %v", err), - } - } - } else { - result.Checks["network_neighborhoods_crd"] = CheckStatus{ - Status: "ok", - Message: "CRD installed", - } - - // Check for NetworkNeighborhood data - neighborhoods, listErr := k.spdxClient.NetworkNeighborhoods(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1}) - if listErr != nil { - result.Checks["network_neighborhoods_data"] = CheckStatus{ - Status: "warning", - Message: fmt.Sprintf("Failed to list network neighborhoods: %v", listErr), - } - } else if len(neighborhoods.Items) == 0 { - result.Checks["network_neighborhoods_data"] = CheckStatus{ - Status: "warning", - Message: "No network neighborhoods found - runtime learning may not have completed yet", + Message: fmt.Sprintf("Failed to query %s: %v", res.displayName, listErr), } - } else { - allNeighborhoods, _ := k.spdxClient.NetworkNeighborhoods(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) - count := 0 - if allNeighborhoods != nil { - count = len(allNeighborhoods.Items) + result.Checks[res.dataCheckKey] = CheckStatus{ + Status: "error", + Message: fmt.Sprintf("Failed to list %s: %v", res.dataNoun, listErr), } - result.Checks["network_neighborhoods_data"] = CheckStatus{ - Status: "ok", - Message: fmt.Sprintf("%d network neighborhoods found", count), + if res.required { + result.Healthy = false } } } @@ -472,13 +447,12 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re namespace := mcp.ParseString(request, "namespace", "") level := mcp.ParseString(request, "level", "both") - // Build label selector based on level - labelSelector := "" switch level { - case "workload": - labelSelector = "kubescape.io/context=filtered" - case "image": - labelSelector = "kubescape.io/context=non-filtered" + case levelImage, levelWorkload, levelBoth: + default: + toolErr := errors.NewKubescapeError("list_vulnerability_manifests", + fmt.Errorf("invalid level %q: must be one of %q, %q or %q", level, levelImage, levelWorkload, levelBoth)) + return toolErr.ToMCPResult(), nil } // Determine namespace to query @@ -487,13 +461,11 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re queryNamespace = namespace } - // List manifests - listOpts := metav1.ListOptions{} - if labelSelector != "" { - listOpts.LabelSelector = labelSelector - } - - manifests, err := k.spdxClient.VulnerabilityManifests(queryNamespace).List(ctx, listOpts) + // Filtering is done client-side below rather than with a labelSelector: the + // Kubescape storage API server ignores labelSelector on list and returns + // every object regardless (kubescape/storage#363), so a server-side filter + // silently returns unfiltered results. + manifests, err := k.spdxClient.VulnerabilityManifests(queryNamespace).List(ctx, metav1.ListOptions{}) if err != nil { toolErr := errors.NewKubescapeError("list_vulnerability_manifests", err). WithContext("namespace", namespace). @@ -504,7 +476,14 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re // Build response vulnerabilityManifests := []map[string]interface{}{} for _, manifest := range manifests.Items { + // A workload-level manifest carries the workload it was filtered for; + // an image-level one does not. This is the same predicate reported as + // image_level/workload_level below, so the filter and the output can + // never disagree. isImageLevel := manifest.Annotations[helpersv1.WlidMetadataKey] == "" + if (level == levelImage && !isImageLevel) || (level == levelWorkload && isImageLevel) { + continue + } manifestMap := map[string]interface{}{ "namespace": manifest.Namespace, "manifest_name": manifest.Name, diff --git a/pkg/kubescape/kubescape_test.go b/pkg/kubescape/kubescape_test.go index 2b0bcaf..382146b 100644 --- a/pkg/kubescape/kubescape_test.go +++ b/pkg/kubescape/kubescape_test.go @@ -6,6 +6,7 @@ import ( "errors" "testing" + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" kubescapefake "github.com/kubescape/storage/pkg/generated/clientset/versioned/fake" "github.com/mark3labs/mcp-go/mcp" @@ -13,10 +14,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - apiextensionsfake "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/fake" + k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" kubefake "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" ) // Helper function to create a CallToolRequest with arguments @@ -88,7 +90,7 @@ func TestHandleCheckHealth_AllComponentsHealthy(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "kubescape-operator-123", Namespace: "kubescape", - Labels: map[string]string{"app.kubernetes.io/name": "kubescape-operator"}, + Labels: map[string]string{"app.kubernetes.io/name": "kubescape-operator", "app": "operator"}, }, Status: corev1.PodStatus{Phase: corev1.PodRunning}, }, @@ -97,29 +99,12 @@ func TestHandleCheckHealth_AllComponentsHealthy(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "storage-123", Namespace: "kubescape", - Labels: map[string]string{"app.kubernetes.io/name": "storage"}, + Labels: map[string]string{"app.kubernetes.io/name": "kubescape-operator", "app": "storage"}, }, Status: corev1.PodStatus{Phase: corev1.PodRunning}, }, ) - //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs - apiExtClient := apiextensionsfake.NewSimpleClientset( - &apiextensionsv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: vulnerabilityManifestsCRD}, - }, - &apiextensionsv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: workloadConfigurationScansCRD}, - }, - &apiextensionsv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: applicationProfilesCRD}, - }, - &apiextensionsv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: networkNeighborhoodsCRD}, - }, - // NOTE: SBOM CRD check is disabled (SBOM tools are too large for LLM context) - ) - spdxClient := kubescapefake.NewClientset( &v1beta1.VulnerabilityManifest{ ObjectMeta: metav1.ObjectMeta{ @@ -148,7 +133,7 @@ func TestHandleCheckHealth_AllComponentsHealthy(t *testing.T) { // NOTE: SBOM data check is disabled (SBOM tools are too large for LLM context) ) - tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(k8sClient, spdxClient.SpdxV1beta1()) result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -180,11 +165,9 @@ func TestHandleCheckHealth_AllComponentsHealthy(t *testing.T) { func TestHandleCheckHealth_NamespaceNotFound(t *testing.T) { //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs k8sClient := kubefake.NewSimpleClientset() // No namespace - //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs - apiExtClient := apiextensionsfake.NewSimpleClientset() spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(k8sClient, spdxClient.SpdxV1beta1()) result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -205,11 +188,9 @@ func TestHandleCheckHealth_OperatorPodsNotRunning(t *testing.T) { &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kubescape"}}, // No operator pods ) - //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs - apiExtClient := apiextensionsfake.NewSimpleClientset() spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(k8sClient, spdxClient.SpdxV1beta1()) result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -233,16 +214,14 @@ func TestHandleCheckHealth_OperatorPodsUnhealthy(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "kubescape-operator-123", Namespace: "kubescape", - Labels: map[string]string{"app.kubernetes.io/name": "kubescape-operator"}, + Labels: map[string]string{"app.kubernetes.io/name": "kubescape-operator", "app": "operator"}, }, Status: corev1.PodStatus{Phase: corev1.PodPending}, // Not running }, ) - //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs - apiExtClient := apiextensionsfake.NewSimpleClientset() spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(k8sClient, spdxClient.SpdxV1beta1()) result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -257,16 +236,19 @@ func TestHandleCheckHealth_OperatorPodsUnhealthy(t *testing.T) { assert.Contains(t, health.Checks["operator_pods"].Message, "0/1 pods running") } -func TestHandleCheckHealth_VulnerabilityCRDMissing(t *testing.T) { +// One resource being unreachable must fail only that resource's checks -- a +// partially degraded storage API should still report what does work. +func TestHandleCheckHealth_VulnerabilityAPIUnavailable(t *testing.T) { //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs k8sClient := kubefake.NewSimpleClientset( &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kubescape"}}, ) - //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs - apiExtClient := apiextensionsfake.NewSimpleClientset() // No CRDs spdxClient := kubescapefake.NewClientset() + spdxClient.PrependReactor("list", "vulnerabilitymanifests", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, k8serrors.NewServiceUnavailable("no response from storage service") + }) - tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(k8sClient, spdxClient.SpdxV1beta1()) result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -278,7 +260,9 @@ func TestHandleCheckHealth_VulnerabilityCRDMissing(t *testing.T) { assert.False(t, health.Healthy) assert.Equal(t, "error", health.Checks["vulnerability_crd"].Status) - assert.Contains(t, health.Checks["vulnerability_crd"].Message, "not installed") + assert.Contains(t, health.Checks["vulnerability_crd"].Message, "not available") + // The configuration API is still reachable, so it must still report ok. + assert.Equal(t, "ok", health.Checks["configuration_crd"].Status) } func TestHandleCheckHealth_NoScanData(t *testing.T) { @@ -286,18 +270,9 @@ func TestHandleCheckHealth_NoScanData(t *testing.T) { k8sClient := kubefake.NewSimpleClientset( &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kubescape"}}, ) - //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs - apiExtClient := apiextensionsfake.NewSimpleClientset( - &apiextensionsv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: vulnerabilityManifestsCRD}, - }, - &apiextensionsv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: workloadConfigurationScansCRD}, - }, - ) spdxClient := kubescapefake.NewClientset() // No vulnerability manifests - tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(k8sClient, spdxClient.SpdxV1beta1()) result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -312,24 +287,21 @@ func TestHandleCheckHealth_NoScanData(t *testing.T) { assert.Contains(t, health.Checks["vulnerability_scan_data"].Message, "No vulnerability manifests found") } -func TestHandleCheckHealth_RuntimeObservabilityCRDsMissing(t *testing.T) { +// Runtime observability is optional: its resources being unreachable warns and +// recommends enabling the capability, but does not fail the health check. +func TestHandleCheckHealth_RuntimeObservabilityAPIUnavailable(t *testing.T) { //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs k8sClient := kubefake.NewSimpleClientset( &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kubescape"}}, ) - //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs - apiExtClient := apiextensionsfake.NewSimpleClientset( - &apiextensionsv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: vulnerabilityManifestsCRD}, - }, - &apiextensionsv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: workloadConfigurationScansCRD}, - }, - // No runtime observability CRDs (applicationprofiles, networkneighborhoods) - ) spdxClient := kubescapefake.NewClientset() + for _, resource := range []string{"applicationprofiles", "networkneighborhoods"} { + spdxClient.PrependReactor("list", resource, func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, k8serrors.NewServiceUnavailable("no response from storage service") + }) + } - tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(k8sClient, spdxClient.SpdxV1beta1()) result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -339,11 +311,10 @@ func TestHandleCheckHealth_RuntimeObservabilityCRDsMissing(t *testing.T) { err = json.Unmarshal([]byte(getResultText(result)), &health) require.NoError(t, err) - // Warning for missing runtime observability CRDs assert.Equal(t, "warning", health.Checks["application_profiles_crd"].Status) - assert.Contains(t, health.Checks["application_profiles_crd"].Message, "not installed") + assert.Contains(t, health.Checks["application_profiles_crd"].Message, "not available") assert.Equal(t, "warning", health.Checks["network_neighborhoods_crd"].Status) - assert.Contains(t, health.Checks["network_neighborhoods_crd"].Message, "not installed") + assert.Contains(t, health.Checks["network_neighborhoods_crd"].Message, "not available") // Should have recommendation to enable runtime observability foundRuntimeRecommendation := false @@ -375,11 +346,9 @@ func TestHandleCheckHealth_CustomNamespace(t *testing.T) { k8sClient := kubefake.NewSimpleClientset( &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "custom-ns"}}, ) - //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs - apiExtClient := apiextensionsfake.NewSimpleClientset() spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(k8sClient, spdxClient.SpdxV1beta1()) result, err := tool.HandleCheckHealth(context.Background(), makeRequest(map[string]interface{}{ "namespace": "custom-ns", @@ -431,7 +400,7 @@ func TestHandleListVulnerabilityManifests_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListVulnerabilityManifests(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -457,7 +426,7 @@ func TestHandleListVulnerabilityManifests_FilterByNamespace(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListVulnerabilityManifests(context.Background(), makeRequest(map[string]interface{}{ "namespace": "default", @@ -474,7 +443,7 @@ func TestHandleListVulnerabilityManifests_FilterByNamespace(t *testing.T) { func TestHandleListVulnerabilityManifests_EmptyResults(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListVulnerabilityManifests(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -529,7 +498,7 @@ func TestHandleListVulnerabilitiesInManifest_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListVulnerabilitiesInManifest(context.Background(), makeRequest(map[string]interface{}{ "manifest_name": "test-manifest", @@ -550,7 +519,7 @@ func TestHandleListVulnerabilitiesInManifest_Success(t *testing.T) { func TestHandleListVulnerabilitiesInManifest_MissingManifestName(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListVulnerabilitiesInManifest(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -561,7 +530,7 @@ func TestHandleListVulnerabilitiesInManifest_MissingManifestName(t *testing.T) { func TestHandleListVulnerabilitiesInManifest_ManifestNotFound(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListVulnerabilitiesInManifest(context.Background(), makeRequest(map[string]interface{}{ "manifest_name": "nonexistent", @@ -600,7 +569,7 @@ func TestHandleGetVulnerabilityDetails_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetVulnerabilityDetails(context.Background(), makeRequest(map[string]interface{}{ "manifest_name": "test-manifest", @@ -620,7 +589,7 @@ func TestHandleGetVulnerabilityDetails_Success(t *testing.T) { func TestHandleGetVulnerabilityDetails_MissingManifestName(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetVulnerabilityDetails(context.Background(), makeRequest(map[string]interface{}{ "cve_id": "CVE-2021-1234", @@ -633,7 +602,7 @@ func TestHandleGetVulnerabilityDetails_MissingManifestName(t *testing.T) { func TestHandleGetVulnerabilityDetails_MissingCveId(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetVulnerabilityDetails(context.Background(), makeRequest(map[string]interface{}{ "manifest_name": "test-manifest", @@ -659,7 +628,7 @@ func TestHandleGetVulnerabilityDetails_CveNotFound(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetVulnerabilityDetails(context.Background(), makeRequest(map[string]interface{}{ "manifest_name": "test-manifest", @@ -687,7 +656,7 @@ func TestHandleListConfigurationScans_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListConfigurationScans(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -711,7 +680,7 @@ func TestHandleListConfigurationScans_FilterByNamespace(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListConfigurationScans(context.Background(), makeRequest(map[string]interface{}{ "namespace": "default", @@ -728,7 +697,7 @@ func TestHandleListConfigurationScans_FilterByNamespace(t *testing.T) { func TestHandleListConfigurationScans_EmptyResults(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListConfigurationScans(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -751,7 +720,7 @@ func TestHandleGetConfigurationScan_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetConfigurationScan(context.Background(), makeRequest(map[string]interface{}{ "manifest_name": "test-scan", @@ -763,7 +732,7 @@ func TestHandleGetConfigurationScan_Success(t *testing.T) { func TestHandleGetConfigurationScan_MissingManifestName(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetConfigurationScan(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -774,7 +743,7 @@ func TestHandleGetConfigurationScan_MissingManifestName(t *testing.T) { func TestHandleGetConfigurationScan_NotFound(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetConfigurationScan(context.Background(), makeRequest(map[string]interface{}{ "manifest_name": "nonexistent", @@ -807,7 +776,7 @@ func TestTruncateString(t *testing.T) { func TestNilArgumentsHandling(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) // Test with nil arguments map - should use defaults request := mcp.CallToolRequest{} @@ -851,7 +820,7 @@ func TestHandleListApplicationProfiles_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListApplicationProfiles(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -878,7 +847,7 @@ func TestHandleListApplicationProfiles_FilterByNamespace(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListApplicationProfiles(context.Background(), makeRequest(map[string]interface{}{ "namespace": "default", @@ -895,7 +864,7 @@ func TestHandleListApplicationProfiles_FilterByNamespace(t *testing.T) { func TestHandleListApplicationProfiles_EmptyResults(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListApplicationProfiles(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -942,7 +911,7 @@ func TestHandleGetApplicationProfile_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetApplicationProfile(context.Background(), makeRequest(map[string]interface{}{ "namespace": "default", @@ -963,7 +932,7 @@ func TestHandleGetApplicationProfile_Success(t *testing.T) { func TestHandleGetApplicationProfile_MissingName(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetApplicationProfile(context.Background(), makeRequest(map[string]interface{}{ "namespace": "default", @@ -976,7 +945,7 @@ func TestHandleGetApplicationProfile_MissingName(t *testing.T) { func TestHandleGetApplicationProfile_MissingNamespace(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetApplicationProfile(context.Background(), makeRequest(map[string]interface{}{ "name": "test-profile", @@ -989,7 +958,7 @@ func TestHandleGetApplicationProfile_MissingNamespace(t *testing.T) { func TestHandleGetApplicationProfile_NotFound(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetApplicationProfile(context.Background(), makeRequest(map[string]interface{}{ "namespace": "default", @@ -1031,7 +1000,7 @@ func TestHandleListNetworkNeighborhoods_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListNetworkNeighborhoods(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -1058,7 +1027,7 @@ func TestHandleListNetworkNeighborhoods_FilterByNamespace(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListNetworkNeighborhoods(context.Background(), makeRequest(map[string]interface{}{ "namespace": "default", @@ -1075,7 +1044,7 @@ func TestHandleListNetworkNeighborhoods_FilterByNamespace(t *testing.T) { func TestHandleListNetworkNeighborhoods_EmptyResults(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleListNetworkNeighborhoods(context.Background(), makeRequest(nil)) require.NoError(t, err) @@ -1120,7 +1089,7 @@ func TestHandleGetNetworkNeighborhood_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetNetworkNeighborhood(context.Background(), makeRequest(map[string]interface{}{ "namespace": "default", @@ -1141,7 +1110,7 @@ func TestHandleGetNetworkNeighborhood_Success(t *testing.T) { func TestHandleGetNetworkNeighborhood_MissingName(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetNetworkNeighborhood(context.Background(), makeRequest(map[string]interface{}{ "namespace": "default", @@ -1154,7 +1123,7 @@ func TestHandleGetNetworkNeighborhood_MissingName(t *testing.T) { func TestHandleGetNetworkNeighborhood_MissingNamespace(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetNetworkNeighborhood(context.Background(), makeRequest(map[string]interface{}{ "name": "test-nn", @@ -1167,7 +1136,7 @@ func TestHandleGetNetworkNeighborhood_MissingNamespace(t *testing.T) { func TestHandleGetNetworkNeighborhood_NotFound(t *testing.T) { spdxClient := kubescapefake.NewClientset() - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, err := tool.HandleGetNetworkNeighborhood(context.Background(), makeRequest(map[string]interface{}{ "namespace": "default", @@ -1191,3 +1160,238 @@ func TestHandleGetNetworkNeighborhood_NotFound(t *testing.T) { // func TestHandleGetSBOM_MissingName(t *testing.T) { ... } // func TestHandleGetSBOM_MissingNamespace(t *testing.T) { ... } // func TestHandleGetSBOM_NotFound(t *testing.T) { ... } + +// --------------------------------------------------------------------------- +// PR-1 regression tests (P1: aggregated-API availability, P2: pod selectors, +// P3: client-side level filter) +// --------------------------------------------------------------------------- + +// realWorldKubescapePods returns pods labelled the way the kubescape-operator +// Helm chart actually labels them: every pod carries the chart-wide +// app.kubernetes.io/name=kubescape-operator label, and per-component identity +// lives in the plain `app` label. +func realWorldKubescapePods() []runtime.Object { + pod := func(name, app string, phase corev1.PodPhase) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "kubescape", + Labels: map[string]string{ + "app.kubernetes.io/name": "kubescape-operator", + "app": app, + }, + }, + Status: corev1.PodStatus{Phase: phase}, + } + } + return []runtime.Object{ + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kubescape"}}, + pod("operator-5d854fdc8f-j75sb", "operator", corev1.PodRunning), + pod("storage-5ff6f76c7f-ngb2d", "storage", corev1.PodRunning), + pod("node-agent-7vfq2", "node-agent", corev1.PodRunning), + pod("kubevuln-c6bb59f9c-v5xlq", "kubevuln", corev1.PodRunning), + } +} + +func allSpdxResources() []runtime.Object { + return []runtime.Object{ + &v1beta1.VulnerabilityManifest{ObjectMeta: metav1.ObjectMeta{Name: "vm", Namespace: "kubescape"}}, + &v1beta1.WorkloadConfigurationScan{ObjectMeta: metav1.ObjectMeta{Name: "cs", Namespace: "kubescape"}}, + &v1beta1.ApplicationProfile{ObjectMeta: metav1.ObjectMeta{Name: "ap", Namespace: "kubescape"}}, + &v1beta1.NetworkNeighborhood{ObjectMeta: metav1.ObjectMeta{Name: "nn", Namespace: "kubescape"}}, + } +} + +func healthOf(t *testing.T, tool *KubescapeTool) HealthCheckResult { + t.Helper() + result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + var health HealthCheckResult + require.NoError(t, json.Unmarshal([]byte(getResultText(result)), &health)) + return health +} + +// P1: a working standalone install has NO CRDs for the spdx group -- the +// resources are served by an aggregated API server. Health must report healthy. +func TestHandleCheckHealth_HealthyWithAggregatedAPIAndNoCRDs(t *testing.T) { + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + k8sClient := kubefake.NewSimpleClientset(realWorldKubescapePods()...) + spdxClient := kubescapefake.NewClientset(allSpdxResources()...) + + tool := NewKubescapeToolWithClients(k8sClient, spdxClient.SpdxV1beta1()) + + health := healthOf(t, tool) + + assert.True(t, health.Healthy, "healthy should be true on a working install with no CRDs; recommendations=%v", health.Recommendations) + assert.Equal(t, "ok", health.Checks["vulnerability_crd"].Status) + assert.Equal(t, "ok", health.Checks["configuration_crd"].Status) + assert.Equal(t, "ok", health.Checks["application_profiles_crd"].Status) + assert.Equal(t, "ok", health.Checks["network_neighborhoods_crd"].Status) + assert.Equal(t, "Kubescape is fully operational", health.Summary) +} + +// P1: when the aggregated API is down, the storage-backed checks must fail and +// say so in the product's own vocabulary -- not "CRD not installed". +func TestHandleCheckHealth_AggregatedAPIUnavailable(t *testing.T) { + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + k8sClient := kubefake.NewSimpleClientset(realWorldKubescapePods()...) + spdxClient := kubescapefake.NewClientset() + spdxClient.PrependReactor("list", "*", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, k8serrors.NewServiceUnavailable("no response from storage service") + }) + + tool := NewKubescapeToolWithClients(k8sClient, spdxClient.SpdxV1beta1()) + + health := healthOf(t, tool) + + assert.False(t, health.Healthy) + assert.Equal(t, "error", health.Checks["vulnerability_crd"].Status) + assert.Equal(t, "error", health.Checks["configuration_crd"].Status) + // runtime observability stays a warning, as before + assert.Equal(t, "warning", health.Checks["application_profiles_crd"].Status) + assert.Equal(t, "warning", health.Checks["network_neighborhoods_crd"].Status) + assert.NotContains(t, health.Checks["vulnerability_crd"].Message, "CRD") + assert.Contains(t, health.Checks["vulnerability_crd"].Message, "not available") +} + +// P2: operator_pods must count the operator Deployment, not every pod the chart +// created. +func TestHandleCheckHealth_OperatorPodsCountsOnlyOperator(t *testing.T) { + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + k8sClient := kubefake.NewSimpleClientset(realWorldKubescapePods()...) + spdxClient := kubescapefake.NewClientset(allSpdxResources()...) + + tool := NewKubescapeToolWithClients(k8sClient, spdxClient.SpdxV1beta1()) + + health := healthOf(t, tool) + + assert.Equal(t, "ok", health.Checks["operator_pods"].Status) + assert.Equal(t, "1/1 pods running", health.Checks["operator_pods"].Message) +} + +// P2: the storage pod is found via app=storage, not app.kubernetes.io/name=storage. +func TestHandleCheckHealth_StoragePodFound(t *testing.T) { + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + k8sClient := kubefake.NewSimpleClientset(realWorldKubescapePods()...) + spdxClient := kubescapefake.NewClientset(allSpdxResources()...) + + tool := NewKubescapeToolWithClients(k8sClient, spdxClient.SpdxV1beta1()) + + health := healthOf(t, tool) + + assert.Equal(t, "ok", health.Checks["storage_pods"].Status) + assert.Equal(t, "1/1 pods running", health.Checks["storage_pods"].Message) +} + +// P2: nothing works without storage, so its absence is an error, not a warning. +func TestHandleCheckHealth_StoragePodMissingIsError(t *testing.T) { + objs := []runtime.Object{} + for _, o := range realWorldKubescapePods() { + if pod, ok := o.(*corev1.Pod); ok && pod.Labels["app"] == "storage" { + continue + } + objs = append(objs, o) + } + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + k8sClient := kubefake.NewSimpleClientset(objs...) + spdxClient := kubescapefake.NewClientset(allSpdxResources()...) + + tool := NewKubescapeToolWithClients(k8sClient, spdxClient.SpdxV1beta1()) + + health := healthOf(t, tool) + + assert.False(t, health.Healthy) + assert.Equal(t, "error", health.Checks["storage_pods"].Status) + assert.Contains(t, health.Checks["storage_pods"].Message, "No storage pods found") +} + +// P3: the advertised `level` filter must actually filter. The storage API +// ignores labelSelector, so filtering has to happen client-side. +func TestHandleListVulnerabilityManifests_LevelFilter(t *testing.T) { + imageLevel := &v1beta1.VulnerabilityManifest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "docker.io-library-nginx-1.14.0-e34030", + Namespace: "kubescape", + Labels: map[string]string{"kubescape.io/context": "non-filtered"}, + }, + } + workloadLevel := &v1beta1.VulnerabilityManifest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "replicaset-chatty-client-nginx-1234", + Namespace: "kubescape", + Labels: map[string]string{"kubescape.io/context": "filtered"}, + Annotations: map[string]string{ + helpersv1.WlidMetadataKey: "wlid://cluster-test/namespace-default/deployment-chatty-client", + }, + }, + } + + tests := []struct { + name string + args map[string]interface{} + wantNames []string + }{ + {"no level returns both", nil, []string{imageLevel.Name, workloadLevel.Name}}, + {"level=both returns both", map[string]interface{}{"level": "both"}, []string{imageLevel.Name, workloadLevel.Name}}, + {"level=image returns image-level only", map[string]interface{}{"level": "image"}, []string{imageLevel.Name}}, + {"level=workload returns workload-level only", map[string]interface{}{"level": "workload"}, []string{workloadLevel.Name}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spdxClient := kubescapefake.NewClientset(imageLevel, workloadLevel) + + // The real Kubescape storage API server IGNORES labelSelector on + // list (kubescape/storage#363) -- it returns every object no matter + // what is asked for. The fake clientset honours selectors, which + // would let a server-side filter pass this test while failing on a + // real cluster. This reactor reproduces the server's actual + // behaviour, and records what the provider asked for. + var sentSelector string + spdxClient.PrependReactor("list", "vulnerabilitymanifests", func(action k8stesting.Action) (bool, runtime.Object, error) { + sentSelector = action.(k8stesting.ListAction).GetListRestrictions().Labels.String() + return true, &v1beta1.VulnerabilityManifestList{ + Items: []v1beta1.VulnerabilityManifest{*imageLevel, *workloadLevel}, + }, nil + }) + + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + tool := NewKubescapeToolWithClients(kubefake.NewSimpleClientset(), spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListVulnerabilityManifests(context.Background(), makeRequest(tt.args)) + require.NoError(t, err) + + assert.Empty(t, sentSelector, "must not rely on a server-side label selector: the storage API ignores it") + + var payload struct { + Manifests []struct { + ManifestName string `json:"manifest_name"` + } `json:"vulnerability_manifests"` + TotalCount int `json:"total_count"` + } + require.NoError(t, json.Unmarshal([]byte(getResultText(result)), &payload)) + + got := []string{} + for _, m := range payload.Manifests { + got = append(got, m.ManifestName) + } + assert.ElementsMatch(t, tt.wantNames, got) + assert.Equal(t, len(tt.wantNames), payload.TotalCount) + }) + } +} + +// P3: an unrecognised level is a caller error, not a silent "both". +func TestHandleListVulnerabilityManifests_InvalidLevel(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + tool := NewKubescapeToolWithClients(kubefake.NewSimpleClientset(), spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListVulnerabilityManifests(context.Background(), makeRequest(map[string]interface{}{ + "level": "bogus", + })) + require.NoError(t, err) + assert.Contains(t, getResultText(result), "level") + assert.True(t, result.IsError) +} diff --git a/pkg/kubescape/testing.go b/pkg/kubescape/testing.go index 093e371..72d634f 100644 --- a/pkg/kubescape/testing.go +++ b/pkg/kubescape/testing.go @@ -2,21 +2,18 @@ package kubescape import ( spdxv1beta1 "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1" - apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" "k8s.io/client-go/kubernetes" ) // NewKubescapeToolWithClients creates a KubescapeTool with pre-configured clients for testing func NewKubescapeToolWithClients( k8sClient kubernetes.Interface, - apiExtClient apiextensionsclientset.Interface, spdxClient spdxv1beta1.SpdxV1beta1Interface, ) *KubescapeTool { return &KubescapeTool{ - k8sClient: k8sClient, - apiExtClient: apiExtClient, - spdxClient: spdxClient, - initError: nil, + k8sClient: k8sClient, + spdxClient: spdxClient, + initError: nil, } } From a639f0c682ebf1d14318701bd9fe33243fe073ff Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 1 Sep 2026 16:01:23 +0300 Subject: [PATCH 2/2] docs(kubescape): scope the labelSelector note to storage versions before v0.0.305 kubescape/storage#362 added label selector support to list operations and shipped in v0.0.305 on 2026-08-20, so the behaviour the level filter works around is not unconditional. The kubescape-operator chart still pins storage v0.0.298, and a client cannot know which server version it is talking to, so the client-side filter stays -- it returns the same answer against either. Comments only; no behaviour change. Signed-off-by: Ben Hirschberg Docs-exempt: comment-only change, no behavioral change Signed-off-by: Ben --- pkg/kubescape/kubescape.go | 11 +++++++---- pkg/kubescape/kubescape_test.go | 11 ++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/pkg/kubescape/kubescape.go b/pkg/kubescape/kubescape.go index fefded8..eb97bef 100644 --- a/pkg/kubescape/kubescape.go +++ b/pkg/kubescape/kubescape.go @@ -461,10 +461,13 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re queryNamespace = namespace } - // Filtering is done client-side below rather than with a labelSelector: the - // Kubescape storage API server ignores labelSelector on list and returns - // every object regardless (kubescape/storage#363), so a server-side filter - // silently returns unfiltered results. + // Filtering is done client-side below rather than with a labelSelector. + // Storage servers before v0.0.305 ignore labelSelector on list and return + // every object regardless, so a server-side filter silently returns + // unfiltered results. kubescape/storage#362 added selector support in + // v0.0.305, but the kubescape-operator chart still pins v0.0.298 and this + // provider cannot know which version it is talking to -- filtering here + // gives the same answer against either. manifests, err := k.spdxClient.VulnerabilityManifests(queryNamespace).List(ctx, metav1.ListOptions{}) if err != nil { toolErr := errors.NewKubescapeError("list_vulnerability_manifests", err). diff --git a/pkg/kubescape/kubescape_test.go b/pkg/kubescape/kubescape_test.go index 382146b..6d68f6d 100644 --- a/pkg/kubescape/kubescape_test.go +++ b/pkg/kubescape/kubescape_test.go @@ -1342,11 +1342,12 @@ func TestHandleListVulnerabilityManifests_LevelFilter(t *testing.T) { t.Run(tt.name, func(t *testing.T) { spdxClient := kubescapefake.NewClientset(imageLevel, workloadLevel) - // The real Kubescape storage API server IGNORES labelSelector on - // list (kubescape/storage#363) -- it returns every object no matter - // what is asked for. The fake clientset honours selectors, which - // would let a server-side filter pass this test while failing on a - // real cluster. This reactor reproduces the server's actual + // Storage servers before v0.0.305 IGNORE labelSelector on list -- + // they return every object no matter what is asked for -- and the + // kubescape-operator chart still pins v0.0.298. The fake clientset + // honours selectors, which would let a server-side filter pass this + // test while returning unfiltered results against the storage + // version actually deployed. This reactor reproduces that server's // behaviour, and records what the provider asked for. var sentSelector string spdxClient.PrependReactor("list", "vulnerabilitymanifests", func(action k8stesting.Action) (bool, runtime.Object, error) {