diff --git a/Makefile b/Makefile index 2fff7f3..50afa53 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ ARCH ?= amd64 OS ?= $(shell uname -s | tr A-Z a-z) K8S_LATEST_VER ?= $(shell curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt) export CONTROLLER_IMG ?= $(REGISTRY)/$(IMAGE_NAME) -TAG ?= v1.12.0 +TAG ?= main .PHONY: all all: build diff --git a/README.md b/README.md index ed46274..7b69f2d 100644 --- a/README.md +++ b/README.md @@ -498,6 +498,8 @@ Response fields: - `profiles` — number of accessible Profiles - `clusterSummaries` — number of accessible ClusterSummaries (one per profile+cluster pair) - `eventTriggers` — number of accessible EventTriggers +- `classifiers` — number of accessible Classifiers +- `managementClusterClassifiers` — number of accessible ManagementClusterClassifiers Each count reflects only the resources the authenticated user has permission to view. @@ -508,7 +510,7 @@ http://localhost:9000/stats returns ```json -{"capiClusters":1,"notReadyCAPIClusters":0,"sveltosClusters":1,"notReadySveltosClusters":0,"pullModeClusters":0,"clusterProfiles":12,"profiles":0,"clusterSummaries":11,"eventTriggers":1} +{"capiClusters":1,"notReadyCAPIClusters":0,"sveltosClusters":1,"notReadySveltosClusters":0,"pullModeClusters":0,"clusterProfiles":12,"profiles":0,"clusterSummaries":11,"eventTriggers":1,"classifiers":1,"managementClusterClassifiers":1} ``` ### Get list of EventTriggers @@ -540,6 +542,76 @@ Response contains: {"eventTriggerName":"service-network-policy","clusterSelector":{"matchLabels":{"env":"fv"}},"eventSource":{"kind":"EventSource","apiVersion":"lib.projectsveltos.io/v1beta1","metadata":{"name":"sveltos-service"},"spec":{"resourceSelectors":[{"group":"","version":"v1","kind":"Service","labelFilters":[{"key":"sveltos","operation":"Equal","value":"fv"}]}],"collectResources":true}},"clusterEventMatches":[{"clusterNamespace":"default","clusterName":"clusterapi-workload","clusterKind":"Cluster","labels":{"cluster.x-k8s.io/cluster-name":"clusterapi-workload","env":"fv","sveltos-agent":"present","topology.cluster.x-k8s.io/owned":""},"version":"v1.35.0","ready":true,"paused":true,"failureMessage":null,"resources":[{"name":"my-service","namespace":"default","group":"","kind":"Service","version":"","profileNames":null}]}]} ``` +### Get list of Classifiers + +```/classifiers``` + +Returns a summary of all Classifier and ManagementClusterClassifier instances the user can access. + +It is possible to filter by cluster: + +. ```cluster_namespace=``` => returns only classifiers currently matching a cluster in this namespace + +. ```cluster_name=``` => returns only classifiers currently matching a cluster with this name + +. ```cluster_type=``` => returns only classifiers currently matching a cluster of this type + +When a cluster filter is set, the list is narrowed to instances currently matching that cluster, but each entry's `matchingClusterCount` still reflects the instance's total match count across all clusters, not just the filtered one. + +This API supports pagination. Use: + +. ```limit=``` to specify the number of classifiers the API will return + +. ```skip=``` to specify from which classifier to start (classifiers are ordered by name, then type) + +For instance: + +``` +http://localhost:9000/classifiers +``` + +returns + +```json +{"totalClassifiers":2,"classifiers":[{"name":"default-classifier","type":"Classifier","labelCount":1,"matchingClusterCount":2},{"name":"tag-production-clusters","type":"ManagementClusterClassifier","labelCount":2,"matchingClusterCount":1}]} +``` + +### Get Classifier Details + +```/classifier?name=&type=``` + +Response contains: + +- Name: name of the Classifier/ManagementClusterClassifier +- Type: `Classifier` or `ManagementClusterClassifier` +- Spec fields specific to the type: resource selectors, aggregated classification and kubernetes version constraints for `Classifier`; match resources and classification Lua for `ManagementClusterClassifier` +- ClassifierLabels: the labels this instance is configured to add to matching clusters +- MatchingClusters: clusters currently matching this instance. For each cluster: the labels it currently owns there (joined from the corresponding ClassifierReport/ManagementClusterClassifierReport), and any labels this instance would like to manage but currently cannot because a different Classifier or ManagementClusterClassifier already owns them + +For instance: + +``` +http://localhost:9000/classifier?name=default-classifier&type=Classifier +``` + +returns + +```json +{"name":"default-classifier","type":"Classifier","classifierLabels":[{"key":"sveltos-agent","value":"present"}],"matchingClusters":[{"clusterNamespace":"default","clusterName":"clusterapi-workload","clusterType":"Capi","managedLabels":[{"key":"sveltos-agent","value":"present"}]},{"clusterNamespace":"mgmt","clusterName":"mgmt","clusterType":"Sveltos","managedLabels":[{"key":"sveltos-agent","value":"present"}]}]} +``` + +For a ManagementClusterClassifier: + +``` +http://localhost:9000/classifier?name=tag-production-clusters&type=ManagementClusterClassifier +``` + +returns + +```json +{"name":"tag-production-clusters","type":"ManagementClusterClassifier","matchResources":[{"group":"","version":"v1","kind":"ConfigMap","namespace":"projectsveltos","selector":{"matchLabels":{"sveltos.io/env":"production"}}}],"classificationLua":"function evaluate(resources)\n local result = {}\n for _, cm in ipairs(resources) do\n local ns = cm.data.clusterNamespace\n local name = cm.data.clusterName\n if ns ~= nil and name ~= nil then\n table.insert(result, {namespace=ns, name=name, kind=\"Cluster\"})\n end\n end\n return result\nend\n","classifierLabels":[{"key":"env","value":"fv"},{"key":"cost-centre","value":"platform"}],"matchingClusters":[{"clusterNamespace":"default","clusterName":"clusterapi-workload","clusterType":"Capi","managedLabels":[{"key":"env","value":"fv"},{"key":"cost-centre","value":"platform"}]}]} +``` + ### How to get token First, create a service account in the desired namespace: diff --git a/cmd/main.go b/cmd/main.go index 9d92794..f306a5b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -36,6 +36,7 @@ import ( "github.com/spf13/pflag" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" cliflag "k8s.io/component-base/cli/flag" @@ -73,9 +74,12 @@ var ( ) const ( - defaultReconcilers = 10 - mebibytes_bytes = 1 << 20 - gibibytes_per_bytes = 1 << 30 + defaultReconcilers = 10 + mebibytes_bytes = 1 << 20 + // memoryLimitCoefficient is the fraction of the container memory limit that + // GOMEMLIMIT is set to. Kept below 1 so Go's GC reacts to memory pressure + // and frees memory before the kubelet OOM-kills the container. + memoryLimitCoefficient = 0.75 ) // Add RBAC for the authorized diagnostics endpoint. @@ -96,6 +100,13 @@ const ( //+kubebuilder:rbac:groups=lib.projectsveltos.io,resources=eventreports,verbs=get;list;watch //+kubebuilder:rbac:groups=lib.projectsveltos.io,resources=eventreports/status,verbs=get;list;watch +//+kubebuilder:rbac:groups=lib.projectsveltos.io,resources=classifiers,verbs=get;list;watch +//+kubebuilder:rbac:groups=lib.projectsveltos.io,resources=classifiers/status,verbs=get;list;watch +//+kubebuilder:rbac:groups=lib.projectsveltos.io,resources=managementclusterclassifiers,verbs=get;list;watch +//+kubebuilder:rbac:groups=lib.projectsveltos.io,resources=managementclusterclassifiers/status,verbs=get;list;watch +//+kubebuilder:rbac:groups=lib.projectsveltos.io,resources=classifierreports,verbs=get;list;watch +//+kubebuilder:rbac:groups=lib.projectsveltos.io,resources=managementclusterclassifierreports,verbs=get;list;watch + func main() { scheme, err := controller.InitScheme() if err != nil { @@ -120,6 +131,9 @@ func main() { }), Cache: cache.Options{ SyncPeriod: &syncPeriod, + // ui-backend never reads managedFields; stripping them before they + // are committed to the cache significantly reduces its memory usage. + DefaultTransform: cache.TransformStripManagedFields(), }, PprofBindAddress: profilerAddress, } @@ -141,7 +155,7 @@ func main() { libsveltosv1beta1.ComponentUIBackend, ctrl.Log.WithName("log-setter"), ctrl.GetConfigOrDie()) - debug.SetMemoryLimit(gibibytes_per_bytes) + setMemoryLimit(ctrl.Log.WithName("memory-usage")) go printMemUsage(ctrl.Log.WithName("memory-usage")) server.InitializeManagerInstance(ctx, mgr.GetConfig(), mgr.GetClient(), scheme, @@ -292,6 +306,40 @@ func getDiagnosticsOptions() metricsserver.Options { } } +// setMemoryLimit configures GOMEMLIMIT from the container's memory limit, +// read via the TOTAL_MEMORY_LIMIT env var (populated by a resourceFieldRef to +// limits.memory in the deployment manifest). No-op if the env var is unset or +// invalid, e.g. when running outside the container manifest. +func setMemoryLimit(logger logr.Logger) { + limitStr := os.Getenv("TOTAL_MEMORY_LIMIT") + if limitStr == "" { + return + } + + quantity, err := resource.ParseQuantity(limitStr) + if err != nil { + logger.Error(err, "failed to parse TOTAL_MEMORY_LIMIT", "value", limitStr) + return + } + + limitBytes := quantity.Value() + if limitBytes <= 0 { + return + } + + aggressiveLimit := int64(float64(limitBytes) * memoryLimitCoefficient) + debug.SetMemoryLimit(aggressiveLimit) + + var goLimitMiB uint64 + if aggressiveLimit > 0 { + goLimitMiB = bToMb(uint64(aggressiveLimit)) + } + + logger.V(logs.LogDebug).Info("configured GOMEMLIMIT", + "container_limit_mib", bToMb(uint64(limitBytes)), + "go_limit_mib", goLimitMiB) +} + // printMemUsage memory stats. Call GC func printMemUsage(logger logr.Logger) { for { diff --git a/config/default/manager_image_patch.yaml b/config/default/manager_image_patch.yaml index 4700bea..eef3924 100644 --- a/config/default/manager_image_patch.yaml +++ b/config/default/manager_image_patch.yaml @@ -8,5 +8,5 @@ spec: spec: containers: # Change the value of image field below to your controller image URL - - image: projectsveltos/ui-backend:v1.12.0 + - image: projectsveltos/ui-backend:main name: manager diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index a88017c..2899368 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -50,6 +50,11 @@ spec: - /manager args: - --v=5 + env: + - name: TOTAL_MEMORY_LIMIT + valueFrom: + resourceFieldRef: + resource: limits.memory image: controller:latest name: manager ports: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index dc805a0..279d319 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -51,6 +51,9 @@ rules: - apiGroups: - lib.projectsveltos.io resources: + - classifierreports + - classifiers + - classifiers/status - debuggingconfigurations - eventreports - eventreports/status @@ -58,6 +61,9 @@ rules: - eventsources/status - eventtriggers - eventtriggers/status + - managementclusterclassifierreports + - managementclusterclassifiers + - managementclusterclassifiers/status - sveltosclusters - sveltosclusters/status verbs: diff --git a/internal/mcpclient/client.go b/internal/mcpclient/client.go index d4fc2dd..7cdbc7b 100644 --- a/internal/mcpclient/client.go +++ b/internal/mcpclient/client.go @@ -21,6 +21,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/go-logr/logr" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -39,6 +40,23 @@ const ( apiVersionKey = "apiVersion" ) +// contentToString renders MCP tool result content (typically a single TextContent block +// describing an error) as a human-readable string. mcp.Content is an interface with no +// String() method, so formatting it directly with %v prints pointer addresses instead of +// the actual message. +func contentToString(content []mcp.Content) string { + var parts []string + for _, c := range content { + if tc, ok := c.(*mcp.TextContent); ok { + parts = append(parts, tc.Text) + } + } + if len(parts) == 0 { + return fmt.Sprintf("%d content block(s)", len(content)) + } + return strings.Join(parts, "; ") +} + // connect to Sveltos mcp server func connect(ctx context.Context, url string, logger logr.Logger) (*mcp.ClientSession, error) { // Create the URL for the server. @@ -99,7 +117,7 @@ func CheckInstallation(ctx context.Context, url string, logger logr.Logger) (*Sv } if result.IsError { - errorMsg := fmt.Sprintf("MCP installation_status returned error: %v", result.Content) + errorMsg := fmt.Sprintf("MCP installation_status returned error: %s", contentToString(result.Content)) logger.V(logs.LogInfo).Info(errorMsg) return nil, errors.New(errorMsg) } @@ -183,7 +201,7 @@ func CheckProfileDeploymentOnCluster(ctx context.Context, url string, clusterRef } if result.IsError { - errorMsg := fmt.Sprintf("MCP analyze_profile_deployment returned error: %v", result.Content) + errorMsg := fmt.Sprintf("MCP analyze_profile_deployment returned error: %s", contentToString(result.Content)) logger.V(logs.LogInfo).Info(errorMsg) return nil, errors.New(errorMsg) } @@ -264,7 +282,7 @@ func CheckClusterDeploymentStatuses(ctx context.Context, url string, clusterRef } if result.IsError { - errorMsg := fmt.Sprintf("MCP list_deployement_errors returned error: %v", result.Content) + errorMsg := fmt.Sprintf("MCP list_deployement_errors returned error: %s", contentToString(result.Content)) logger.V(logs.LogInfo).Info(errorMsg) return nil, errors.New(errorMsg) } @@ -307,29 +325,19 @@ func CheckClusterDeploymentStatuses(ctx context.Context, url string, clusterRef return &deploymentResult, nil } -type EventPipelineStatus struct { - // Stage 1: Detection - ClusterReady bool `json:"clusterReady"` - ClusterPaused bool `json:"clusterPaused"` - ClusterMatched bool `json:"clusterMatched"` - ClusterProvisioned bool `json:"clusterProvisioned"` - EventSourceName string `json:"eventSourceName"` - EventSourceFoundInControlCluster bool `json:"eventSourceFoundInControlCluster"` - EventSourceFoundInManagedCluster bool `json:"eventSourceFoundInManagedCluster"` - EventReportFoundInManagedCluster bool `json:"eventReportFoundInManagedCluster"` - EventReportFoundInControlCluster bool `json:"eventReportFoundInControlCluster"` - ResourcesDetected int `json:"resourcesDetected"` - LastEventReportTime string `json:"lastEventReportTime,omitempty"` - - // Stage 2: Result - InstantiatedProfile string `json:"instantiatedProfile,omitempty"` - - // Issue Reporting: contains all detected issues +// pipelineIssues is the common shape shared by every pipeline-tracing MCP tool's structured +// result: regardless of how many stage-specific fields the tool reports, callers here only +// ever consume the final Issues list. +type pipelineIssues struct { Issues []string `json:"issues,omitempty"` } -func AnalyzeEventPipeline(ctx context.Context, url string, clusterRef *corev1.ObjectReference, - eventTriggerName string, logger logr.Logger) ([]string, error) { +// analyzePipeline invokes a pipeline-tracing MCP tool (analyze_event_deployment_pipeline, +// analyze_classifier_pipeline, ...) for one resource/cluster pair and returns the issues it +// detected. resourceKey/resourceName is the tool-specific identifier argument, e.g. +// ("eventTriggerName", "my-trigger") or ("classifierName", "my-classifier"). +func analyzePipeline(ctx context.Context, url, toolName, resourceKey, resourceName string, + clusterRef *corev1.ObjectReference, logger logr.Logger) ([]string, error) { session, err := connect(ctx, url, logger) if err != nil { @@ -338,28 +346,28 @@ func AnalyzeEventPipeline(ctx context.Context, url string, clusterRef *corev1.Ob } defer session.Close() - input := map[string]interface{}{ - "clusterRef": map[string]string{ - "namespace": clusterRef.Namespace, - "name": clusterRef.Name, - "apiVersion": clusterRef.APIVersion, - "kind": clusterRef.Kind, + input := map[string]any{ + clusterRefKey: map[string]any{ + namespaceKey: clusterRef.Namespace, + nameKey: clusterRef.Name, + kindKey: clusterRef.Kind, + apiVersionKey: clusterRef.APIVersion, }, - "eventTriggerName": eventTriggerName, + resourceKey: resourceName, } result, err := session.CallTool(ctx, &mcp.CallToolParams{ - Name: "analyze_event_deployment_pipeline", + Name: toolName, Arguments: input, }) if err != nil { - logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to invoked analyze_event_deployment_pipeline tool: %v", err)) + logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to invoked %s tool: %v", toolName, err)) return nil, err } if result.IsError { - errorMsg := fmt.Sprintf("MCP analyze_event_deployment_pipeline returned error: %v", result.Content) + errorMsg := fmt.Sprintf("MCP %s returned error: %s", toolName, contentToString(result.Content)) logger.V(logs.LogInfo).Info(errorMsg) return nil, errors.New(errorMsg) } @@ -380,19 +388,18 @@ func AnalyzeEventPipeline(ctx context.Context, url string, clusterRef *corev1.Ob return nil, errors.New(errorMsg) } - // Unmarshal into the status struct - var status EventPipelineStatus + var status pipelineIssues if err := json.Unmarshal(data, &status); err != nil { - errorMsg := fmt.Sprintf("failed to unmarshal result into EventPipelineStatus: %v", err) + errorMsg := fmt.Sprintf("failed to unmarshal %s result: %v", toolName, err) logger.V(logs.LogInfo).Info(errorMsg) return nil, errors.New(errorMsg) } - logger.V(logs.LogInfo).Info(fmt.Sprintf("analyze_profile_deployment result: %v", status)) + logger.V(logs.LogInfo).Info(fmt.Sprintf("%s result: %v", toolName, status)) if len(status.Issues) == 0 { - logger.V(logs.LogInfo).Info(fmt.Sprintf("eventTrigger %s successfully deployed on cluster %s %s/%s", - eventTriggerName, clusterRef.Kind, clusterRef.Namespace, clusterRef.Namespace)) + logger.V(logs.LogInfo).Info(fmt.Sprintf("%s successfully processed for cluster %s %s/%s", + resourceName, clusterRef.Kind, clusterRef.Namespace, clusterRef.Name)) return []string{}, nil } @@ -402,3 +409,17 @@ func AnalyzeEventPipeline(ctx context.Context, url string, clusterRef *corev1.Ob return status.Issues, nil } + +func AnalyzeEventPipeline(ctx context.Context, url string, clusterRef *corev1.ObjectReference, + eventTriggerName string, logger logr.Logger) ([]string, error) { + + return analyzePipeline(ctx, url, "analyze_event_deployment_pipeline", "eventTriggerName", + eventTriggerName, clusterRef, logger) +} + +func AnalyzeClassifierPipeline(ctx context.Context, url string, clusterRef *corev1.ObjectReference, + classifierName string, logger logr.Logger) ([]string, error) { + + return analyzePipeline(ctx, url, "analyze_classifier_pipeline", "classifierName", + classifierName, clusterRef, logger) +} diff --git a/internal/server/classifiers.go b/internal/server/classifiers.go new file mode 100644 index 0000000..ce7fcb2 --- /dev/null +++ b/internal/server/classifiers.go @@ -0,0 +1,497 @@ +/* +Copyright 2026. projectsveltos.io. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package server + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/gin-gonic/gin" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" +) + +type ClassifierSummary struct { + Name string `json:"name"` + // Type is either Classifier or ManagementClusterClassifier + Type string `json:"type"` + LabelCount int `json:"labelCount"` + MatchingClusterCount int `json:"matchingClusterCount"` +} + +type ClassifierSummaries []ClassifierSummary + +func (s ClassifierSummaries) Len() int { return len(s) } +func (s ClassifierSummaries) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s ClassifierSummaries) Less(i, j int) bool { + if s[i].Name != s[j].Name { + return s[i].Name < s[j].Name + } + return s[i].Type < s[j].Type +} + +type ClassifiersResult struct { + TotalClassifiers int `json:"totalClassifiers"` + Classifiers []ClassifierSummary `json:"classifiers"` +} + +type ClassifierLabelEntry struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type UnManagedLabelEntry struct { + Key string `json:"key"` + FailureMessage string `json:"failureMessage,omitempty"` +} + +type MatchingClusterEntry struct { + ClusterNamespace string `json:"clusterNamespace"` + ClusterName string `json:"clusterName"` + ClusterType string `json:"clusterType"` + ManagedLabels []ClassifierLabelEntry `json:"managedLabels,omitempty"` + ConflictedLabels []UnManagedLabelEntry `json:"conflictedLabels,omitempty"` +} + +type ClassifierDetails struct { + Name string `json:"name"` + // Type is either Classifier or ManagementClusterClassifier + Type string `json:"type"` + + // Classifier-only + ResourceSelectors []libsveltosv1beta1.ResourceSelector `json:"resourceSelectors,omitempty"` + AggregatedClassification string `json:"aggregatedClassification,omitempty"` + KubernetesVersionConstraints []libsveltosv1beta1.KubernetesVersionConstraint `json:"kubernetesVersionConstraints,omitempty"` + + // ManagementClusterClassifier-only + MatchResources []libsveltosv1beta1.ResourceSelector `json:"matchResources,omitempty"` + ClassificationLua string `json:"classificationLua,omitempty"` + + // shared + ClassifierLabels []ClassifierLabelEntry `json:"classifierLabels"` + MatchingClusters []MatchingClusterEntry `json:"matchingClusters"` +} + +type classifierFilters struct { + Name string `uri:"name"` + ClusterNamespace string `uri:"clusterNamespace"` + ClusterName string `uri:"clusterName"` +} + +func getClassifierFiltersFromQuery(c *gin.Context) *classifierFilters { + var filters classifierFilters + filters.Name = c.Query("name") + filters.ClusterNamespace = c.Query("cluster_namespace") + filters.ClusterName = c.Query("cluster_name") + + return &filters +} + +func clusterFilterRequested(filters *classifierFilters) bool { + return filters.ClusterNamespace != "" || filters.ClusterName != "" +} + +func classifierNameMatches(name string, filters *classifierFilters) bool { + return filters.Name == "" || + strings.Contains(strings.ToLower(name), strings.ToLower(filters.Name)) +} + +func getClassifiersInRange(classifiers []ClassifierSummary, limit, skip int) ([]ClassifierSummary, error) { + return getSliceInRange(classifiers, limit, skip) +} + +// getClassifiers returns a summary of all Classifier and ManagementClusterClassifier +// instances the user can access. If filters identifies a cluster, the result is +// narrowed to only instances currently matching that cluster; each entry's +// MatchingClusterCount still reflects the instance's total match count, not just +// the filtered cluster. +func (m *instance) getClassifiers(ctx context.Context, canListClassifiers, canListManagementClusterClassifiers bool, + user string, filters *classifierFilters) (ClassifierSummaries, error) { + + reportsByClassifier, reportsByMCC, err := m.listClassifierReportsByName(ctx) + if err != nil { + return nil, err + } + + result := ClassifierSummaries{} + + classifiers := &libsveltosv1beta1.ClassifierList{} + if err := m.client.List(ctx, classifiers); err != nil { + return nil, err + } + + for i := range classifiers.Items { + classifier := &classifiers.Items[i] + if !classifier.GetDeletionTimestamp().IsZero() { + continue + } + + if !canListClassifiers { + ok, err := m.canGetClassifier(classifier.Name, user) + if err != nil || !ok { + continue + } + } + + if !classifierNameMatches(classifier.Name, filters) { + continue + } + + reports := reportsByClassifier[classifier.Name] + if clusterFilterRequested(filters) && !classifierReportsMatchCluster(reports, filters) { + continue + } + + result = append(result, ClassifierSummary{ + Name: classifier.Name, + Type: libsveltosv1beta1.ClassifierKind, + LabelCount: len(classifier.Spec.ClassifierLabels), + MatchingClusterCount: countMatchingClassifierReports(reports), + }) + } + + mccs := &libsveltosv1beta1.ManagementClusterClassifierList{} + if err := m.client.List(ctx, mccs); err != nil { + return nil, err + } + + for i := range mccs.Items { + mcc := &mccs.Items[i] + if !mcc.GetDeletionTimestamp().IsZero() { + continue + } + + if !canListManagementClusterClassifiers { + ok, err := m.canGetManagementClusterClassifier(mcc.Name, user) + if err != nil || !ok { + continue + } + } + + if !classifierNameMatches(mcc.Name, filters) { + continue + } + + reports := reportsByMCC[mcc.Name] + if clusterFilterRequested(filters) && !managementClusterClassifierReportsMatchCluster(reports, filters) { + continue + } + + result = append(result, ClassifierSummary{ + Name: mcc.Name, + Type: libsveltosv1beta1.ManagementClusterClassifierKind, + LabelCount: len(mcc.Spec.ClassifierLabels), + MatchingClusterCount: countMatchingManagementClusterClassifierReports(reports), + }) + } + + return result, nil +} + +// listClassifierReportsByName lists all ClassifierReport and ManagementClusterClassifierReport +// instances once and groups them by the owning classifier/managementClusterClassifier name. +func (m *instance) listClassifierReportsByName(ctx context.Context, +) (byClassifier map[string][]libsveltosv1beta1.ClassifierReport, + byMCC map[string][]libsveltosv1beta1.ManagementClusterClassifierReport, err error) { + + classifierReports := &libsveltosv1beta1.ClassifierReportList{} + if err := m.client.List(ctx, classifierReports); err != nil { + return nil, nil, err + } + + reportsByClassifier := map[string][]libsveltosv1beta1.ClassifierReport{} + for i := range classifierReports.Items { + name := classifierReports.Items[i].Spec.ClassifierName + reportsByClassifier[name] = append(reportsByClassifier[name], classifierReports.Items[i]) + } + + mccReports := &libsveltosv1beta1.ManagementClusterClassifierReportList{} + if err := m.client.List(ctx, mccReports); err != nil { + return nil, nil, err + } + + reportsByMCC := map[string][]libsveltosv1beta1.ManagementClusterClassifierReport{} + for i := range mccReports.Items { + name := mccReports.Items[i].Spec.ClassifierName + reportsByMCC[name] = append(reportsByMCC[name], mccReports.Items[i]) + } + + return reportsByClassifier, reportsByMCC, nil +} + +// clusterMatchesFilters reports whether a cluster matches filters. Each filter field is +// only applied when non-empty, using case-insensitive substring matching (same convention +// as EventTrigger's cluster filters) so a user can filter on just namespace or just name +// without having to fully specify the cluster. +func clusterMatchesFilters(clusterNamespace, clusterName string, filters *classifierFilters) bool { + if filters.ClusterNamespace != "" && + !strings.Contains(strings.ToLower(clusterNamespace), strings.ToLower(filters.ClusterNamespace)) { + + return false + } + if filters.ClusterName != "" && + !strings.Contains(strings.ToLower(clusterName), strings.ToLower(filters.ClusterName)) { + + return false + } + return true +} + +// classifierReportsMatchCluster returns true if any report indicates the Classifier +// currently matches a cluster satisfying filters. Reports with an empty +// ClusterName (not yet associated with a cluster) are ignored. +func classifierReportsMatchCluster(reports []libsveltosv1beta1.ClassifierReport, filters *classifierFilters) bool { + for i := range reports { + r := &reports[i] + if r.Spec.ClusterName == "" || !r.Spec.Match { + continue + } + if clusterMatchesFilters(r.Spec.ClusterNamespace, r.Spec.ClusterName, filters) { + return true + } + } + return false +} + +// managementClusterClassifierReportsMatchCluster returns true if any report indicates +// the ManagementClusterClassifier currently targets a cluster satisfying filters. +// Unlike ClassifierReport, there is no Match field: a report only exists for clusters +// the Lua evaluation currently returns. +func managementClusterClassifierReportsMatchCluster(reports []libsveltosv1beta1.ManagementClusterClassifierReport, + filters *classifierFilters) bool { + + for i := range reports { + r := &reports[i] + if r.Spec.ClusterName == "" { + continue + } + if clusterMatchesFilters(r.Spec.ClusterNamespace, r.Spec.ClusterName, filters) { + return true + } + } + return false +} + +func countMatchingClassifierReports(reports []libsveltosv1beta1.ClassifierReport) int { + count := 0 + for i := range reports { + if reports[i].Spec.ClusterName != "" && reports[i].Spec.Match { + count++ + } + } + return count +} + +func countMatchingManagementClusterClassifierReports(reports []libsveltosv1beta1.ManagementClusterClassifierReport) int { + count := 0 + for i := range reports { + if reports[i].Spec.ClusterName != "" { + count++ + } + } + return count +} + +// GetClassifierDetails returns the spec, configured labels, and matching clusters +// (with the labels actually owned on each) for a Classifier or ManagementClusterClassifier +// instance. Returns nil, nil if the instance is not found. +func (m *instance) GetClassifierDetails(ctx context.Context, name, classifierType string, +) (*ClassifierDetails, error) { + + switch classifierType { + case libsveltosv1beta1.ClassifierKind: + return m.getClassifierDetails(ctx, name) + case libsveltosv1beta1.ManagementClusterClassifierKind: + return m.getManagementClusterClassifierDetails(ctx, name) + default: + return nil, fmt.Errorf("unsupported classifier type %q", classifierType) + } +} + +func (m *instance) getClassifierDetails(ctx context.Context, name string) (*ClassifierDetails, error) { + classifier := &libsveltosv1beta1.Classifier{} + err := m.client.Get(ctx, types.NamespacedName{Name: name}, classifier) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + + reports := &libsveltosv1beta1.ClassifierReportList{} + if err := m.client.List(ctx, reports); err != nil { + return nil, err + } + + ownReports := make([]libsveltosv1beta1.ClassifierReport, 0) + for i := range reports.Items { + if reports.Items[i].Spec.ClassifierName == name { + ownReports = append(ownReports, reports.Items[i]) + } + } + + result := &ClassifierDetails{ + Name: name, + Type: libsveltosv1beta1.ClassifierKind, + KubernetesVersionConstraints: classifier.Spec.KubernetesVersionConstraints, + ClassifierLabels: toClassifierLabelEntries(classifier.Spec.ClassifierLabels), + MatchingClusters: matchingClustersFromClassifierReports(ownReports, classifier.Spec.ClassifierLabels), + } + if classifier.Spec.DeployedResourceConstraint != nil { + result.ResourceSelectors = classifier.Spec.DeployedResourceConstraint.ResourceSelectors + result.AggregatedClassification = classifier.Spec.DeployedResourceConstraint.AggregatedClassification + } + + return result, nil +} + +func (m *instance) getManagementClusterClassifierDetails(ctx context.Context, name string, +) (*ClassifierDetails, error) { + + mcc := &libsveltosv1beta1.ManagementClusterClassifier{} + err := m.client.Get(ctx, types.NamespacedName{Name: name}, mcc) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + + reports := &libsveltosv1beta1.ManagementClusterClassifierReportList{} + if err := m.client.List(ctx, reports); err != nil { + return nil, err + } + + ownReports := make([]libsveltosv1beta1.ManagementClusterClassifierReport, 0) + for i := range reports.Items { + if reports.Items[i].Spec.ClassifierName == name { + ownReports = append(ownReports, reports.Items[i]) + } + } + + return &ClassifierDetails{ + Name: name, + Type: libsveltosv1beta1.ManagementClusterClassifierKind, + MatchResources: mcc.Spec.MatchResources, + ClassificationLua: mcc.Spec.ClassificationLua, + ClassifierLabels: toClassifierLabelEntries(mcc.Spec.ClassifierLabels), + MatchingClusters: matchingClustersFromManagementClusterClassifierReports(ownReports, mcc.Spec.ClassifierLabels), + }, nil +} + +func toClassifierLabelEntries(labels []libsveltosv1beta1.ClassifierLabel) []ClassifierLabelEntry { + entries := make([]ClassifierLabelEntry, len(labels)) + for i := range labels { + entries[i] = ClassifierLabelEntry{Key: labels[i].Key, Value: labels[i].Value} + } + return entries +} + +func classifierLabelValues(labels []libsveltosv1beta1.ClassifierLabel) map[string]string { + values := make(map[string]string, len(labels)) + for i := range labels { + values[labels[i].Key] = labels[i].Value + } + return values +} + +func toManagedLabelEntries(keys []string, values map[string]string) []ClassifierLabelEntry { + if len(keys) == 0 { + return nil + } + entries := make([]ClassifierLabelEntry, 0, len(keys)) + for _, key := range keys { + entries = append(entries, ClassifierLabelEntry{Key: key, Value: values[key]}) + } + return entries +} + +func toUnManagedLabelEntries(unmanaged []libsveltosv1beta1.UnManagedLabel) []UnManagedLabelEntry { + if len(unmanaged) == 0 { + return nil + } + entries := make([]UnManagedLabelEntry, 0, len(unmanaged)) + for i := range unmanaged { + entry := UnManagedLabelEntry{Key: unmanaged[i].Key} + if unmanaged[i].FailureMessage != nil { + entry.FailureMessage = *unmanaged[i].FailureMessage + } + entries = append(entries, entry) + } + return entries +} + +func sortMatchingClusters(entries []MatchingClusterEntry) { + sort.Slice(entries, func(i, j int) bool { + if entries[i].ClusterNamespace != entries[j].ClusterNamespace { + return entries[i].ClusterNamespace < entries[j].ClusterNamespace + } + return entries[i].ClusterName < entries[j].ClusterName + }) +} + +func matchingClustersFromClassifierReports(reports []libsveltosv1beta1.ClassifierReport, + classifierLabels []libsveltosv1beta1.ClassifierLabel) []MatchingClusterEntry { + + values := classifierLabelValues(classifierLabels) + + result := make([]MatchingClusterEntry, 0, len(reports)) + for i := range reports { + r := &reports[i] + if r.Spec.ClusterName == "" || !r.Spec.Match { + continue + } + result = append(result, MatchingClusterEntry{ + ClusterNamespace: r.Spec.ClusterNamespace, + ClusterName: r.Spec.ClusterName, + ClusterType: string(r.Spec.ClusterType), + ManagedLabels: toManagedLabelEntries(r.Status.ManagedLabels, values), + ConflictedLabels: toUnManagedLabelEntries(r.Status.UnManagedLabels), + }) + } + + sortMatchingClusters(result) + return result +} + +func matchingClustersFromManagementClusterClassifierReports(reports []libsveltosv1beta1.ManagementClusterClassifierReport, + classifierLabels []libsveltosv1beta1.ClassifierLabel) []MatchingClusterEntry { + + values := classifierLabelValues(classifierLabels) + + result := make([]MatchingClusterEntry, 0, len(reports)) + for i := range reports { + r := &reports[i] + if r.Spec.ClusterName == "" { + continue + } + result = append(result, MatchingClusterEntry{ + ClusterNamespace: r.Spec.ClusterNamespace, + ClusterName: r.Spec.ClusterName, + ClusterType: string(r.Spec.ClusterType), + ManagedLabels: toManagedLabelEntries(r.Status.ManagedLabels, values), + ConflictedLabels: toUnManagedLabelEntries(r.Status.UnManagedLabels), + }) + } + + sortMatchingClusters(result) + return result +} diff --git a/internal/server/classifiers_test.go b/internal/server/classifiers_test.go new file mode 100644 index 0000000..b1b7ba3 --- /dev/null +++ b/internal/server/classifiers_test.go @@ -0,0 +1,239 @@ +/* +Copyright 2026. projectsveltos.io. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package server_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/utils/ptr" + + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" + "github.com/projectsveltos/ui-backend/internal/server" +) + +const ( + testEnvLabelKey = "env" + testTeamLabelKey = "team" + testEnvValue = "prod" + testTeamValue = "platform" + testNamespace = "default" + testClusterName = "workload" + testMgmtName = "mgmt" + testClassifierName = "default-classifier" + testConflictOwner = "classifier other-classifier currently manages this" + testNoMatchValue = "no-such-value" +) + +var _ = Describe("Classifier Data", func() { + var classifierLabels []libsveltosv1beta1.ClassifierLabel + + BeforeEach(func() { + classifierLabels = []libsveltosv1beta1.ClassifierLabel{ + {Key: testEnvLabelKey, Value: testEnvValue}, + {Key: testTeamLabelKey, Value: testTeamValue}, + } + }) + + It("matchingClustersFromClassifierReports joins label values and skips non-matching/phantom reports", func() { + matchingReport := libsveltosv1beta1.ClassifierReport{ + Spec: libsveltosv1beta1.ClassifierReportSpec{ + ClusterNamespace: testNamespace, + ClusterName: testClusterName, + ClusterType: libsveltosv1beta1.ClusterTypeCapi, + Match: true, + }, + Status: libsveltosv1beta1.ClassifierReportStatus{ + ManagedLabels: []string{testEnvLabelKey}, + }, + } + + conflictedReport := libsveltosv1beta1.ClassifierReport{ + Spec: libsveltosv1beta1.ClassifierReportSpec{ + ClusterNamespace: testMgmtName, + ClusterName: testMgmtName, + ClusterType: libsveltosv1beta1.ClusterTypeSveltos, + Match: true, + }, + Status: libsveltosv1beta1.ClassifierReportStatus{ + ManagedLabels: []string{testTeamLabelKey}, + UnManagedLabels: []libsveltosv1beta1.UnManagedLabel{ + {Key: testEnvLabelKey, FailureMessage: ptr.To(testConflictOwner)}, + }, + }, + } + + // Cluster no longer a match: must not be reported as a matching cluster. + nonMatchingReport := libsveltosv1beta1.ClassifierReport{ + Spec: libsveltosv1beta1.ClassifierReportSpec{ + ClusterNamespace: testNamespace, + ClusterName: "stale", + ClusterType: libsveltosv1beta1.ClusterTypeCapi, + Match: false, + }, + } + + // Placeholder report with no cluster associated yet (observed live: phase + // WaitingForDelivery, empty cluster fields, match true). Must be skipped. + phantomReport := libsveltosv1beta1.ClassifierReport{ + Spec: libsveltosv1beta1.ClassifierReportSpec{ + Match: true, + }, + } + + reports := []libsveltosv1beta1.ClassifierReport{ + matchingReport, conflictedReport, nonMatchingReport, phantomReport, + } + + result := server.MatchingClustersFromClassifierReports(reports, classifierLabels) + Expect(result).To(HaveLen(2)) + + // Sorted by namespace then name: "default/workload" before "mgmt/mgmt". + Expect(result[0].ClusterNamespace).To(Equal(testNamespace)) + Expect(result[0].ClusterName).To(Equal(testClusterName)) + Expect(result[0].ManagedLabels).To(ConsistOf(server.ClassifierLabelEntry{Key: testEnvLabelKey, Value: testEnvValue})) + Expect(result[0].ConflictedLabels).To(BeEmpty()) + + Expect(result[1].ClusterNamespace).To(Equal(testMgmtName)) + Expect(result[1].ClusterName).To(Equal(testMgmtName)) + Expect(result[1].ManagedLabels).To(ConsistOf(server.ClassifierLabelEntry{Key: testTeamLabelKey, Value: testTeamValue})) + Expect(result[1].ConflictedLabels).To(ConsistOf(server.UnManagedLabelEntry{ + Key: testEnvLabelKey, FailureMessage: testConflictOwner, + })) + }) + + It("matchingClustersFromManagementClusterClassifierReports skips reports with no cluster associated", func() { + matchingReport := libsveltosv1beta1.ManagementClusterClassifierReport{ + Spec: libsveltosv1beta1.ManagementClusterClassifierReportSpec{ + ClusterNamespace: testNamespace, + ClusterName: testClusterName, + ClusterType: libsveltosv1beta1.ClusterTypeCapi, + }, + Status: libsveltosv1beta1.ManagementClusterClassifierReportStatus{ + ManagedLabels: []string{testEnvLabelKey, testTeamLabelKey}, + }, + } + + phantomReport := libsveltosv1beta1.ManagementClusterClassifierReport{} + + reports := []libsveltosv1beta1.ManagementClusterClassifierReport{matchingReport, phantomReport} + + result := server.MatchingClustersFromManagementClusterClassifierReports(reports, classifierLabels) + Expect(result).To(HaveLen(1)) + Expect(result[0].ClusterName).To(Equal(testClusterName)) + Expect(result[0].ManagedLabels).To(ConsistOf( + server.ClassifierLabelEntry{Key: testEnvLabelKey, Value: testEnvValue}, + server.ClassifierLabelEntry{Key: testTeamLabelKey, Value: testTeamValue}, + )) + }) + + It("classifierReportsMatchCluster requires Match to be true", func() { + reports := []libsveltosv1beta1.ClassifierReport{ + { + Spec: libsveltosv1beta1.ClassifierReportSpec{ + ClusterNamespace: testNamespace, ClusterName: testClusterName, + ClusterType: libsveltosv1beta1.ClusterTypeCapi, Match: false, + }, + }, + } + filters := &server.ClassifierFilters{ + ClusterNamespace: testNamespace, ClusterName: testClusterName, + } + Expect(server.ClassifierReportsMatchCluster(reports, filters)).To(BeFalse()) + + reports[0].Spec.Match = true + Expect(server.ClassifierReportsMatchCluster(reports, filters)).To(BeTrue()) + + otherFilters := &server.ClassifierFilters{ + ClusterNamespace: testNamespace, ClusterName: "other", + } + Expect(server.ClassifierReportsMatchCluster(reports, otherFilters)).To(BeFalse()) + }) + + It("classifierReportsMatchCluster matches on a partially specified filter", func() { + reports := []libsveltosv1beta1.ClassifierReport{ + { + Spec: libsveltosv1beta1.ClassifierReportSpec{ + ClusterNamespace: testNamespace, ClusterName: testClusterName, + ClusterType: libsveltosv1beta1.ClusterTypeCapi, Match: true, + }, + }, + } + + // Only cluster_namespace set: must still match, not require cluster_name too. + namespaceOnly := &server.ClassifierFilters{ClusterNamespace: testNamespace} + Expect(server.ClassifierReportsMatchCluster(reports, namespaceOnly)).To(BeTrue()) + + // Only cluster_name set. + nameOnly := &server.ClassifierFilters{ClusterName: testClusterName} + Expect(server.ClassifierReportsMatchCluster(reports, nameOnly)).To(BeTrue()) + + // Substring match on namespace. + substringNamespace := &server.ClassifierFilters{ClusterNamespace: "efau"} + Expect(server.ClassifierReportsMatchCluster(reports, substringNamespace)).To(BeTrue()) + + // A field that doesn't match still excludes the report even if others are unset. + wrongName := &server.ClassifierFilters{ClusterName: testNoMatchValue} + Expect(server.ClassifierReportsMatchCluster(reports, wrongName)).To(BeFalse()) + }) + + It("classifierNameMatches applies a case-insensitive substring match on the classifier name", func() { + Expect(server.ClassifierNameMatches(testClassifierName, &server.ClassifierFilters{})).To(BeTrue()) + Expect( + server.ClassifierNameMatches(testClassifierName, &server.ClassifierFilters{Name: "DEFAULT-CLASSIF"}), + ).To(BeTrue()) + Expect( + server.ClassifierNameMatches(testClassifierName, &server.ClassifierFilters{Name: testNoMatchValue}), + ).To(BeFalse()) + }) + + It("managementClusterClassifierReportsMatchCluster ignores reports with no cluster associated", func() { + reports := []libsveltosv1beta1.ManagementClusterClassifierReport{ + {}, + } + filters := &server.ClassifierFilters{ + ClusterNamespace: testNamespace, ClusterName: testClusterName, + } + Expect(server.ManagementClusterClassifierReportsMatchCluster(reports, filters)).To(BeFalse()) + + reports[0].Spec = libsveltosv1beta1.ManagementClusterClassifierReportSpec{ + ClusterNamespace: testNamespace, ClusterName: testClusterName, ClusterType: libsveltosv1beta1.ClusterTypeCapi, + } + Expect(server.ManagementClusterClassifierReportsMatchCluster(reports, filters)).To(BeTrue()) + + // Only cluster_namespace set: must still match. + namespaceOnly := &server.ClassifierFilters{ClusterNamespace: testNamespace} + Expect( + server.ManagementClusterClassifierReportsMatchCluster(reports, namespaceOnly), + ).To(BeTrue()) + }) + + It("countMatchingClassifierReports and countMatchingManagementClusterClassifierReports exclude non-matches and phantoms", func() { + classifierReports := []libsveltosv1beta1.ClassifierReport{ + {Spec: libsveltosv1beta1.ClassifierReportSpec{ClusterName: "a", Match: true}}, + {Spec: libsveltosv1beta1.ClassifierReportSpec{ClusterName: "b", Match: false}}, + {Spec: libsveltosv1beta1.ClassifierReportSpec{Match: true}}, // phantom: empty ClusterName + } + Expect(server.CountMatchingClassifierReports(classifierReports)).To(Equal(1)) + + mccReports := []libsveltosv1beta1.ManagementClusterClassifierReport{ + {Spec: libsveltosv1beta1.ManagementClusterClassifierReportSpec{ClusterName: "a"}}, + {}, // phantom: empty ClusterName + } + Expect(server.CountMatchingManagementClusterClassifierReports(mccReports)).To(Equal(1)) + }) +}) diff --git a/internal/server/export_test.go b/internal/server/export_test.go index beb369e..3910f29 100644 --- a/internal/server/export_test.go +++ b/internal/server/export_test.go @@ -95,6 +95,14 @@ var ( GetEventClusterDetails = getEventClusterDetails GetProfileData = getProfileData + + MatchingClustersFromClassifierReports = matchingClustersFromClassifierReports + MatchingClustersFromManagementClusterClassifierReports = matchingClustersFromManagementClusterClassifierReports + ClassifierReportsMatchCluster = classifierReportsMatchCluster + ManagementClusterClassifierReportsMatchCluster = managementClusterClassifierReportsMatchCluster + CountMatchingClassifierReports = countMatchingClassifierReports + CountMatchingManagementClusterClassifierReports = countMatchingManagementClusterClassifierReports + ClassifierNameMatches = classifierNameMatches ) var ( @@ -102,7 +110,8 @@ var ( ) type ( - ProfileFilters = profileFilters + ProfileFilters = profileFilters + ClassifierFilters = classifierFilters ) func GetNamespaceFilter(f clusterFilters) string { diff --git a/internal/server/http.go b/internal/server/http.go index 61af661..e647066 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -645,6 +645,137 @@ var ( c.JSON(http.StatusOK, details) } + getClassifiers = func(c *gin.Context) { + ginLogger.V(logs.LogDebug).Info("get classifiers") + + limit, skip := getLimitAndSkipFromQuery(c) + ginLogger.V(logs.LogDebug).Info(fmt.Sprintf("limit %d skip %d", limit, skip)) + + filters := getClassifierFiltersFromQuery(c) + ginLogger.V(logs.LogDebug).Info(fmt.Sprintf("filters: name %q cluster_namespace %q cluster_name %q", + filters.Name, filters.ClusterNamespace, filters.ClusterName)) + + user, err := validateToken(c) + if err != nil { + _ = c.AbortWithError(http.StatusUnauthorized, err) + return + } + + manager := GetManagerInstance() + + canListClassifiers, err := manager.canListClassifiers(user) + if err != nil { + ginLogger.V(logs.LogInfo).Info(fmt.Sprintf("failed to verify permissions %s: %v", c.Request.URL, err)) + _ = c.AbortWithError(http.StatusUnauthorized, err) + return + } + + canListManagementClusterClassifiers, err := manager.canListManagementClusterClassifiers(user) + if err != nil { + ginLogger.V(logs.LogInfo).Info(fmt.Sprintf("failed to verify permissions %s: %v", c.Request.URL, err)) + _ = c.AbortWithError(http.StatusUnauthorized, err) + return + } + + classifierData, err := manager.getClassifiers(c.Request.Context(), canListClassifiers, + canListManagementClusterClassifiers, user, filters) + if err != nil { + ginLogger.V(logs.LogInfo).Info(fmt.Sprintf("failed to get classifiers %s: %v", c.Request.URL, err)) + _ = c.AbortWithError(http.StatusUnauthorized, err) + return + } + + sort.Sort(classifierData) + + result, err := getClassifiersInRange(classifierData, limit, skip) + if err != nil { + ginLogger.V(logs.LogInfo).Info(fmt.Sprintf("bad request %s: %v", c.Request.URL, err)) + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + response := ClassifiersResult{ + TotalClassifiers: len(classifierData), + Classifiers: result, + } + + // Return JSON response + c.JSON(http.StatusOK, response) + } + + getClassifier = func(c *gin.Context) { + ginLogger.V(logs.LogDebug).Info("get a classifier") + + name := c.Query("name") + classifierType := c.Query("type") + ginLogger.V(logs.LogDebug).Info(fmt.Sprintf("filters: name %q type %q", name, classifierType)) + + if name == "" { + msg := nameRequiredError + ginLogger.V(logs.LogInfo).Info(msg) + _ = c.AbortWithError(http.StatusBadRequest, errors.New(msg)) + return + } + + if classifierType != libsveltosv1beta1.ClassifierKind && + classifierType != libsveltosv1beta1.ManagementClusterClassifierKind { + + msg := "type must be Classifier or ManagementClusterClassifier" + ginLogger.V(logs.LogInfo).Info(msg) + _ = c.AbortWithError(http.StatusBadRequest, errors.New(msg)) + return + } + + user, err := validateToken(c) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + manager := GetManagerInstance() + + var canListAll bool + if classifierType == libsveltosv1beta1.ClassifierKind { + canListAll, err = manager.canListClassifiers(user) + } else { + canListAll, err = manager.canListManagementClusterClassifiers(user) + } + if err != nil { + ginLogger.V(logs.LogInfo).Info(fmt.Sprintf("failed to verify permissions %s: %v", c.Request.URL, err)) + _ = c.AbortWithError(http.StatusUnauthorized, err) + return + } + + if !canListAll { + var canGet bool + if classifierType == libsveltosv1beta1.ClassifierKind { + canGet, err = manager.canGetClassifier(name, user) + } else { + canGet, err = manager.canGetManagementClusterClassifier(name, user) + } + if err != nil { + ginLogger.V(logs.LogInfo).Info(fmt.Sprintf("failed to verify permissions %s: %v", c.Request.URL, err)) + _ = c.AbortWithError(http.StatusUnauthorized, err) + return + } + if !canGet { + ginLogger.V(logs.LogInfo).Info(fmt.Sprintf("user does not have permission to access resource. URI: %s", c.Request.URL)) + _ = c.AbortWithError(http.StatusUnauthorized, errors.New("no permissions to access this classifier")) + return + } + } + + details, err := manager.GetClassifierDetails(c.Request.Context(), name, classifierType) + if err != nil { + ginLogger.V(logs.LogInfo).Info(fmt.Sprintf("failed to get classifier details. %s: %v", c.Request.URL, err)) + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + + // Return JSON response + c.JSON(http.StatusOK, details) + } + checkInstallationState = func(c *gin.Context) { ginLogger.V(logs.LogDebug).Info("check Sveltos installation status") @@ -918,54 +1049,12 @@ var ( analyzeEventPipeline = func(c *gin.Context) { ginLogger.V(logs.LogDebug).Info("check EventTrigger errors") + handleAnalyzePipeline(c, "event_name", mcpclient.AnalyzeEventPipeline) + } - namespace, name, clusterType := getClusterFromQuery(c) - ginLogger.V(logs.LogDebug).Info(fmt.Sprintf("cluster %s:%s/%s", clusterType, namespace, name)) - eventTriggerName := c.Query("event_name") - ginLogger.V(logs.LogDebug).Info(fmt.Sprintf("eventTrigger %s", eventTriggerName)) - - user, err := validateToken(c) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - - manager := GetManagerInstance() - - canGetCluster, err := manager.canGetCluster(namespace, name, user, clusterType) - if err != nil { - ginLogger.V(logs.LogInfo).Info(fmt.Sprintf("failed to verify permissions %s: %v", c.Request.URL, err)) - _ = c.AbortWithError(http.StatusUnauthorized, err) - return - } - - if !canGetCluster { - _ = c.AbortWithError(http.StatusUnauthorized, errors.New("no permissions to access this cluster")) - return - } - - clusterKind := clusterv1.ClusterKind - clusterApiVersion := clusterv1.GroupVersion.String() - if clusterType == libsveltosv1beta1.ClusterTypeSveltos { - clusterApiVersion = libsveltosv1beta1.GroupVersion.String() - clusterKind = libsveltosv1beta1.SveltosClusterKind - } - clusterRef := &corev1.ObjectReference{ - Kind: clusterKind, - APIVersion: clusterApiVersion, - Namespace: namespace, - Name: name, - } - - result, err := mcpclient.AnalyzeEventPipeline(c.Request.Context(), getMCPServerURL(), - clusterRef, eventTriggerName, ginLogger) - if err != nil { - ginLogger.V(logs.LogInfo).Info(fmt.Sprintf("failed to check profile deployment errors: %v", err)) - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } - - c.JSON(http.StatusOK, result) + analyzeClassifierPipeline = func(c *gin.Context) { + ginLogger.V(logs.LogDebug).Info("check Classifier errors") + handleAnalyzePipeline(c, "classifier_name", mcpclient.AnalyzeClassifierPipeline) } getStats = func(c *gin.Context) { @@ -1016,6 +1105,10 @@ func (m *instance) start(ctx context.Context, port string, logger logr.Logger) { r.GET("/events", getEvents) // Return details about a specific EventTrigger r.GET("/event", getEvent) + // Return existing Classifiers and ManagementClusterClassifiers + r.GET("/classifiers", getClassifiers) + // Return details about a specific Classifier or ManagementClusterClassifier + r.GET("/classifier", getClassifier) // Return counts of Sveltos resources accessible to the user r.GET("/stats", getStats) @@ -1028,6 +1121,8 @@ func (m *instance) start(ctx context.Context, port string, logger logr.Logger) { r.GET("/debugCluster", checkClusterDeploymentStatuses) // Return details about event errors for a given EventTrigger and Cluster r.GET("/analyzeEventPipeline", analyzeEventPipeline) + // Return details about classifier errors for a given Classifier/ManagementClusterClassifier and Cluster + r.GET("/analyzeClassifierPipeline", analyzeClassifierPipeline) // Return dryrun diff for a given profile+cluster pair r.GET("/dryRunChanges", getDryRunChanges) @@ -1247,6 +1342,63 @@ func getClusterFromQuery(c *gin.Context) (namespace, name string, clusterType li return } +// analyzePipelineFunc matches mcpclient.AnalyzeEventPipeline/AnalyzeClassifierPipeline: trace +// a pipeline for one resource/cluster pair and return the issues detected. +type analyzePipelineFunc func(ctx context.Context, url string, clusterRef *corev1.ObjectReference, + resourceName string, logger logr.Logger) ([]string, error) + +// handleAnalyzePipeline is the shared handler body for the /analyzeEventPipeline and +// /analyzeClassifierPipeline MCP debug endpoints: resolve the cluster and resource name from +// the query, check the caller can access the cluster, then delegate to analyze. +func handleAnalyzePipeline(c *gin.Context, resourceQueryParam string, analyze analyzePipelineFunc) { + namespace, name, clusterType := getClusterFromQuery(c) + ginLogger.V(logs.LogDebug).Info(fmt.Sprintf("cluster %s:%s/%s", clusterType, namespace, name)) + resourceName := c.Query(resourceQueryParam) + ginLogger.V(logs.LogDebug).Info(fmt.Sprintf("%s %s", resourceQueryParam, resourceName)) + + user, err := validateToken(c) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + manager := GetManagerInstance() + + canGetCluster, err := manager.canGetCluster(namespace, name, user, clusterType) + if err != nil { + ginLogger.V(logs.LogInfo).Info(fmt.Sprintf("failed to verify permissions %s: %v", c.Request.URL, err)) + _ = c.AbortWithError(http.StatusUnauthorized, err) + return + } + + if !canGetCluster { + _ = c.AbortWithError(http.StatusUnauthorized, errors.New("no permissions to access this cluster")) + return + } + + clusterKind := clusterv1.ClusterKind + clusterApiVersion := clusterv1.GroupVersion.String() + if clusterType == libsveltosv1beta1.ClusterTypeSveltos { + clusterApiVersion = libsveltosv1beta1.GroupVersion.String() + clusterKind = libsveltosv1beta1.SveltosClusterKind + } + clusterRef := &corev1.ObjectReference{ + Kind: clusterKind, + APIVersion: clusterApiVersion, + Namespace: namespace, + Name: name, + } + + result, err := analyze(c.Request.Context(), getMCPServerURL(), clusterRef, resourceName, ginLogger) + if err != nil { + ginLogger.V(logs.LogInfo).Info(fmt.Sprintf("failed to analyze pipeline: %v", err)) + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusOK, result) +} + func getTokenFromAuthorizationHeader(c *gin.Context) (string, error) { // Get the authorization header value authorizationHeader := c.GetHeader("Authorization") diff --git a/internal/server/k8s-utils.go b/internal/server/k8s-utils.go index b34b6e2..6b16d9a 100644 --- a/internal/server/k8s-utils.go +++ b/internal/server/k8s-utils.go @@ -532,3 +532,122 @@ func (m *instance) canGetEventTrigger(name, user string) (bool, error) { return canI.Status.Allowed, nil } + +// canListClassifiers verifies whether user has permission to view Classifiers +func (m *instance) canListClassifiers(user string) (bool, error) { + clientset, err := kubernetes.NewForConfig(m.config) + if err != nil { + m.logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to get clientset: %v", err)) + return false, err + } + + sar := &authorizationapi.SubjectAccessReview{ + Spec: authorizationapi.SubjectAccessReviewSpec{ + ResourceAttributes: &authorizationapi.ResourceAttributes{ + Verb: verbGet, + Group: libsveltosv1beta1.GroupVersion.Group, + Version: libsveltosv1beta1.GroupVersion.Version, + Resource: libsveltosv1beta1.ClassifierKind, + }, + User: user, + }, + } + + canI, err := clientset.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), sar, metav1.CreateOptions{}) + if err != nil { + m.logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to check clientset permissions: %v", err)) + return false, err + } + + return canI.Status.Allowed, nil +} + +// canGetClassifier returns true if user can access Classifier name +func (m *instance) canGetClassifier(name, user string) (bool, error) { + clientset, err := kubernetes.NewForConfig(m.config) + if err != nil { + m.logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to get clientset: %v", err)) + return false, err + } + + sar := &authorizationapi.SubjectAccessReview{ + Spec: authorizationapi.SubjectAccessReviewSpec{ + ResourceAttributes: &authorizationapi.ResourceAttributes{ + Verb: verbGet, + Group: libsveltosv1beta1.GroupVersion.Group, + Version: libsveltosv1beta1.GroupVersion.Version, + Resource: libsveltosv1beta1.ClassifierKind, + Name: name, + }, + User: user, + }, + } + + canI, err := clientset.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), sar, metav1.CreateOptions{}) + if err != nil { + m.logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to check clientset permissions: %v", err)) + return false, err + } + + return canI.Status.Allowed, nil +} + +// canListManagementClusterClassifiers verifies whether user has permission to view +// ManagementClusterClassifiers +func (m *instance) canListManagementClusterClassifiers(user string) (bool, error) { + clientset, err := kubernetes.NewForConfig(m.config) + if err != nil { + m.logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to get clientset: %v", err)) + return false, err + } + + sar := &authorizationapi.SubjectAccessReview{ + Spec: authorizationapi.SubjectAccessReviewSpec{ + ResourceAttributes: &authorizationapi.ResourceAttributes{ + Verb: verbGet, + Group: libsveltosv1beta1.GroupVersion.Group, + Version: libsveltosv1beta1.GroupVersion.Version, + Resource: libsveltosv1beta1.ManagementClusterClassifierKind, + }, + User: user, + }, + } + + canI, err := clientset.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), sar, metav1.CreateOptions{}) + if err != nil { + m.logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to check clientset permissions: %v", err)) + return false, err + } + + return canI.Status.Allowed, nil +} + +// canGetManagementClusterClassifier returns true if user can access ManagementClusterClassifier name +func (m *instance) canGetManagementClusterClassifier(name, user string) (bool, error) { + clientset, err := kubernetes.NewForConfig(m.config) + if err != nil { + m.logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to get clientset: %v", err)) + return false, err + } + + sar := &authorizationapi.SubjectAccessReview{ + Spec: authorizationapi.SubjectAccessReviewSpec{ + ResourceAttributes: &authorizationapi.ResourceAttributes{ + Verb: verbGet, + Group: libsveltosv1beta1.GroupVersion.Group, + Version: libsveltosv1beta1.GroupVersion.Version, + Resource: libsveltosv1beta1.ManagementClusterClassifierKind, + Name: name, + }, + User: user, + }, + } + + canI, err := clientset.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), sar, metav1.CreateOptions{}) + if err != nil { + m.logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to check clientset permissions: %v", err)) + return false, err + } + + return canI.Status.Allowed, nil +} diff --git a/internal/server/stats.go b/internal/server/stats.go index 880f8f9..429fbae 100644 --- a/internal/server/stats.go +++ b/internal/server/stats.go @@ -22,20 +22,23 @@ import ( configv1beta1 "github.com/projectsveltos/addon-controller/api/v1beta1" eventv1beta1 "github.com/projectsveltos/event-manager/api/v1beta1" + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" logs "github.com/projectsveltos/libsveltos/lib/logsettings" ) // Stats holds counts of Sveltos resources accessible to the requesting user. type Stats struct { - CAPIClusters int `json:"capiClusters"` - NotReadyCAPIClusters int `json:"notReadyCAPIClusters"` - SveltosClusters int `json:"sveltosClusters"` - NotReadySveltosClusters int `json:"notReadySveltosClusters"` - PullModeClusters int `json:"pullModeClusters"` - ClusterProfiles int `json:"clusterProfiles"` - Profiles int `json:"profiles"` - ClusterSummaries int `json:"clusterSummaries"` - EventTriggers int `json:"eventTriggers"` + CAPIClusters int `json:"capiClusters"` + NotReadyCAPIClusters int `json:"notReadyCAPIClusters"` + SveltosClusters int `json:"sveltosClusters"` + NotReadySveltosClusters int `json:"notReadySveltosClusters"` + PullModeClusters int `json:"pullModeClusters"` + ClusterProfiles int `json:"clusterProfiles"` + Profiles int `json:"profiles"` + ClusterSummaries int `json:"clusterSummaries"` + EventTriggers int `json:"eventTriggers"` + Classifiers int `json:"classifiers"` + ManagementClusterClassifiers int `json:"managementClusterClassifiers"` } type clusterCounts struct { @@ -67,16 +70,28 @@ func (m *instance) getSveltosStats(ctx context.Context, user string) (Stats, err return Stats{}, err } + classifiers, err := m.countClassifiers(ctx, user) + if err != nil { + return Stats{}, err + } + + managementClusterClassifiers, err := m.countManagementClusterClassifiers(ctx, user) + if err != nil { + return Stats{}, err + } + return Stats{ - CAPIClusters: cc.capiTotal, - NotReadyCAPIClusters: cc.capiNotReady, - SveltosClusters: cc.sveltosTotal, - NotReadySveltosClusters: cc.sveltosNotReady, - PullModeClusters: cc.pullMode, - ClusterProfiles: clusterProfiles, - Profiles: profiles, - ClusterSummaries: clusterSummaries, - EventTriggers: eventTriggers, + CAPIClusters: cc.capiTotal, + NotReadyCAPIClusters: cc.capiNotReady, + SveltosClusters: cc.sveltosTotal, + NotReadySveltosClusters: cc.sveltosNotReady, + PullModeClusters: cc.pullMode, + ClusterProfiles: clusterProfiles, + Profiles: profiles, + ClusterSummaries: clusterSummaries, + EventTriggers: eventTriggers, + Classifiers: classifiers, + ManagementClusterClassifiers: managementClusterClassifiers, }, nil } @@ -211,3 +226,69 @@ func (m *instance) countEventTriggers(ctx context.Context, user string) (int, er } return count, nil } + +func (m *instance) countClassifiers(ctx context.Context, user string) (int, error) { + canList, err := m.canListClassifiers(user) + if err != nil { + return 0, err + } + + classifiers := &libsveltosv1beta1.ClassifierList{} + if err := m.client.List(ctx, classifiers); err != nil { + m.logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to list Classifiers: %v", err)) + return 0, err + } + + count := 0 + for i := range classifiers.Items { + classifier := &classifiers.Items[i] + if !classifier.GetDeletionTimestamp().IsZero() { + continue + } + if canList { + count++ + continue + } + ok, err := m.canGetClassifier(classifier.Name, user) + if err != nil { + continue + } + if ok { + count++ + } + } + return count, nil +} + +func (m *instance) countManagementClusterClassifiers(ctx context.Context, user string) (int, error) { + canList, err := m.canListManagementClusterClassifiers(user) + if err != nil { + return 0, err + } + + mccs := &libsveltosv1beta1.ManagementClusterClassifierList{} + if err := m.client.List(ctx, mccs); err != nil { + m.logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to list ManagementClusterClassifiers: %v", err)) + return 0, err + } + + count := 0 + for i := range mccs.Items { + mcc := &mccs.Items[i] + if !mcc.GetDeletionTimestamp().IsZero() { + continue + } + if canList { + count++ + continue + } + ok, err := m.canGetManagementClusterClassifier(mcc.Name, user) + if err != nil { + continue + } + if ok { + count++ + } + } + return count, nil +} diff --git a/manifest/manifest.yaml b/manifest/manifest.yaml index b8ba61f..c4e8c96 100644 --- a/manifest/manifest.yaml +++ b/manifest/manifest.yaml @@ -68,6 +68,9 @@ rules: - apiGroups: - lib.projectsveltos.io resources: + - classifierreports + - classifiers + - classifiers/status - debuggingconfigurations - eventreports - eventreports/status @@ -75,6 +78,9 @@ rules: - eventsources/status - eventtriggers - eventtriggers/status + - managementclusterclassifierreports + - managementclusterclassifiers + - managementclusterclassifiers/status - sveltosclusters - sveltosclusters/status verbs: @@ -147,7 +153,12 @@ spec: - --v=5 command: - /manager - image: projectsveltos/ui-backend:v1.12.0 + env: + - name: TOTAL_MEMORY_LIMIT + valueFrom: + resourceFieldRef: + resource: limits.memory + image: projectsveltos/ui-backend:main livenessProbe: failureThreshold: 3 httpGet: