diff --git a/pkg/kubescape/kubescape.go b/pkg/kubescape/kubescape.go index e2227fa..eb97bef 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,14 @@ 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. + // 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). WithContext("namespace", namespace). @@ -504,7 +479,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..6d68f6d 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,239 @@ 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) + + // 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) { + 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, } }