diff --git a/go.mod b/go.mod index df04b46..00c0221 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,6 @@ require ( go.opentelemetry.io/otel/sdk v1.46.0 go.opentelemetry.io/otel/trace v1.46.0 k8s.io/api v0.37.0 - k8s.io/apiextensions-apiserver v0.37.0 k8s.io/apimachinery v0.37.0 k8s.io/client-go v0.37.0 ) @@ -189,6 +188,7 @@ require ( google.golang.org/protobuf v1.36.12 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/apiextensions-apiserver v0.37.0 // indirect k8s.io/apiserver v0.37.0 // indirect k8s.io/component-base v0.37.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect diff --git a/pkg/kubescape/kubescape.go b/pkg/kubescape/kubescape.go index dcadfee..6bd4110 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" @@ -14,8 +13,8 @@ import ( spdxv1beta1 "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" 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" @@ -25,16 +24,24 @@ 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" ) // kubescapeErrResult adapts ToolError to an MCP error result. @@ -44,10 +51,9 @@ func kubescapeErrResult(toolErr *errors.ToolError) *sdkmcp.CallToolResult { // 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 @@ -68,14 +74,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 { @@ -307,6 +305,123 @@ type getNetworkNeighborhoodOutput struct { Description string `json:"description"` } +// 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 *sdkmcp.CallToolRequest, in checkHealthInput) (*sdkmcp.CallToolResult, HealthCheckResult, error) { if k.initError != nil { @@ -407,10 +522,14 @@ func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request *sdkmcp.C } 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 { @@ -431,210 +550,66 @@ func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request *sdkmcp.C } } - // 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 } } } @@ -668,16 +643,15 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re namespace := in.Namespace level := in.Level if level == "" { - level = "both" + level = levelBoth } - // 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 kubescapeErrResult(toolErr), listVulnerabilityManifestsOutput{}, nil } // Determine namespace to query @@ -686,13 +660,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). @@ -709,7 +684,14 @@ func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, re // is clean. An absent field cannot be mistaken for a measured zero. Use // kubescape_list_vulnerabilities with a manifest_name for real counts. 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 + } vulnerabilityManifests = append(vulnerabilityManifests, vulnerabilityManifestSummary{ Namespace: manifest.Namespace, ManifestName: manifest.Name, diff --git a/pkg/kubescape/kubescape_test.go b/pkg/kubescape/kubescape_test.go index fd9a672..4a826e3 100644 --- a/pkg/kubescape/kubescape_test.go +++ b/pkg/kubescape/kubescape_test.go @@ -6,16 +6,18 @@ 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" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "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 extract text content from MCP result @@ -47,7 +49,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}, }, @@ -56,29 +58,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{ @@ -107,7 +92,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(), checkHealthInput{}) require.NoError(t, err) @@ -139,11 +124,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(), checkHealthInput{}) require.NoError(t, err) @@ -164,11 +147,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(), checkHealthInput{}) require.NoError(t, err) @@ -192,16 +173,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(), checkHealthInput{}) require.NoError(t, err) @@ -216,16 +195,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(), checkHealthInput{}) require.NoError(t, err) @@ -237,7 +219,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) { @@ -245,18 +229,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(), checkHealthInput{}) require.NoError(t, err) @@ -271,24 +246,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(), checkHealthInput{}) require.NoError(t, err) @@ -298,11 +270,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 @@ -334,11 +305,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(), checkHealthInput{ Namespace: "custom-ns", @@ -390,7 +359,7 @@ func TestHandleListVulnerabilityManifests_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleListVulnerabilityManifests(context.Background(), listVulnerabilityManifestsInput{}) require.NoError(t, err) @@ -415,7 +384,7 @@ func TestHandleListVulnerabilityManifests_FilterByNamespace(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleListVulnerabilityManifests(context.Background(), listVulnerabilityManifestsInput{ Namespace: "default", @@ -432,7 +401,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(), listVulnerabilityManifestsInput{}) require.NoError(t, err) @@ -487,7 +456,7 @@ func TestHandleListVulnerabilitiesInManifest_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleListVulnerabilitiesInManifest(context.Background(), listVulnerabilitiesInManifestInput{ ManifestName: "test-manifest", @@ -507,7 +476,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(), listVulnerabilitiesInManifestInput{}) require.NoError(t, err) @@ -518,7 +487,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(), listVulnerabilitiesInManifestInput{ ManifestName: "nonexistent", @@ -557,7 +526,7 @@ func TestHandleGetVulnerabilityDetails_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) // The handler returns the matches as its typed Out and leaves the tool result // for the SDK to synthesise. For an array Out the SDK serialises the value @@ -576,7 +545,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(), getVulnerabilityDetailsInput{ CveID: "CVE-2021-1234", @@ -589,7 +558,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(), getVulnerabilityDetailsInput{ ManifestName: "test-manifest", @@ -615,7 +584,7 @@ func TestHandleGetVulnerabilityDetails_CveNotFound(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleGetVulnerabilityDetails(context.Background(), getVulnerabilityDetailsInput{ ManifestName: "test-manifest", @@ -643,7 +612,7 @@ func TestHandleListConfigurationScans_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleListConfigurationScans(context.Background(), listConfigurationScansInput{}) require.NoError(t, err) @@ -667,7 +636,7 @@ func TestHandleListConfigurationScans_FilterByNamespace(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleListConfigurationScans(context.Background(), listConfigurationScansInput{ Namespace: "default", @@ -684,7 +653,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(), listConfigurationScansInput{}) require.NoError(t, err) @@ -707,7 +676,7 @@ func TestHandleGetConfigurationScan_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleGetConfigurationScan(context.Background(), getConfigurationScanInput{ ManifestName: "test-scan", @@ -719,7 +688,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(), getConfigurationScanInput{}) require.NoError(t, err) @@ -730,7 +699,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(), getConfigurationScanInput{ ManifestName: "nonexistent", @@ -763,7 +732,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()) // Empty input should use defaults result, _, err := tool.HandleListVulnerabilityManifests(context.Background(), listVulnerabilityManifestsInput{}) @@ -804,7 +773,7 @@ func TestHandleListApplicationProfiles_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleListApplicationProfiles(context.Background(), listApplicationProfilesInput{}) require.NoError(t, err) @@ -830,7 +799,7 @@ func TestHandleListApplicationProfiles_FilterByNamespace(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleListApplicationProfiles(context.Background(), listApplicationProfilesInput{ Namespace: "default", @@ -847,7 +816,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(), listApplicationProfilesInput{}) require.NoError(t, err) @@ -894,7 +863,7 @@ func TestHandleGetApplicationProfile_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleGetApplicationProfile(context.Background(), getApplicationProfileInput{ Namespace: "default", @@ -915,7 +884,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(), getApplicationProfileInput{ Namespace: "default", @@ -928,7 +897,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(), getApplicationProfileInput{ Name: "test-profile", @@ -941,7 +910,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(), getApplicationProfileInput{ Namespace: "default", @@ -983,7 +952,7 @@ func TestHandleListNetworkNeighborhoods_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleListNetworkNeighborhoods(context.Background(), listNetworkNeighborhoodsInput{}) require.NoError(t, err) @@ -1009,7 +978,7 @@ func TestHandleListNetworkNeighborhoods_FilterByNamespace(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleListNetworkNeighborhoods(context.Background(), listNetworkNeighborhoodsInput{ Namespace: "default", @@ -1026,7 +995,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(), listNetworkNeighborhoodsInput{}) require.NoError(t, err) @@ -1071,7 +1040,7 @@ func TestHandleGetNetworkNeighborhood_Success(t *testing.T) { }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleGetNetworkNeighborhood(context.Background(), getNetworkNeighborhoodInput{ Namespace: "default", @@ -1092,7 +1061,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(), getNetworkNeighborhoodInput{ Namespace: "default", @@ -1105,7 +1074,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(), getNetworkNeighborhoodInput{ Name: "test-nn", @@ -1118,7 +1087,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(), getNetworkNeighborhoodInput{ Namespace: "default", @@ -1165,7 +1134,7 @@ func TestHandleListVulnerabilityManifests_OmitsVulnerabilityCount(t *testing.T) }, ) - tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + tool := NewKubescapeToolWithClients(nil, spdxClient.SpdxV1beta1()) result, _, err := tool.HandleListVulnerabilityManifests(context.Background(), listVulnerabilityManifestsInput{}) require.NoError(t, err) @@ -1180,3 +1149,237 @@ func TestHandleListVulnerabilityManifests_OmitsVulnerabilityCount(t *testing.T) // The useful metadata is still there. assert.Contains(t, getResultText(result), "nginx:1.14.0") } + +// --------------------------------------------------------------------------- +// 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(), checkHealthInput{}) + 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 + in listVulnerabilityManifestsInput + wantNames []string + }{ + {"no level returns both", listVulnerabilityManifestsInput{}, []string{imageLevel.Name, workloadLevel.Name}}, + {"level=both returns both", listVulnerabilityManifestsInput{Level: "both"}, []string{imageLevel.Name, workloadLevel.Name}}, + {"level=image returns image-level only", listVulnerabilityManifestsInput{Level: "image"}, []string{imageLevel.Name}}, + {"level=workload returns workload-level only", listVulnerabilityManifestsInput{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(), tt.in) + 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(), listVulnerabilityManifestsInput{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, } }