From 8f70168cfb568a33739c3fead1f0aca09b38276e Mon Sep 17 00:00:00 2001 From: Gianluca Mardente Date: Sun, 28 Jun 2026 16:02:35 +0200 Subject: [PATCH] (feat) classify managed clusters from management-cluster resources The existing Classifier evaluates rules against resources inside each managed cluster and applies labels based on what it finds there. This works well, but leaves a gap: there is no way to label managed clusters based on resources that live on the management cluster itself. A concrete example is an Internal Developer Platform backed by Crossplane. When a team orders an addon, a Crossplane Composite Resource is created on the management cluster. The platform needs to label the target cluster in response so that Sveltos can deploy the right addons. But sveltos-agent only sees the managed cluster, not the management cluster, so the existing Classifier cannot help here. This PR introduces a new feature to cover that gap. A ManagementClusterClassifier watches resources on the management cluster and runs a Lua function that receives the full set of matched resources and returns which managed clusters should be labeled. Because the evaluation happens entirely on the management cluster, no deployment to managed clusters is needed. A ManagementClusterClassifierReport tracks the label ownership state per classifier/cluster pair, enabling the same conflict detection the existing Classifier provides through the keymanager. --- .github/workflows/main.yaml | 14 +- Makefile | 4 +- config/manager/manager.yaml | 9 +- config/rbac/kustomization.yaml | 1 + config/rbac/role.yaml | 5 + config/rbac/role_binding.yaml | 13 + config/rbac/role_extra.yaml | 20 + controllers/controllers_suite_test.go | 8 + controllers/export_test.go | 16 + controllers/keymanager/keymanager.go | 43 +- .../mgmtcluster_classifier_controller.go | 547 +++++++++++++++++ .../mgmtcluster_classifier_predicates.go | 68 +++ controllers/mgmtcluster_classifier_test.go | 316 ++++++++++ .../mgmtcluster_classifier_transformations.go | 72 +++ controllers/mgmtcluster_classifier_utils.go | 539 +++++++++++++++++ go.mod | 31 +- go.sum | 64 +- hack/tools/go.mod | 4 +- hack/tools/go.sum | 4 + main.go | 22 +- manifest/deployment-agentless.yaml | 2 + manifest/deployment-shard.yaml | 2 + manifest/manifest.yaml | 25 + pkg/agent/sveltos-agent-in-mgmt-cluster.go | 4 +- pkg/agent/sveltos-agent-in-mgmt-cluster.yaml | 4 +- pkg/agent/sveltos-agent.go | 4 +- pkg/agent/sveltos-agent.yaml | 4 +- test/clusterapi-workload.yaml | 98 ++- test/fv/mgmtcluster_classifier_test.go | 569 ++++++++++++++++++ 29 files changed, 2407 insertions(+), 105 deletions(-) create mode 100644 config/rbac/role_extra.yaml create mode 100644 controllers/mgmtcluster_classifier_controller.go create mode 100644 controllers/mgmtcluster_classifier_predicates.go create mode 100644 controllers/mgmtcluster_classifier_test.go create mode 100644 controllers/mgmtcluster_classifier_transformations.go create mode 100644 controllers/mgmtcluster_classifier_utils.go create mode 100644 test/fv/mgmtcluster_classifier_test.go diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 34434397..31de6e8f 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -16,7 +16,7 @@ jobs: - name: checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: 1.26.4 - name: Build @@ -35,7 +35,7 @@ jobs: - name: checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: 1.26.4 - name: ut @@ -48,7 +48,7 @@ jobs: - name: checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: 1.26.4 - name: fv @@ -61,7 +61,7 @@ jobs: - name: checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: 1.26.4 - name: fv-sharding @@ -74,7 +74,7 @@ jobs: - name: checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: 1.26.4 - name: fv-agentless @@ -87,7 +87,7 @@ jobs: - name: checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: 1.26.4 - name: fv-pullmode @@ -100,7 +100,7 @@ jobs: - name: checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: 1.26.4 - name: fv diff --git a/Makefile b/Makefile index 83112b82..bec8837a 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ KUBECTL := $(TOOLS_BIN_DIR)/kubectl CLUSTERCTL := $(TOOLS_BIN_DIR)/clusterctl GOLANGCI_LINT_VERSION := "v2.12.1" -CLUSTERCTL_VERSION := v1.13.2 +CLUSTERCTL_VERSION := v1.13.3 KUSTOMIZE_VER := v5.8.0 KUSTOMIZE_BIN := kustomize @@ -221,6 +221,8 @@ deploy-crds: $(KUBECTL) ## Install all required Sveltos CRDs $(KUBECTL) apply -f https://raw.githubusercontent.com/projectsveltos/libsveltos/$(TAG)/manifests/apiextensions.k8s.io_v1_customresourcedefinition_debuggingconfigurations.lib.projectsveltos.io.yaml $(KUBECTL) apply -f https://raw.githubusercontent.com/projectsveltos/libsveltos/$(TAG)/manifests/apiextensions.k8s.io_v1_customresourcedefinition_classifiers.lib.projectsveltos.io.yaml $(KUBECTL) apply -f https://raw.githubusercontent.com/projectsveltos/libsveltos/$(TAG)/manifests/apiextensions.k8s.io_v1_customresourcedefinition_classifierreports.lib.projectsveltos.io.yaml + $(KUBECTL) apply -f https://raw.githubusercontent.com/projectsveltos/libsveltos/$(TAG)/manifests/apiextensions.k8s.io_v1_customresourcedefinition_managementclusterclassifiers.lib.projectsveltos.io.yaml + $(KUBECTL) apply -f https://raw.githubusercontent.com/projectsveltos/libsveltos/$(TAG)/manifests/apiextensions.k8s.io_v1_customresourcedefinition_managementclusterclassifierreports.lib.projectsveltos.io.yaml $(KUBECTL) apply -f https://raw.githubusercontent.com/projectsveltos/libsveltos/$(TAG)/manifests/apiextensions.k8s.io_v1_customresourcedefinition_accessrequests.lib.projectsveltos.io.yaml $(KUBECTL) apply -f https://raw.githubusercontent.com/projectsveltos/libsveltos/$(TAG)/manifests/apiextensions.k8s.io_v1_customresourcedefinition_rolerequests.lib.projectsveltos.io.yaml $(KUBECTL) apply -f https://raw.githubusercontent.com/projectsveltos/libsveltos/$(TAG)/manifests/apiextensions.k8s.io_v1_customresourcedefinition_sveltosclusters.lib.projectsveltos.io.yaml diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 4c310cb6..6f36794f 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -26,13 +26,8 @@ spec: spec: securityContext: runAsNonRoot: true - # TODO(user): For common cases that do not require escalating privileges - # it is recommended to ensure that all your Pods/Containers are restrictive. - # More info: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted - # Please uncomment the following code if your project does NOT have to work on old Kubernetes - # versions < 1.19 or on vendors versions which do NOT support this field by default (i.e. Openshift < 4.11 ). - # seccompProfile: - # type: RuntimeDefault + seccompProfile: + type: RuntimeDefault initContainers: - name: migrate command: diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 998dcac9..56403364 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -7,6 +7,7 @@ resources: - service_account.yaml - role.yaml - role_binding.yaml +- role_extra.yaml # Comment the following 4 lines if you want to disable # the auth proxy (https://github.com/brancz/kube-rbac-proxy) # which protects your /metrics endpoint. diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 4dda9fb9..bc2ef544 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -65,6 +65,7 @@ rules: - classifiers - configurationbundles - configurationgroups + - managementclusterclassifiers verbs: - create - delete @@ -77,6 +78,7 @@ rules: - lib.projectsveltos.io resources: - classifierreports + - managementclusterclassifierreports verbs: - create - delete @@ -89,6 +91,8 @@ rules: resources: - classifierreports/status - classifiers/status + - managementclusterclassifierreports/status + - managementclusterclassifiers/status verbs: - get - patch @@ -97,6 +101,7 @@ rules: - lib.projectsveltos.io resources: - classifiers/finalizers + - managementclusterclassifiers/finalizers verbs: - update - apiGroups: diff --git a/config/rbac/role_binding.yaml b/config/rbac/role_binding.yaml index 5a95f66d..d414aed3 100644 --- a/config/rbac/role_binding.yaml +++ b/config/rbac/role_binding.yaml @@ -10,3 +10,16 @@ subjects: - kind: ServiceAccount name: manager namespace: system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: manager-rolebinding-extra +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: controller-role-extra +subjects: +- kind: ServiceAccount + name: manager + namespace: system diff --git a/config/rbac/role_extra.yaml b/config/rbac/role_extra.yaml new file mode 100644 index 00000000..81717ca4 --- /dev/null +++ b/config/rbac/role_extra.yaml @@ -0,0 +1,20 @@ +--- +# ManagementClusterClassifier evaluates resources on the management cluster +# to classify managed clusters and apply labels to them. +# Grant this ClusterRole the verbs (get, list, watch) on whatever API groups +# and resources your ManagementClusterClassifier instances reference in +# spec.matchResources. A ClusterRoleBinding tying this role to the classifier +# ServiceAccount is created automatically when Sveltos is installed. +# Example: to allow watching ConfigMaps and ScanResult CRs add +# +# rules: +# - apiGroups: [""] +# resources: ["configmaps"] +# verbs: ["get", "list", "watch"] +# - apiGroups: ["compliance.example.io"] +# resources: ["scanresults"] +# verbs: ["get", "list", "watch"] +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: controller-role-extra diff --git a/controllers/controllers_suite_test.go b/controllers/controllers_suite_test.go index 7f5be14f..1acaa656 100644 --- a/controllers/controllers_suite_test.go +++ b/controllers/controllers_suite_test.go @@ -129,6 +129,14 @@ var _ = BeforeSuite(func() { Expect(err).To(BeNil()) Expect(testEnv.Create(ctx, classifierReportCRD)).To(Succeed()) + mgmtClusterClassifierCRD, err := k8s_utils.GetUnstructured(crd.GetManagementClusterClassifierCRDYAML()) + Expect(err).To(BeNil()) + Expect(testEnv.Create(ctx, mgmtClusterClassifierCRD)).To(Succeed()) + + mgmtClusterClassifierReportCRD, err := k8s_utils.GetUnstructured(crd.GetManagementClusterClassifierReportCRDYAML()) + Expect(err).To(BeNil()) + Expect(testEnv.Create(ctx, mgmtClusterClassifierReportCRD)).To(Succeed()) + ns := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: sveltosNamespace, diff --git a/controllers/export_test.go b/controllers/export_test.go index 15817086..773342d1 100644 --- a/controllers/export_test.go +++ b/controllers/export_test.go @@ -90,3 +90,19 @@ var ( const ( Controlplaneendpoint = controlplaneendpoint ) + +// ManagementClusterClassifier exports for unit tests. +var ( + DoesMatchLabelFilters = doesMatchLabelFilters + RunClassificationLua = runClassificationLua + ClusterTypeFromKind = clusterTypeFromKind + MgmtClassifierAsClassifier = mgmtClassifierAsClassifier + EnsureMgmtClassifierReport = ensureMgmtClassifierReport + ClassifierLabelKeys = classifierLabelKeys + FetchResourcesForSelector = fetchResourcesForSelector + ListMgmtClassifierReports = listMgmtClassifierReports + GetMgmtClassifierReport = getMgmtClassifierReport + DeleteMgmtClassifierReport = deleteMgmtClassifierReport + ApplyLabelsToCluster = applyLabelsToCluster + RemoveLabelsFromCluster = removeLabelsFromCluster +) diff --git a/controllers/keymanager/keymanager.go b/controllers/keymanager/keymanager.go index a95fa41c..64c5c0a9 100644 --- a/controllers/keymanager/keymanager.go +++ b/controllers/keymanager/keymanager.go @@ -354,7 +354,7 @@ func isClassifierAlreadyRegistered(classifiers []string, classifierKey string) b // rebuildRegistrations rebuilds internal structures to identify Classifiers managing // labels and Classifiers currently just registered but not managing. -// Reads from ClassifierReport.Status (the authoritative post-migration location). +// Reads from ClassifierReport.Status and ManagementClusterClassifierReport.Status. func (m *instance) rebuildRegistrations(ctx context.Context, c client.Client) error { // Lock here m.chartMux.Lock() @@ -389,6 +389,47 @@ func (m *instance) rebuildRegistrations(ctx context.Context, c client.Client) er m.addManagedLabelsInCluster(classifierKey, clusterKey, unManagedKeys) } + // Also rebuild registrations from ManagementClusterClassifierReports. + // ManagementClusterClassifier names are stored with the "mgmt:" prefix to avoid + // collision with regular Classifier names. + if err := m.rebuildMgmtClassifierRegistrations(ctx, c); err != nil { + return err + } + + return nil +} + +// rebuildMgmtClassifierRegistrations reads ManagementClusterClassifierReports and registers +// each ManagementClusterClassifier's label ownership using the "mgmt:" prefix. +func (m *instance) rebuildMgmtClassifierRegistrations(ctx context.Context, c client.Client) error { + mgmtReportList := &libsveltosv1beta1.ManagementClusterClassifierReportList{} + if err := c.List(ctx, mgmtReportList); err != nil { + return err + } + + // First pass: managed labels (primary managers). + for i := range mgmtReportList.Items { + report := &mgmtReportList.Items[i] + if report.Spec.ClusterNamespace == "" || len(report.Status.ManagedLabels) == 0 { + continue + } + clusterKey := m.getClusterKey(report.Spec.ClusterNamespace, report.Spec.ClusterName, report.Spec.ClusterType) + classifierKey := m.getClassifierKey("mgmt:" + report.Spec.ClassifierName) + m.addManagedLabelsInCluster(classifierKey, clusterKey, report.Status.ManagedLabels) + } + + // Second pass: unmanaged labels (waiting to take over). + for i := range mgmtReportList.Items { + report := &mgmtReportList.Items[i] + if report.Spec.ClusterNamespace == "" || len(report.Status.UnManagedLabels) == 0 { + continue + } + clusterKey := m.getClusterKey(report.Spec.ClusterNamespace, report.Spec.ClusterName, report.Spec.ClusterType) + classifierKey := m.getClassifierKey("mgmt:" + report.Spec.ClassifierName) + unManagedKeys := m.buildSliceOfUnManagedLabels(report.Status.UnManagedLabels) + m.addManagedLabelsInCluster(classifierKey, clusterKey, unManagedKeys) + } + return nil } diff --git a/controllers/mgmtcluster_classifier_controller.go b/controllers/mgmtcluster_classifier_controller.go new file mode 100644 index 00000000..c1a41356 --- /dev/null +++ b/controllers/mgmtcluster_classifier_controller.go @@ -0,0 +1,547 @@ +/* +Copyright 2024. 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 controllers + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/projectsveltos/classifier/controllers/keymanager" + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" + logs "github.com/projectsveltos/libsveltos/lib/logsettings" + libsveltosset "github.com/projectsveltos/libsveltos/lib/set" +) + +// ManagementClusterClassifierReconciler reconciles ManagementClusterClassifier objects. +// Unlike the regular Classifier, it evaluates resources on the management cluster itself — +// no deployment to managed clusters is needed. +type ManagementClusterClassifierReconciler struct { + client.Client + Scheme *runtime.Scheme + // Mux guards GVKToClassifiers and other in-memory state. + Mux sync.Mutex + + // GVKToClassifiers maps each GVK from spec.matchResources to the set of + // ManagementClusterClassifier names (as corev1.ObjectReference) that reference it. + // Used by requeueForResource to find which classifiers to requeue when a resource changes. + GVKToClassifiers map[schema.GroupVersionKind]*libsveltosset.Set + + // controller and mgr are stored after SetupWithManager to allow dynamic watches. + controller controller.Controller + mgr ctrl.Manager + + // watchedGVKs tracks which GVKs already have a watch registered. + watchedGVKs map[schema.GroupVersionKind]bool + watchedGVKsMux sync.RWMutex + + Logger logr.Logger +} + +//+kubebuilder:rbac:groups=lib.projectsveltos.io,resources=managementclusterclassifiers,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=lib.projectsveltos.io,resources=managementclusterclassifiers/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=lib.projectsveltos.io,resources=managementclusterclassifiers/finalizers,verbs=update +//+kubebuilder:rbac:groups=lib.projectsveltos.io,resources=managementclusterclassifierreports,verbs=get;list;watch;create;update;delete +//+kubebuilder:rbac:groups=lib.projectsveltos.io,resources=managementclusterclassifierreports/status,verbs=get;update;patch + +func (r *ManagementClusterClassifierReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { + logger := ctrl.LoggerFrom(ctx) + logger.V(logs.LogDebug).Info("Reconciling") + + mcc := &libsveltosv1beta1.ManagementClusterClassifier{} + if err := r.Get(ctx, req.NamespacedName, mcc); err != nil { + if apierrors.IsNotFound(err) { + return reconcile.Result{}, nil + } + return reconcile.Result{}, fmt.Errorf("failed to fetch ManagementClusterClassifier %s: %w", req.Name, err) + } + + logger = logger.WithValues("managementclusterclassifier", mcc.Name) + + if !mcc.DeletionTimestamp.IsZero() { + return r.reconcileDelete(ctx, mcc, logger) + } + return r.reconcileNormal(ctx, mcc, logger) +} + +func (r *ManagementClusterClassifierReconciler) reconcileNormal( + ctx context.Context, mcc *libsveltosv1beta1.ManagementClusterClassifier, logger logr.Logger, +) (reconcile.Result, error) { + + logger.V(logs.LogDebug).Info("Reconciling ManagementClusterClassifier") + + if !controllerutil.ContainsFinalizer(mcc, libsveltosv1beta1.ManagementClusterClassifierFinalizer) { + patch := client.MergeFrom(mcc.DeepCopy()) + controllerutil.AddFinalizer(mcc, libsveltosv1beta1.ManagementClusterClassifierFinalizer) + if err := r.Patch(ctx, mcc, patch); err != nil { + logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to add finalizer: %v", err)) + return reconcile.Result{}, err + } + } + + // Build the sets of previously managed and newly matched clusters. + oldClusters, newClusters, luaFailures, err := r.buildClusterSets(ctx, mcc, logger) + if err != nil { + logger.V(logs.LogInfo).Error(err, "failed to build cluster sets") + _ = setMgmtClassifierFailureMessage(ctx, r.Client, mcc, err.Error()) + return reconcile.Result{Requeue: true, RequeueAfter: normalRequeueAfter}, nil + } + + km, err := keymanager.GetKeyManagerInstance(ctx, r.Client) + if err != nil { + logger.V(logs.LogInfo).Error(err, "failed to get keymanager") + return reconcile.Result{Requeue: true, RequeueAfter: normalRequeueAfter}, nil + } + + fakeClassifier := mgmtClassifierAsClassifier(mcc) + + // Remove labels from clusters that are no longer matched and delete their reports. + if err := r.removeStaleClusterLabels(ctx, fakeClassifier, mcc.Name, oldClusters, newClusters, logger); err != nil { + logger.V(logs.LogInfo).Error(err, "failed to remove stale cluster labels") + return reconcile.Result{Requeue: true, RequeueAfter: normalRequeueAfter}, nil + } + + hasConflict := false + + // Apply labels to newly matched clusters. + for key, ref := range newClusters { + clusterType, err := clusterTypeFromKind(ref.Kind) + if err != nil { + continue + } + + if err := ensureMgmtClassifierReport(ctx, r.Client, mcc.Name, ref.Namespace, ref.Name, clusterType); err != nil { + logger.V(logs.LogInfo).Error(err, fmt.Sprintf("failed to ensure report for cluster %s", key)) + continue + } + + km.RemoveStaleRegistrations(fakeClassifier, ref.Namespace, ref.Name, clusterType) + km.RegisterClassifierForLabels(fakeClassifier, ref.Namespace, ref.Name, clusterType) + + managed, unmanaged := r.classifyMgmtLabels(fakeClassifier, ref.Namespace, ref.Name, clusterType, + mcc.Spec.ClassifierLabels, km) + + if len(unmanaged) > 0 { + hasConflict = true + } + + if err := updateMgmtClassifierReportStatus(ctx, r.Client, mcc.Name, ref.Namespace, ref.Name, + clusterType, managed, unmanaged); err != nil { + logger.V(logs.LogInfo).Error(err, fmt.Sprintf("failed to update report status for cluster %s", key)) + } + + labelsToApply := r.filterManagedLabels(mcc.Spec.ClassifierLabels, managed) + if err := applyLabelsToCluster(ctx, r.Client, ref.Namespace, ref.Name, clusterType, + labelsToApply, logger); err != nil { + if !apierrors.IsNotFound(err) { + logger.V(logs.LogInfo).Error(err, fmt.Sprintf("failed to apply labels to cluster %s", key)) + } + } + } + + // Update failure message: Lua errors plus any recorded conflicts. + failMsg := strings.Join(luaFailures, "; ") + if err := setMgmtClassifierFailureMessage(ctx, r.Client, mcc, failMsg); err != nil { + logger.V(logs.LogInfo).Error(err, "failed to update failure message") + } + + // Sync GVK map and register dynamic watches for spec.matchResources. + r.syncGVKWatches(mcc, logger) + + if hasConflict { + return reconcile.Result{Requeue: true, RequeueAfter: conflictRequeueAfter}, nil + } + + logger.V(logs.LogDebug).Info("Reconcile success") + return reconcile.Result{}, nil +} + +func (r *ManagementClusterClassifierReconciler) reconcileDelete( + ctx context.Context, mcc *libsveltosv1beta1.ManagementClusterClassifier, logger logr.Logger, +) (reconcile.Result, error) { + + logger.V(logs.LogDebug).Info("Reconciling ManagementClusterClassifier delete") + + existingReports, err := listMgmtClassifierReports(ctx, r.Client, mcc.Name) + if err != nil { + logger.V(logs.LogInfo).Error(err, "failed to list reports") + return reconcile.Result{Requeue: true, RequeueAfter: deleteRequeueAfter}, nil + } + + km, err := keymanager.GetKeyManagerInstance(ctx, r.Client) + if err != nil { + logger.V(logs.LogInfo).Error(err, "failed to get keymanager") + return reconcile.Result{Requeue: true, RequeueAfter: deleteRequeueAfter}, nil + } + + fakeClassifier := mgmtClassifierAsClassifier(mcc) + + for i := range existingReports { + report := &existingReports[i] + clusterType := report.Spec.ClusterType + + // Use the in-memory keymanager rather than report.Status.ManagedLabels: the Status + // is read from the cache and can be stale (e.g. after a report was re-created in the + // same reconcile cycle). The keymanager is always authoritative. + keysToRemove := make([]string, 0, len(mcc.Spec.ClassifierLabels)) + for _, cl := range mcc.Spec.ClassifierLabels { + mgr, err := km.GetManagerForKey(report.Spec.ClusterNamespace, report.Spec.ClusterName, cl.Key, clusterType) + if err == nil && mgr == fakeClassifier.Name { + keysToRemove = append(keysToRemove, cl.Key) + } + } + + if len(keysToRemove) > 0 { + if err := removeLabelsFromCluster(ctx, r.Client, + report.Spec.ClusterNamespace, report.Spec.ClusterName, clusterType, + keysToRemove, logger); err != nil { + logger.V(logs.LogInfo).Error(err, fmt.Sprintf("failed to remove labels from cluster %s/%s", + report.Spec.ClusterNamespace, report.Spec.ClusterName)) + return reconcile.Result{Requeue: true, RequeueAfter: deleteRequeueAfter}, nil + } + } + + km.RemoveAllRegistrations(fakeClassifier, report.Spec.ClusterNamespace, report.Spec.ClusterName, clusterType) + + if err := r.Delete(ctx, report); err != nil && !apierrors.IsNotFound(err) { + logger.V(logs.LogInfo).Error(err, "failed to delete report") + return reconcile.Result{Requeue: true, RequeueAfter: deleteRequeueAfter}, nil + } + } + + r.removeFromGVKMap(mcc) + + patch := client.MergeFrom(mcc.DeepCopy()) + controllerutil.RemoveFinalizer(mcc, libsveltosv1beta1.ManagementClusterClassifierFinalizer) + if err := r.Patch(ctx, mcc, patch); err != nil { + return reconcile.Result{}, err + } + + logger.V(logs.LogDebug).Info("Reconcile delete success") + return reconcile.Result{}, nil +} + +// SetupWithManager registers the controller with the manager. +func (r *ManagementClusterClassifierReconciler) SetupWithManager(ctx context.Context, + mgr ctrl.Manager, logger logr.Logger) (controller.Controller, error) { + + r.mgr = mgr + r.watchedGVKs = make(map[schema.GroupVersionKind]bool) + + c, err := ctrl.NewControllerManagedBy(mgr). + For(&libsveltosv1beta1.ManagementClusterClassifier{}, + builder.WithPredicates( + ManagementClusterClassifierPredicate{ + Logger: mgr.GetLogger().WithValues("predicate", "managementclusterclassifierpredicate"), + }, + ), + ). + Build(r) + if err != nil { + return nil, fmt.Errorf("error creating ManagementClusterClassifier controller: %w", err) + } + + r.controller = c + return c, nil +} + +// buildClusterSets returns the set of previously managed clusters (by report) and the set of +// newly matched clusters (from classificationLua), plus any Lua validation failures. +func (r *ManagementClusterClassifierReconciler) buildClusterSets( + ctx context.Context, mcc *libsveltosv1beta1.ManagementClusterClassifier, logger logr.Logger, +) (oldClusters map[string]libsveltosv1beta1.ManagementClusterClassifierReport, + newClusters map[string]clusterRef, + luaFailures []string, + err error) { + + allResources, err := r.collectResources(ctx, mcc, logger) + if err != nil { + return nil, nil, nil, err + } + + var newClusterRefs []clusterRef + newClusterRefs, luaFailures, err = r.evaluateLua(mcc, allResources, logger) + if err != nil { + return nil, nil, nil, err + } + + existingReports, err := listMgmtClassifierReports(ctx, r.Client, mcc.Name) + if err != nil { + return nil, nil, nil, err + } + + oldClusters = make(map[string]libsveltosv1beta1.ManagementClusterClassifierReport, len(existingReports)) + for i := range existingReports { + rp := existingReports[i] + key := mgmtClusterKey(rp.Spec.ClusterNamespace, rp.Spec.ClusterName, rp.Spec.ClusterType) + oldClusters[key] = rp + } + + newClusters = make(map[string]clusterRef, len(newClusterRefs)) + for _, ref := range newClusterRefs { + ct, _ := clusterTypeFromKind(ref.Kind) + key := mgmtClusterKey(ref.Namespace, ref.Name, ct) + newClusters[key] = ref + } + + return oldClusters, newClusters, luaFailures, nil +} + +// collectResources fetches all resources matching spec.matchResources from the management cluster. +func (r *ManagementClusterClassifierReconciler) collectResources( + ctx context.Context, mcc *libsveltosv1beta1.ManagementClusterClassifier, logger logr.Logger, +) ([]*unstructured.Unstructured, error) { + + var all []*unstructured.Unstructured + for i := range mcc.Spec.MatchResources { + resources, err := fetchResourcesForSelector(ctx, r.Client, &mcc.Spec.MatchResources[i], logger) + if err != nil { + return nil, fmt.Errorf("failed to list resources for selector %d: %w", i, err) + } + all = append(all, resources...) + } + return all, nil +} + +// evaluateLua runs classificationLua and returns the valid cluster refs plus any per-entry failures. +func (r *ManagementClusterClassifierReconciler) evaluateLua( + mcc *libsveltosv1beta1.ManagementClusterClassifier, + resources []*unstructured.Unstructured, logger logr.Logger, +) ([]clusterRef, []string, error) { + + rawRefs, err := runClassificationLua(mcc.Spec.ClassificationLua, resources, logger) + if err != nil { + return nil, nil, err + } + + valid := make([]clusterRef, 0, len(rawRefs)) + var failures []string + for _, ref := range rawRefs { + if _, err := clusterTypeFromKind(ref.Kind); err != nil { + msg := fmt.Sprintf("classificationLua returned invalid kind for cluster %s/%s: %v", + ref.Namespace, ref.Name, err) + logger.V(logs.LogInfo).Info(msg) + failures = append(failures, msg) + continue + } + valid = append(valid, ref) + } + return valid, failures, nil +} + +// removeStaleClusterLabels removes labels from clusters no longer matched, removes keymanager +// registrations, and deletes the stale reports. +func (r *ManagementClusterClassifierReconciler) removeStaleClusterLabels( + ctx context.Context, + fakeClassifier *libsveltosv1beta1.Classifier, + classifierName string, + oldClusters map[string]libsveltosv1beta1.ManagementClusterClassifierReport, + newClusters map[string]clusterRef, + logger logr.Logger, +) error { + + km, err := keymanager.GetKeyManagerInstance(ctx, r.Client) + if err != nil { + return err + } + + for key := range oldClusters { + if _, ok := newClusters[key]; ok { + continue + } + + report := oldClusters[key] + ct := report.Spec.ClusterType + + if len(report.Status.ManagedLabels) > 0 { + if err := removeLabelsFromCluster(ctx, r.Client, + report.Spec.ClusterNamespace, report.Spec.ClusterName, ct, + report.Status.ManagedLabels, logger); err != nil { + logger.V(logs.LogInfo).Error(err, fmt.Sprintf("failed to remove labels from cluster %s/%s", + report.Spec.ClusterNamespace, report.Spec.ClusterName)) + return err + } + } + + km.RemoveAllRegistrations(fakeClassifier, report.Spec.ClusterNamespace, report.Spec.ClusterName, ct) + + if err := deleteMgmtClassifierReport(ctx, r.Client, classifierName, + report.Spec.ClusterNamespace, report.Spec.ClusterName, ct); err != nil { + logger.V(logs.LogInfo).Error(err, "failed to delete stale report") + return err + } + } + return nil +} + +// classifyMgmtLabels divides desired labels into managed and unmanaged via the keymanager. +func (r *ManagementClusterClassifierReconciler) classifyMgmtLabels( + fakeClassifier *libsveltosv1beta1.Classifier, + clusterNamespace, clusterName string, + clusterType libsveltosv1beta1.ClusterType, + desired []libsveltosv1beta1.ClassifierLabel, + km interface { + CanManageLabel(*libsveltosv1beta1.Classifier, string, string, string, libsveltosv1beta1.ClusterType) bool + GetManagerForKey(string, string, string, libsveltosv1beta1.ClusterType) (string, error) + }, +) (managed []string, unmanaged []libsveltosv1beta1.UnManagedLabel) { + + for i := range desired { + label := &desired[i] + if km.CanManageLabel(fakeClassifier, clusterNamespace, clusterName, label.Key, clusterType) { + managed = append(managed, label.Key) + } else { + ul := libsveltosv1beta1.UnManagedLabel{Key: label.Key} + if current, err := km.GetManagerForKey(clusterNamespace, clusterName, label.Key, clusterType); err == nil { + msg := fmt.Sprintf("classifier %s currently manages this label", current) + ul.FailureMessage = &msg + } + unmanaged = append(unmanaged, ul) + } + } + return managed, unmanaged +} + +// filterManagedLabels returns only those ClassifierLabels whose keys appear in managedKeys. +func (r *ManagementClusterClassifierReconciler) filterManagedLabels( + all []libsveltosv1beta1.ClassifierLabel, managedKeys []string, +) []libsveltosv1beta1.ClassifierLabel { + + keySet := make(map[string]bool, len(managedKeys)) + for _, k := range managedKeys { + keySet[k] = true + } + result := make([]libsveltosv1beta1.ClassifierLabel, 0, len(managedKeys)) + for i := range all { + if keySet[all[i].Key] { + result = append(result, all[i]) + } + } + return result +} + +// syncGVKWatches updates the GVK→classifier map and ensures dynamic informer watches are +// registered for every GVK declared in mcc.spec.matchResources. +func (r *ManagementClusterClassifierReconciler) syncGVKWatches( + mcc *libsveltosv1beta1.ManagementClusterClassifier, logger logr.Logger) { + + r.updateGVKMap(mcc) + for i := range mcc.Spec.MatchResources { + rs := &mcc.Spec.MatchResources[i] + gvk := schema.GroupVersionKind{Group: rs.Group, Version: rs.Version, Kind: rs.Kind} + if err := r.registerWatchForGVK(gvk); err != nil { + logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to register watch for %s: %v", gvk, err)) + } + } +} + +// updateGVKMap rebuilds the GVKToClassifiers entry for mcc under the reconciler lock. +func (r *ManagementClusterClassifierReconciler) updateGVKMap(mcc *libsveltosv1beta1.ManagementClusterClassifier) { + ref := mccObjectRef(mcc) + + r.Mux.Lock() + defer r.Mux.Unlock() + + // Remove this classifier from all GVK sets (stale entries from previous spec). + for gvk, set := range r.GVKToClassifiers { + set.Erase(ref) + if set.Len() == 0 { + delete(r.GVKToClassifiers, gvk) + } + } + + // Re-add for the current set of GVKs. + for i := range mcc.Spec.MatchResources { + rs := &mcc.Spec.MatchResources[i] + gvk := schema.GroupVersionKind{Group: rs.Group, Version: rs.Version, Kind: rs.Kind} + if r.GVKToClassifiers[gvk] == nil { + r.GVKToClassifiers[gvk] = &libsveltosset.Set{} + } + r.GVKToClassifiers[gvk].Insert(ref) + } +} + +// removeFromGVKMap removes mcc from all GVKToClassifiers entries. +func (r *ManagementClusterClassifierReconciler) removeFromGVKMap(mcc *libsveltosv1beta1.ManagementClusterClassifier) { + ref := mccObjectRef(mcc) + + r.Mux.Lock() + defer r.Mux.Unlock() + + for gvk, set := range r.GVKToClassifiers { + set.Erase(ref) + if set.Len() == 0 { + delete(r.GVKToClassifiers, gvk) + } + } +} + +// registerWatchForGVK adds an informer watch for gvk if not already registered. +func (r *ManagementClusterClassifierReconciler) registerWatchForGVK(gvk schema.GroupVersionKind) error { + r.watchedGVKsMux.Lock() + defer r.watchedGVKsMux.Unlock() + + if r.watchedGVKs[gvk] { + return nil + } + + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(gvk) + + src := source.Kind[*unstructured.Unstructured]( + r.mgr.GetCache(), + u, + handler.TypedEnqueueRequestsFromMapFunc(r.requeueForResource), + ) + + if err := r.controller.Watch(src); err != nil { + return fmt.Errorf("failed to watch GVK %s: %w", gvk, err) + } + + r.watchedGVKs[gvk] = true + return nil +} + +// mccObjectRef builds the corev1.ObjectReference used as the key in GVKToClassifiers. +func mccObjectRef(mcc *libsveltosv1beta1.ManagementClusterClassifier) *corev1.ObjectReference { + return &corev1.ObjectReference{ + APIVersion: libsveltosv1beta1.GroupVersion.String(), + Kind: libsveltosv1beta1.ManagementClusterClassifierKind, + Name: mcc.Name, + } +} + +// mgmtClusterKey returns a string key for a (namespace, name, clusterType) triple. +func mgmtClusterKey(namespace, name string, ct libsveltosv1beta1.ClusterType) string { + return fmt.Sprintf("%s/%s/%s", namespace, name, string(ct)) +} diff --git a/controllers/mgmtcluster_classifier_predicates.go b/controllers/mgmtcluster_classifier_predicates.go new file mode 100644 index 00000000..c9882b27 --- /dev/null +++ b/controllers/mgmtcluster_classifier_predicates.go @@ -0,0 +1,68 @@ +/* +Copyright 2024. 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 controllers + +import ( + "reflect" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/event" + + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" + logs "github.com/projectsveltos/libsveltos/lib/logsettings" +) + +// ManagementClusterClassifierPredicate filters ManagementClusterClassifier events. +type ManagementClusterClassifierPredicate struct { + Logger logr.Logger +} + +func (p ManagementClusterClassifierPredicate) Create(e event.CreateEvent) bool { + return true +} + +func (p ManagementClusterClassifierPredicate) Update(e event.UpdateEvent) bool { + newMCC := e.ObjectNew.(*libsveltosv1beta1.ManagementClusterClassifier) + oldMCC := e.ObjectOld.(*libsveltosv1beta1.ManagementClusterClassifier) + log := p.Logger.WithValues("predicate", "updateManagementClusterClassifier", + "managementclusterclassifier", newMCC.Name, + ) + + if oldMCC == nil { + log.V(logs.LogVerbose).Info("Old ManagementClusterClassifier is nil. Reconcile.") + return true + } + + if !reflect.DeepEqual(oldMCC.DeletionTimestamp, newMCC.DeletionTimestamp) { + return true + } + + if !reflect.DeepEqual(oldMCC.Spec, newMCC.Spec) { + log.V(logs.LogVerbose).Info("ManagementClusterClassifier Spec changed. Reconciling.") + return true + } + + return false +} + +func (p ManagementClusterClassifierPredicate) Delete(e event.DeleteEvent) bool { + return true +} + +func (p ManagementClusterClassifierPredicate) Generic(e event.GenericEvent) bool { + return false +} diff --git a/controllers/mgmtcluster_classifier_test.go b/controllers/mgmtcluster_classifier_test.go new file mode 100644 index 00000000..b4e7fcfe --- /dev/null +++ b/controllers/mgmtcluster_classifier_test.go @@ -0,0 +1,316 @@ +/* +Copyright 2024. 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 controllers_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" + + "github.com/projectsveltos/classifier/controllers" +) + +const ( + testLabelEnv = "env" + testLabelApp = "app" + testValueProd = "prod" +) + +var _ = Describe("ManagementClusterClassifier utils", func() { + + Context("doesMatchLabelFilters", func() { + It("returns true when all Equal filters match", func() { + u := buildUnstructuredConfigMap("cm", map[string]string{ + testLabelEnv: testValueProd, + testLabelApp: "nginx", + }) + filters := []libsveltosv1beta1.LabelFilter{ + {Key: testLabelEnv, Operation: libsveltosv1beta1.OperationEqual, Value: testValueProd}, + {Key: testLabelApp, Operation: libsveltosv1beta1.OperationHas}, + } + Expect(controllers.DoesMatchLabelFilters(u, filters)).To(BeTrue()) + }) + + It("returns false when an Equal filter value does not match", func() { + u := buildUnstructuredConfigMap("cm", map[string]string{testLabelEnv: "dev"}) + filters := []libsveltosv1beta1.LabelFilter{ + {Key: testLabelEnv, Operation: libsveltosv1beta1.OperationEqual, Value: testValueProd}, + } + Expect(controllers.DoesMatchLabelFilters(u, filters)).To(BeFalse()) + }) + + It("returns false when a Has filter key is missing", func() { + u := buildUnstructuredConfigMap("cm", map[string]string{"other": "x"}) + filters := []libsveltosv1beta1.LabelFilter{ + {Key: testLabelEnv, Operation: libsveltosv1beta1.OperationHas}, + } + Expect(controllers.DoesMatchLabelFilters(u, filters)).To(BeFalse()) + }) + + It("returns false when DoesNotHave filter key is present", func() { + u := buildUnstructuredConfigMap("cm", map[string]string{testLabelEnv: "x"}) + filters := []libsveltosv1beta1.LabelFilter{ + {Key: testLabelEnv, Operation: libsveltosv1beta1.OperationDoesNotHave}, + } + Expect(controllers.DoesMatchLabelFilters(u, filters)).To(BeFalse()) + }) + + It("returns false when Different filter value equals", func() { + u := buildUnstructuredConfigMap("cm", map[string]string{testLabelEnv: testValueProd}) + filters := []libsveltosv1beta1.LabelFilter{ + {Key: testLabelEnv, Operation: libsveltosv1beta1.OperationDifferent, Value: testValueProd}, + } + Expect(controllers.DoesMatchLabelFilters(u, filters)).To(BeFalse()) + }) + + It("returns true with no filters", func() { + u := buildUnstructuredConfigMap("cm", nil) + Expect(controllers.DoesMatchLabelFilters(u, nil)).To(BeTrue()) + }) + }) + + Context("runClassificationLua", func() { + It("returns no clusters for empty resource list", func() { + script := ` +function evaluate(resources) + local result = {} + if #resources > 0 then + table.insert(result, {namespace="default", name="cluster1", kind="Cluster"}) + end + return result +end +` + refs, err := controllers.RunClassificationLua(script, nil, testEnv.GetLogger()) + Expect(err).ToNot(HaveOccurred()) + Expect(refs).To(BeEmpty()) + }) + + It("returns cluster refs when resources are present", func() { + script := ` +function evaluate(resources) + local result = {} + table.insert(result, {namespace="ns1", name="cluster1", kind="Cluster"}) + table.insert(result, {namespace="ns2", name="sc1", kind="SveltosCluster"}) + return result +end +` + resources := []*unstructured.Unstructured{ + buildUnstructuredConfigMap("cm1", nil), + } + refs, err := controllers.RunClassificationLua(script, resources, testEnv.GetLogger()) + Expect(err).ToNot(HaveOccurred()) + Expect(refs).To(HaveLen(2)) + }) + + It("returns an error when the script fails to compile", func() { + _, err := controllers.RunClassificationLua("not valid lua !@#", nil, testEnv.GetLogger()) + Expect(err).To(HaveOccurred()) + }) + + It("returns an error when evaluate function is missing", func() { + // Valid Lua but no evaluate function + _, err := controllers.RunClassificationLua(`local x = 1`, nil, testEnv.GetLogger()) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("clusterTypeFromKind", func() { + It("returns ClusterTypeCapi for Cluster (case insensitive)", func() { + ct, err := controllers.ClusterTypeFromKind("Cluster") + Expect(err).ToNot(HaveOccurred()) + Expect(ct).To(Equal(libsveltosv1beta1.ClusterTypeCapi)) + }) + + It("returns ClusterTypeSveltos for SveltosCluster", func() { + ct, err := controllers.ClusterTypeFromKind("SveltosCluster") + Expect(err).ToNot(HaveOccurred()) + Expect(ct).To(Equal(libsveltosv1beta1.ClusterTypeSveltos)) + }) + + It("returns an error for unknown kind", func() { + _, err := controllers.ClusterTypeFromKind("Unknown") + Expect(err).To(HaveOccurred()) + }) + }) + + Context("mgmtClassifierAsClassifier", func() { + It("prefixes the name with mgmt:", func() { + mcc := &libsveltosv1beta1.ManagementClusterClassifier{ + ObjectMeta: metav1.ObjectMeta{Name: "my-classifier"}, + Spec: libsveltosv1beta1.ManagementClusterClassifierSpec{ + ClassifierLabels: []libsveltosv1beta1.ClassifierLabel{ + {Key: testLabelEnv, Value: testValueProd}, + }, + }, + } + c := controllers.MgmtClassifierAsClassifier(mcc) + Expect(c.Name).To(Equal("mgmt:my-classifier")) + Expect(c.Spec.ClassifierLabels).To(HaveLen(1)) + Expect(c.Spec.ClassifierLabels[0].Key).To(Equal(testLabelEnv)) + }) + }) + + Context("classifierLabelKeys", func() { + It("returns all keys from ClassifierLabel slice", func() { + labels := []libsveltosv1beta1.ClassifierLabel{ + {Key: "k1", Value: "v1"}, + {Key: "k2", Value: "v2"}, + } + keys := controllers.ClassifierLabelKeys(labels) + Expect(keys).To(ConsistOf("k1", "k2")) + }) + + It("returns empty slice for nil input", func() { + Expect(controllers.ClassifierLabelKeys(nil)).To(HaveLen(0)) + }) + }) + + Context("ensureMgmtClassifierReport", func() { + It("creates a report when it does not exist", func() { + ns := randomString() + clusterName := randomString() + classifierName := randomString() + clusterType := libsveltosv1beta1.ClusterTypeCapi + + s := mgmtTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s). + WithStatusSubresource(&libsveltosv1beta1.ManagementClusterClassifierReport{}). + Build() + + err := controllers.EnsureMgmtClassifierReport(context.TODO(), fakeClient, + classifierName, ns, clusterName, clusterType) + Expect(err).ToNot(HaveOccurred()) + + reportName := libsveltosv1beta1.GetManagementClusterClassifierReportName( + classifierName, clusterName, &clusterType) + report := &libsveltosv1beta1.ManagementClusterClassifierReport{} + Expect(fakeClient.Get(context.TODO(), + types.NamespacedName{Namespace: ns, Name: reportName}, report)).To(Succeed()) + Expect(report.Spec.ClassifierName).To(Equal(classifierName)) + Expect(report.Spec.ClusterName).To(Equal(clusterName)) + Expect(report.Spec.ClusterType).To(Equal(clusterType)) + }) + + It("is idempotent when the report already exists", func() { + ns := randomString() + clusterName := randomString() + classifierName := randomString() + clusterType := libsveltosv1beta1.ClusterTypeCapi + + reportName := libsveltosv1beta1.GetManagementClusterClassifierReportName( + classifierName, clusterName, &clusterType) + existing := &libsveltosv1beta1.ManagementClusterClassifierReport{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: reportName}, + Spec: libsveltosv1beta1.ManagementClusterClassifierReportSpec{ + ClassifierName: classifierName, + ClusterName: clusterName, + ClusterType: clusterType, + }, + } + + s := mgmtTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s). + WithObjects(existing). + WithStatusSubresource(&libsveltosv1beta1.ManagementClusterClassifierReport{}). + Build() + + err := controllers.EnsureMgmtClassifierReport(context.TODO(), fakeClient, + classifierName, ns, clusterName, clusterType) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("listMgmtClassifierReports", func() { + It("returns all reports for the given classifier", func() { + classifierName := randomString() + clusterType := libsveltosv1beta1.ClusterTypeCapi + + r1Name := libsveltosv1beta1.GetManagementClusterClassifierReportName( + classifierName, "cluster1", &clusterType) + r2Name := libsveltosv1beta1.GetManagementClusterClassifierReportName( + classifierName, "cluster2", &clusterType) + otherName := libsveltosv1beta1.GetManagementClusterClassifierReportName( + "other-classifier", "cluster1", &clusterType) + + r1 := reportWithLabel(classifierName, "ns1", r1Name, "cluster1", clusterType) + r2 := reportWithLabel(classifierName, "ns2", r2Name, "cluster2", clusterType) + other := reportWithLabel("other-classifier", "ns1", otherName, "cluster1", clusterType) + + s := mgmtTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s). + WithObjects(r1, r2, other). + Build() + + reports, err := controllers.ListMgmtClassifierReports(context.TODO(), fakeClient, classifierName) + Expect(err).ToNot(HaveOccurred()) + Expect(reports).To(HaveLen(2)) + }) + }) +}) + +// buildUnstructuredConfigMap creates an *unstructured.Unstructured ConfigMap in the default namespace. +func buildUnstructuredConfigMap(name string, lbls map[string]string) *unstructured.Unstructured { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: name, + Labels: lbls, + }, + } + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(cm) + Expect(err).ToNot(HaveOccurred()) + u := &unstructured.Unstructured{Object: obj} + u.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) + return u +} + +// mgmtTestScheme builds a minimal scheme for fake-client tests of the new controller. +func mgmtTestScheme() *runtime.Scheme { + s := runtime.NewScheme() + Expect(libsveltosv1beta1.AddToScheme(s)).To(Succeed()) + Expect(corev1.AddToScheme(s)).To(Succeed()) + return s +} + +// reportWithLabel builds a ManagementClusterClassifierReport with the classifier name label set. +func reportWithLabel(classifierName, ns, reportName, clusterName string, + clusterType libsveltosv1beta1.ClusterType) *libsveltosv1beta1.ManagementClusterClassifierReport { + + return &libsveltosv1beta1.ManagementClusterClassifierReport{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: reportName, + Labels: libsveltosv1beta1.GetManagementClusterClassifierReportLabels( + classifierName, clusterName, &clusterType), + }, + Spec: libsveltosv1beta1.ManagementClusterClassifierReportSpec{ + ClassifierName: classifierName, + ClusterName: clusterName, + ClusterType: clusterType, + }, + } +} diff --git a/controllers/mgmtcluster_classifier_transformations.go b/controllers/mgmtcluster_classifier_transformations.go new file mode 100644 index 00000000..1ab3850e --- /dev/null +++ b/controllers/mgmtcluster_classifier_transformations.go @@ -0,0 +1,72 @@ +/* +Copyright 2024. 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 controllers + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/klog/v2/textlogger" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + logs "github.com/projectsveltos/libsveltos/lib/logsettings" +) + +// requeueForResource is the TypedEnqueueRequestsFromMapFunc handler for dynamically watched +// management-cluster resources. It maps a changed resource to the set of +// ManagementClusterClassifiers that listed its GVK in spec.matchResources. +func (r *ManagementClusterClassifierReconciler) requeueForResource( + ctx context.Context, o *unstructured.Unstructured, +) []reconcile.Request { + + gvk := schema.GroupVersionKind{ + Group: o.GroupVersionKind().Group, + Version: o.GroupVersionKind().Version, + Kind: o.GroupVersionKind().Kind, + } + + logger := textlogger.NewLogger(textlogger.NewConfig(textlogger.Verbosity(1))).WithValues( + "objectMapper", "requeueForResource", + "gvk", gvk.String(), + "namespace", o.GetNamespace(), + "name", o.GetName(), + ) + + logger.V(logs.LogDebug).Info("reacting to management-cluster resource change") + + r.Mux.Lock() + defer r.Mux.Unlock() + + classifierSet, ok := r.GVKToClassifiers[gvk] + if !ok || classifierSet == nil || classifierSet.Len() == 0 { + return nil + } + + items := classifierSet.Items() + requests := make([]ctrl.Request, 0, len(items)) + for i := range items { + logger.V(logs.LogDebug).Info(fmt.Sprintf("requeuing ManagementClusterClassifier %s", items[i].Name)) + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKey{Name: items[i].Name}, + }) + } + return requests +} diff --git a/controllers/mgmtcluster_classifier_utils.go b/controllers/mgmtcluster_classifier_utils.go new file mode 100644 index 00000000..66548469 --- /dev/null +++ b/controllers/mgmtcluster_classifier_utils.go @@ -0,0 +1,539 @@ +/* +Copyright 2024. 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 controllers + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/go-logr/logr" + lua "github.com/yuin/gopher-lua" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/controller-runtime/pkg/client" + + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" + sveltoscel "github.com/projectsveltos/libsveltos/lib/cel" + logs "github.com/projectsveltos/libsveltos/lib/logsettings" + sveltoslua "github.com/projectsveltos/libsveltos/lib/lua" +) + +// clusterRef is the struct that classificationLua must return entries of. +type clusterRef struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Kind string `json:"kind"` +} + +// resourceMatchStatus mirrors the return type of per-resource Lua evaluate scripts. +type resourceMatchStatus struct { + Matching bool `json:"matching"` + Message string `json:"message"` +} + +// fetchResourcesForSelector lists all management-cluster resources matching rs, +// applying name, namespace, LabelFilter, Selector, per-resource Evaluate and EvaluateCEL filters. +func fetchResourcesForSelector(ctx context.Context, c client.Client, + rs *libsveltosv1beta1.ResourceSelector, logger logr.Logger) ([]*unstructured.Unstructured, error) { + + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: rs.Group, + Version: rs.Version, + Kind: rs.Kind + "List", + }) + + listOpts := []client.ListOption{} + if rs.Namespace != "" { + listOpts = append(listOpts, client.InNamespace(rs.Namespace)) + } + if rs.Selector != nil { + sel, err := metav1.LabelSelectorAsSelector(rs.Selector) + if err != nil { + return nil, fmt.Errorf("invalid selector for %s/%s/%s: %w", rs.Group, rs.Version, rs.Kind, err) + } + listOpts = append(listOpts, client.MatchingLabelsSelector{Selector: sel}) + } + + if err := c.List(ctx, list, listOpts...); err != nil { + return nil, err + } + + // Compile per-resource Lua once so we don't re-parse it for every resource. + var luaState *lua.LState + if rs.Evaluate != "" { + luaState = lua.NewState() + sveltoslua.LoadModulesAndRegisterMethods(luaState) + if err := luaState.DoString(rs.Evaluate); err != nil { + luaState.Close() + return nil, fmt.Errorf("failed to compile ResourceSelector.Evaluate: %w", err) + } + defer luaState.Close() + } + + result := make([]*unstructured.Unstructured, 0, len(list.Items)) + for i := range list.Items { + u := &list.Items[i] + + if !u.GetDeletionTimestamp().IsZero() { + continue + } + if rs.Name != "" && u.GetName() != rs.Name { + continue + } + if !doesMatchLabelFilters(u, rs.LabelFilters) { + continue + } + + match, err := isResourceMatch(u, rs, luaState, logger) + if err != nil { + logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to evaluate resource %s/%s: %v", + u.GetNamespace(), u.GetName(), err)) + continue + } + if match { + result = append(result, u) + } + } + + return result, nil +} + +// isResourceMatch evaluates CEL rules and per-resource Lua for u. +// If neither filter is defined the resource matches by default. +func isResourceMatch(u *unstructured.Unstructured, rs *libsveltosv1beta1.ResourceSelector, + luaState *lua.LState, logger logr.Logger) (bool, error) { + + if len(rs.EvaluateCEL) == 0 && luaState == nil { + return true, nil + } + + if len(rs.EvaluateCEL) > 0 { + celMatch, err := sveltoscel.EvaluateRules(u, rs.EvaluateCEL, logger) + if err != nil { + return false, err + } + if celMatch { + return true, nil + } + } + + if luaState != nil { + return isMatchForEvaluateScript(u, luaState, logger) + } + + return false, nil +} + +// doesMatchLabelFilters returns true when u satisfies every LabelFilter in filters. +func doesMatchLabelFilters(u *unstructured.Unstructured, filters []libsveltosv1beta1.LabelFilter) bool { + resourceLabels := labels.Set(u.GetLabels()) + for i := range filters { + f := &filters[i] + switch f.Operation { + case libsveltosv1beta1.OperationEqual: + v, ok := resourceLabels[f.Key] + if !ok || v != f.Value { + return false + } + case libsveltosv1beta1.OperationDifferent: + v, ok := resourceLabels[f.Key] + if ok && v == f.Value { + return false + } + case libsveltosv1beta1.OperationHas: + if _, ok := resourceLabels[f.Key]; !ok { + return false + } + case libsveltosv1beta1.OperationDoesNotHave: + if _, ok := resourceLabels[f.Key]; ok { + return false + } + } + } + return true +} + +// isMatchForEvaluateScript calls the pre-compiled Lua state's evaluate function for u. +func isMatchForEvaluateScript(u *unstructured.Unstructured, l *lua.LState, logger logr.Logger) (bool, error) { + obj := sveltoslua.MapToTable(u.UnstructuredContent()) + + if err := l.CallByParam(lua.P{ + Fn: l.GetGlobal("evaluate"), + NRet: 1, + Protect: true, + }, obj); err != nil { + return false, err + } + + lv := l.Get(-1) + l.Pop(1) + + tbl, ok := lv.(*lua.LTable) + if !ok { + return false, fmt.Errorf("%s", sveltoslua.LuaTableError) + } + + goResult := sveltoslua.ToGoValue(tbl) + resultJSON, err := json.Marshal(goResult) + if err != nil { + return false, err + } + + var status resourceMatchStatus + if err := json.Unmarshal(resultJSON, &status); err != nil { + return false, err + } + if status.Message != "" { + logger.V(logs.LogDebug).Info(fmt.Sprintf("Lua evaluate message: %s", status.Message)) + } + return status.Matching, nil +} + +// runClassificationLua calls classificationLua's evaluate function with all matched resources +// and returns the cluster references it yields. +func runClassificationLua(luaScript string, resources []*unstructured.Unstructured, + logger logr.Logger) ([]clusterRef, error) { + + l := lua.NewState() + defer l.Close() + sveltoslua.LoadModulesAndRegisterMethods(l) + + if err := l.DoString(luaScript); err != nil { + return nil, fmt.Errorf("failed to compile classificationLua: %w", err) + } + + argTable := l.CreateTable(len(resources), 0) + for i := range resources { + obj := sveltoslua.MapToTable(resources[i].UnstructuredContent()) + l.RawSet(argTable, lua.LNumber(i+1), obj) + } + l.SetGlobal("resources", argTable) + + if err := l.CallByParam(lua.P{ + Fn: l.GetGlobal("evaluate"), + NRet: 1, + Protect: true, + }, argTable); err != nil { + return nil, fmt.Errorf("classificationLua evaluate failed: %w", err) + } + + lv := l.Get(-1) + l.Pop(1) + + tbl, ok := lv.(*lua.LTable) + if !ok { + return nil, fmt.Errorf("%s", sveltoslua.LuaTableError) + } + + goResult := sveltoslua.ToGoValue(tbl) + resultJSON, err := json.Marshal(goResult) + if err != nil { + return nil, err + } + + var rawResult interface{} + if err := json.Unmarshal(resultJSON, &rawResult); err != nil { + return nil, fmt.Errorf("failed to parse classificationLua return: %w", err) + } + + return convertToClusterRefs(rawResult, logger) +} + +// convertToClusterRefs converts the raw Lua return value to []clusterRef. +// gopher-lua's ToGoValue returns array tables as map[string]interface{} with numeric string keys. +func convertToClusterRefs(raw interface{}, logger logr.Logger) ([]clusterRef, error) { + switch v := raw.(type) { + case []interface{}: + return parseClusterRefSlice(v, logger) + case map[string]interface{}: + ordered := make([]interface{}, 0, len(v)) + for i := 1; i <= len(v); i++ { + entry, ok := v[fmt.Sprintf("%d", i)] + if !ok { + break + } + ordered = append(ordered, entry) + } + return parseClusterRefSlice(ordered, logger) + default: + if raw == nil { + return nil, nil + } + return nil, fmt.Errorf("unexpected classificationLua return type %T", raw) + } +} + +func parseClusterRefSlice(items []interface{}, logger logr.Logger) ([]clusterRef, error) { + refs := make([]clusterRef, 0, len(items)) + for _, item := range items { + entryJSON, err := json.Marshal(item) + if err != nil { + logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to marshal cluster ref entry: %v", err)) + continue + } + var ref clusterRef + if err := json.Unmarshal(entryJSON, &ref); err != nil { + logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to parse cluster ref entry: %v", err)) + continue + } + refs = append(refs, ref) + } + return refs, nil +} + +// listMgmtClassifierReports returns all ManagementClusterClassifierReports labeled for classifierName. +func listMgmtClassifierReports(ctx context.Context, c client.Client, + classifierName string) ([]libsveltosv1beta1.ManagementClusterClassifierReport, error) { + + list := &libsveltosv1beta1.ManagementClusterClassifierReportList{} + if err := c.List(ctx, list, client.MatchingLabels{ + libsveltosv1beta1.ManagementClusterClassifierNameLabel: classifierName, + }); err != nil { + return nil, err + } + return list.Items, nil +} + +// getMgmtClassifierReport fetches the ManagementClusterClassifierReport for the given (classifier, cluster) pair. +func getMgmtClassifierReport(ctx context.Context, c client.Client, + classifierName, clusterNamespace, clusterName string, + clusterType libsveltosv1beta1.ClusterType) (*libsveltosv1beta1.ManagementClusterClassifierReport, error) { + + name := libsveltosv1beta1.GetManagementClusterClassifierReportName(classifierName, clusterName, &clusterType) + report := &libsveltosv1beta1.ManagementClusterClassifierReport{} + if err := c.Get(ctx, types.NamespacedName{Namespace: clusterNamespace, Name: name}, report); err != nil { + return nil, err + } + return report, nil +} + +// ensureMgmtClassifierReport creates the report if it does not yet exist. Idempotent. +func ensureMgmtClassifierReport(ctx context.Context, c client.Client, + classifierName, clusterNamespace, clusterName string, + clusterType libsveltosv1beta1.ClusterType) error { + + name := libsveltosv1beta1.GetManagementClusterClassifierReportName(classifierName, clusterName, &clusterType) + existing := &libsveltosv1beta1.ManagementClusterClassifierReport{} + err := c.Get(ctx, types.NamespacedName{Namespace: clusterNamespace, Name: name}, existing) + if err == nil { + return nil + } + if !apierrors.IsNotFound(err) { + return err + } + + report := &libsveltosv1beta1.ManagementClusterClassifierReport{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: clusterNamespace, + Name: name, + Labels: libsveltosv1beta1.GetManagementClusterClassifierReportLabels( + classifierName, clusterName, &clusterType), + }, + Spec: libsveltosv1beta1.ManagementClusterClassifierReportSpec{ + ClassifierName: classifierName, + ClusterNamespace: clusterNamespace, + ClusterName: clusterName, + ClusterType: clusterType, + }, + } + if createErr := c.Create(ctx, report); createErr != nil && !apierrors.IsAlreadyExists(createErr) { + return createErr + } + return nil +} + +// deleteMgmtClassifierReport deletes the report for the given pair if it exists. +func deleteMgmtClassifierReport(ctx context.Context, c client.Client, + classifierName, clusterNamespace, clusterName string, + clusterType libsveltosv1beta1.ClusterType) error { + + report, err := getMgmtClassifierReport(ctx, c, classifierName, clusterNamespace, clusterName, clusterType) + if err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return err + } + return c.Delete(ctx, report) +} + +// updateMgmtClassifierReportStatus patches the report status with the current label ownership state. +func updateMgmtClassifierReportStatus(ctx context.Context, c client.Client, + classifierName, clusterNamespace, clusterName string, + clusterType libsveltosv1beta1.ClusterType, + managed []string, unmanaged []libsveltosv1beta1.UnManagedLabel) error { + + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + report, err := getMgmtClassifierReport(ctx, c, classifierName, clusterNamespace, clusterName, clusterType) + if err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return err + } + patch := client.MergeFrom(report.DeepCopy()) + report.Status.ManagedLabels = managed + report.Status.UnManagedLabels = unmanaged + return c.Status().Patch(ctx, report, patch) + }) +} + +// applyLabelsToCluster adds classifierLabels to the cluster. +func applyLabelsToCluster(ctx context.Context, c client.Client, + clusterNamespace, clusterName string, clusterType libsveltosv1beta1.ClusterType, + classifierLabels []libsveltosv1beta1.ClassifierLabel, logger logr.Logger) error { + + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + cluster, err := getClusterObject(ctx, c, clusterNamespace, clusterName, clusterType) + if err != nil { + return err + } + + current := cluster.GetLabels() + if current == nil { + current = make(map[string]string) + } + for i := range classifierLabels { + current[classifierLabels[i].Key] = classifierLabels[i].Value + } + cluster.SetLabels(current) + + if err := c.Update(ctx, cluster); err != nil { + logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to apply labels on cluster %s/%s: %v", + clusterNamespace, clusterName, err)) + return err + } + return nil + }) +} + +// removeLabelsFromCluster deletes the given label keys from the cluster. +func removeLabelsFromCluster(ctx context.Context, c client.Client, + clusterNamespace, clusterName string, clusterType libsveltosv1beta1.ClusterType, + labelKeys []string, logger logr.Logger) error { + + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + cluster, err := getClusterObject(ctx, c, clusterNamespace, clusterName, clusterType) + if err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return err + } + + current := cluster.GetLabels() + if current == nil { + return nil + } + changed := false + for _, k := range labelKeys { + if _, ok := current[k]; ok { + delete(current, k) + changed = true + } + } + if !changed { + return nil + } + cluster.SetLabels(current) + + if err := c.Update(ctx, cluster); err != nil { + logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to remove labels from cluster %s/%s: %v", + clusterNamespace, clusterName, err)) + return err + } + return nil + }) +} + +// getClusterObject fetches the cluster as an Unstructured object regardless of type. +func getClusterObject(ctx context.Context, c client.Client, + clusterNamespace, clusterName string, clusterType libsveltosv1beta1.ClusterType) (*unstructured.Unstructured, error) { + + u := &unstructured.Unstructured{} + switch clusterType { + case libsveltosv1beta1.ClusterTypeCapi: + u.SetGroupVersionKind(clusterv1.GroupVersion.WithKind("Cluster")) + case libsveltosv1beta1.ClusterTypeSveltos: + u.SetGroupVersionKind(libsveltosv1beta1.GroupVersion.WithKind(libsveltosv1beta1.SveltosClusterKind)) + default: + return nil, fmt.Errorf("unknown cluster type %q", clusterType) + } + if err := c.Get(ctx, types.NamespacedName{Namespace: clusterNamespace, Name: clusterName}, u); err != nil { + return nil, err + } + return u, nil +} + +// mgmtClassifierAsClassifier returns a *Classifier whose Name carries the "mgmt:" prefix so that +// the keymanager tracks ManagementClusterClassifier entries without collision with Classifier names. +// The colon character is not valid in Kubernetes resource names, making the collision structurally impossible. +func mgmtClassifierAsClassifier(mcc *libsveltosv1beta1.ManagementClusterClassifier) *libsveltosv1beta1.Classifier { + c := &libsveltosv1beta1.Classifier{} + c.Name = "mgmt:" + mcc.Name + c.Spec.ClassifierLabels = mcc.Spec.ClassifierLabels + return c +} + +// clusterTypeFromKind converts the kind string from classificationLua to a ClusterType. +func clusterTypeFromKind(kind string) (libsveltosv1beta1.ClusterType, error) { + switch { + case strings.EqualFold(kind, "Cluster"): + return libsveltosv1beta1.ClusterTypeCapi, nil + case strings.EqualFold(kind, libsveltosv1beta1.SveltosClusterKind): + return libsveltosv1beta1.ClusterTypeSveltos, nil + default: + return "", fmt.Errorf("unknown kind %q: must be Cluster or SveltosCluster", kind) + } +} + +// setMgmtClassifierFailureMessage patches ManagementClusterClassifier.Status.FailureMessage. +func setMgmtClassifierFailureMessage(ctx context.Context, c client.Client, + mcc *libsveltosv1beta1.ManagementClusterClassifier, msg string) error { + + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + current := &libsveltosv1beta1.ManagementClusterClassifier{} + if err := c.Get(ctx, types.NamespacedName{Name: mcc.Name}, current); err != nil { + return err + } + patch := client.MergeFrom(current.DeepCopy()) + if msg == "" { + current.Status.FailureMessage = nil + } else { + current.Status.FailureMessage = stringPtr(msg) + } + return c.Status().Patch(ctx, current, patch) + }) +} + +// classifierLabelKeys returns just the key strings from a ClassifierLabel slice. +func classifierLabelKeys(cl []libsveltosv1beta1.ClassifierLabel) []string { + keys := make([]string, len(cl)) + for i := range cl { + keys[i] = cl[i].Key + } + return keys +} diff --git a/go.mod b/go.mod index ba8d3d4c..98ec98f0 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,13 @@ require ( github.com/TwiN/go-color v1.4.1 github.com/gdexlab/go-render v1.0.1 github.com/go-logr/logr v1.4.3 - github.com/onsi/ginkgo/v2 v2.31.0 - github.com/onsi/gomega v1.42.0 + github.com/onsi/ginkgo/v2 v2.32.0 + github.com/onsi/gomega v1.42.1 github.com/pkg/errors v0.9.1 - github.com/projectsveltos/libsveltos v1.11.2-0.20260622062629-79f5f1f526c0 + github.com/projectsveltos/libsveltos v1.11.2-0.20260628140048-3eb0233989b6 github.com/prometheus/client_golang v1.23.2 github.com/spf13/pflag v1.0.10 + github.com/yuin/gopher-lua v1.1.2 golang.org/x/text v0.38.0 k8s.io/api v0.36.2 k8s.io/apiextensions-apiserver v0.36.2 @@ -19,8 +20,8 @@ require ( k8s.io/client-go v0.36.2 k8s.io/component-base v0.36.2 k8s.io/klog/v2 v2.140.0 - k8s.io/utils v0.0.0-20260617174310-a95e086a2553 - sigs.k8s.io/cluster-api v1.13.2 + k8s.io/utils v0.0.0-20260626114624-be93311217bd + sigs.k8s.io/cluster-api v1.13.3 sigs.k8s.io/controller-runtime v0.24.1 sigs.k8s.io/yaml v1.6.0 ) @@ -28,11 +29,14 @@ require ( require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + dario.cat/mergo v1.0.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect @@ -73,20 +77,29 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/projectsveltos/lua-utils/glua-json v0.0.0-20251212200258-2b3cdcb7c0f5 // indirect + github.com/projectsveltos/lua-utils/glua-runes v0.0.0-20251212200258-2b3cdcb7c0f5 // indirect + github.com/projectsveltos/lua-utils/glua-sprig v0.0.0-20251212200258-2b3cdcb7c0f5 // indirect + github.com/projectsveltos/lua-utils/glua-strings v0.0.0-20251212200258-2b3cdcb7c0f5 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect @@ -101,14 +114,14 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.51.0 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.45.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect diff --git a/go.sum b/go.sum index 0b276f88..50272c33 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= @@ -14,8 +16,12 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc= github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -69,6 +75,8 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -123,6 +131,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -147,6 +157,10 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -157,10 +171,10 @@ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.31.0 h1:GtuJos5DFUV9EerYJo8RhYxosYNGvOdDE5haKq6Grfs= -github.com/onsi/ginkgo/v2 v2.31.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.42.0 h1:CJby8u36xb7v34W78F8WKvqTQP7PCMIPB78IVDB73l4= -github.com/onsi/gomega v1.42.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -168,8 +182,16 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/projectsveltos/libsveltos v1.11.2-0.20260622062629-79f5f1f526c0 h1:VxpBJhQsUYjSVWwMlcV1Tm6lskYlFLVu/WmQMyGNFd0= -github.com/projectsveltos/libsveltos v1.11.2-0.20260622062629-79f5f1f526c0/go.mod h1:0BqS35yVUCvk5qXOirNgvIkNqMSQEgh4hnHud822sUs= +github.com/projectsveltos/libsveltos v1.11.2-0.20260628140048-3eb0233989b6 h1:OUEZ8cNqZTCoTW9aThlzgNI9lIINyPSHiYUJzPA6abE= +github.com/projectsveltos/libsveltos v1.11.2-0.20260628140048-3eb0233989b6/go.mod h1:0BqS35yVUCvk5qXOirNgvIkNqMSQEgh4hnHud822sUs= +github.com/projectsveltos/lua-utils/glua-json v0.0.0-20251212200258-2b3cdcb7c0f5 h1:khnc+994UszxZYu69J+R5FKiLA/Nk1JQj0EYAkwTWz0= +github.com/projectsveltos/lua-utils/glua-json v0.0.0-20251212200258-2b3cdcb7c0f5/go.mod h1:yVL8KQFa9tmcxgwl9nwIMtKgtmIVC1zaFRSCfOwYvPY= +github.com/projectsveltos/lua-utils/glua-runes v0.0.0-20251212200258-2b3cdcb7c0f5 h1:YbsebwRwTRhV8QacvEAdFqxcxHdeu7JTVtsBovbkgos= +github.com/projectsveltos/lua-utils/glua-runes v0.0.0-20251212200258-2b3cdcb7c0f5/go.mod h1:wqG0smJvO9uuoRSrP0RalHuJUfn3foEqb4CF3NbwNT0= +github.com/projectsveltos/lua-utils/glua-sprig v0.0.0-20251212200258-2b3cdcb7c0f5 h1:HhLqggyKqcdQTZVawgSc8VspzhJNFEgaQWDM1PcwpnI= +github.com/projectsveltos/lua-utils/glua-sprig v0.0.0-20251212200258-2b3cdcb7c0f5/go.mod h1:esi+znTJzieo7M60Ytx56vJZbWJ8WuJfVyRvOCOWs64= +github.com/projectsveltos/lua-utils/glua-strings v0.0.0-20251212200258-2b3cdcb7c0f5 h1:ifNj1y4pqhSSDL0B5XfCPTnFy9ZjGTzStuOJu1jE9xs= +github.com/projectsveltos/lua-utils/glua-strings v0.0.0-20251212200258-2b3cdcb7c0f5/go.mod h1:P/l817Avvelnzyb9YyMmnDYG3OabOyK8KuWF8s35kj0= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -183,6 +205,10 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -212,6 +238,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= @@ -242,23 +270,23 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -307,12 +335,12 @@ k8s.io/kube-openapi v0.0.0-20260427204847-8949caaa1199 h1:sWu4Td5mgJlwunsUydnhKE k8s.io/kube-openapi v0.0.0-20260427204847-8949caaa1199/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/streaming v0.36.2 h1:NSKthPPg9UFSKsRauVJUVGH2Dvn8fhKmY4qrMkw/p98= k8s.io/streaming v0.36.2/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= -k8s.io/utils v0.0.0-20260617174310-a95e086a2553 h1:hmGqDecjc8d7HVzWzRFl0QD9bYuYKbBEG7t8xwnVxfI= -k8s.io/utils v0.0.0-20260617174310-a95e086a2553/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs= +k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/cluster-api v1.13.2 h1:NVdbVLmh6IyfdtENQAi80AijJf/FjfQLODz/6caDjlc= -sigs.k8s.io/cluster-api v1.13.2/go.mod h1:h7cyiUh+N7sIBkSerqU8cDkYMtRlXVO1c5RoJE1p5+g= +sigs.k8s.io/cluster-api v1.13.3 h1:BlNVnjg644NnlWnxIWHbkltleFLVQwm8FmjWCSB9wGY= +sigs.k8s.io/cluster-api v1.13.3/go.mod h1:7xB2mYn7oOxSlUw7wk6TukNCjR2phn+MI0gRju3TKSk= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/hack/tools/go.mod b/hack/tools/go.mod index ca426f8a..d099a77a 100644 --- a/hack/tools/go.mod +++ b/hack/tools/go.mod @@ -6,7 +6,7 @@ require ( github.com/a8m/envsubst v1.4.3 github.com/onsi/ginkgo/v2 v2.32.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/tools v0.46.0 + golang.org/x/tools v0.47.0 k8s.io/client-go v0.36.2 sigs.k8s.io/controller-tools v0.21.0 sigs.k8s.io/kind v0.32.0 @@ -58,7 +58,7 @@ require ( golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect - golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9 // indirect + golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect diff --git a/hack/tools/go.sum b/hack/tools/go.sum index cedd7539..b898378d 100644 --- a/hack/tools/go.sum +++ b/hack/tools/go.sum @@ -219,6 +219,8 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9 h1:FjUup8XrRy7lv+XHONi6KKUSizeF2NnVrTnz/HhbohQ= golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= @@ -227,6 +229,8 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= diff --git a/main.go b/main.go index 6f9993c4..5156fe96 100644 --- a/main.go +++ b/main.go @@ -163,6 +163,8 @@ func main() { os.Exit(1) } + setupMgmtClassifierReconciler(ctx, mgr) + startSveltosClusterReconciler(mgr) //+kubebuilder:scaffold:builder @@ -365,6 +367,23 @@ func capiWatchers(ctx context.Context, mgr ctrl.Manager, } } +func setupMgmtClassifierReconciler(ctx context.Context, mgr manager.Manager) { + if _, err := getMgmtClassifierReconciler(mgr).SetupWithManager( + ctx, mgr, ctrl.Log.WithName("managementclusterclassifierreconciler")); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ManagementClusterClassifier") + os.Exit(1) + } +} + +func getMgmtClassifierReconciler(mgr manager.Manager) *controllers.ManagementClusterClassifierReconciler { + return &controllers.ManagementClusterClassifierReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + GVKToClassifiers: make(map[schema.GroupVersionKind]*libsveltosset.Set), + Logger: ctrl.Log.WithName("managementclusterclassifierreconciler"), + } +} + func getClassifierReconciler(mgr manager.Manager) *controllers.ClassifierReconciler { return &controllers.ClassifierReconciler{ Client: mgr.GetClient(), @@ -413,7 +432,8 @@ func getCtrlOptions(scheme *runtime.Scheme) ctrl.Options { Port: webhookPort, }), Cache: cache.Options{ - SyncPeriod: &syncPeriod, + SyncPeriod: &syncPeriod, + DefaultTransform: cache.TransformStripManagedFields(), }, } } diff --git a/manifest/deployment-agentless.yaml b/manifest/deployment-agentless.yaml index 204912e0..cf76e693 100644 --- a/manifest/deployment-agentless.yaml +++ b/manifest/deployment-agentless.yaml @@ -109,5 +109,7 @@ spec: - ALL securityContext: runAsNonRoot: true + seccompProfile: + type: RuntimeDefault serviceAccountName: classifier-manager terminationGracePeriodSeconds: 10 diff --git a/manifest/deployment-shard.yaml b/manifest/deployment-shard.yaml index 3bf765a6..dc207b71 100644 --- a/manifest/deployment-shard.yaml +++ b/manifest/deployment-shard.yaml @@ -109,5 +109,7 @@ spec: - ALL securityContext: runAsNonRoot: true + seccompProfile: + type: RuntimeDefault serviceAccountName: classifier-manager terminationGracePeriodSeconds: 10 diff --git a/manifest/manifest.yaml b/manifest/manifest.yaml index 5628bd4a..d1b5113e 100644 --- a/manifest/manifest.yaml +++ b/manifest/manifest.yaml @@ -11,6 +11,11 @@ metadata: --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole +metadata: + name: classifier-controller-role-extra +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole metadata: name: classifier-manager-role rules: @@ -75,6 +80,7 @@ rules: - classifiers - configurationbundles - configurationgroups + - managementclusterclassifiers verbs: - create - delete @@ -87,6 +93,7 @@ rules: - lib.projectsveltos.io resources: - classifierreports + - managementclusterclassifierreports verbs: - create - delete @@ -99,6 +106,8 @@ rules: resources: - classifierreports/status - classifiers/status + - managementclusterclassifierreports/status + - managementclusterclassifiers/status verbs: - get - patch @@ -107,6 +116,7 @@ rules: - lib.projectsveltos.io resources: - classifiers/finalizers + - managementclusterclassifiers/finalizers verbs: - update - apiGroups: @@ -143,6 +153,19 @@ subjects: name: classifier-manager namespace: projectsveltos --- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: classifier-manager-rolebinding-extra +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: classifier-controller-role-extra +subjects: +- kind: ServiceAccount + name: classifier-manager + namespace: projectsveltos +--- apiVersion: apps/v1 kind: Deployment metadata: @@ -254,5 +277,7 @@ spec: - ALL securityContext: runAsNonRoot: true + seccompProfile: + type: RuntimeDefault serviceAccountName: classifier-manager terminationGracePeriodSeconds: 10 diff --git a/pkg/agent/sveltos-agent-in-mgmt-cluster.go b/pkg/agent/sveltos-agent-in-mgmt-cluster.go index 1e58250a..52ad1d05 100644 --- a/pkg/agent/sveltos-agent-in-mgmt-cluster.go +++ b/pkg/agent/sveltos-agent-in-mgmt-cluster.go @@ -62,7 +62,7 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace - image: docker.io/projectsveltos/sveltos-agent@sha256:b2b23716cad70b4e876bd7f61353c3187976bdd80ae127c4890f46dbfdb5f56c + image: docker.io/projectsveltos/sveltos-agent@sha256:4fbd6863da36457a98c2020bc4543e3896849809404cd37f5d148299f1c14e22 livenessProbe: failureThreshold: 3 httpGet: @@ -101,6 +101,8 @@ spec: - ALL securityContext: runAsNonRoot: true + seccompProfile: + type: RuntimeDefault serviceAccountName: sveltos-agent-manager terminationGracePeriodSeconds: 10 `) diff --git a/pkg/agent/sveltos-agent-in-mgmt-cluster.yaml b/pkg/agent/sveltos-agent-in-mgmt-cluster.yaml index e92cb8ee..fefc0580 100644 --- a/pkg/agent/sveltos-agent-in-mgmt-cluster.yaml +++ b/pkg/agent/sveltos-agent-in-mgmt-cluster.yaml @@ -44,7 +44,7 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace - image: docker.io/projectsveltos/sveltos-agent@sha256:b2b23716cad70b4e876bd7f61353c3187976bdd80ae127c4890f46dbfdb5f56c + image: docker.io/projectsveltos/sveltos-agent@sha256:4fbd6863da36457a98c2020bc4543e3896849809404cd37f5d148299f1c14e22 livenessProbe: failureThreshold: 3 httpGet: @@ -83,5 +83,7 @@ spec: - ALL securityContext: runAsNonRoot: true + seccompProfile: + type: RuntimeDefault serviceAccountName: sveltos-agent-manager terminationGracePeriodSeconds: 10 diff --git a/pkg/agent/sveltos-agent.go b/pkg/agent/sveltos-agent.go index cc5b368c..25413176 100644 --- a/pkg/agent/sveltos-agent.go +++ b/pkg/agent/sveltos-agent.go @@ -221,7 +221,7 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace - image: docker.io/projectsveltos/sveltos-agent@sha256:b2b23716cad70b4e876bd7f61353c3187976bdd80ae127c4890f46dbfdb5f56c + image: docker.io/projectsveltos/sveltos-agent@sha256:4fbd6863da36457a98c2020bc4543e3896849809404cd37f5d148299f1c14e22 livenessProbe: failureThreshold: 3 httpGet: @@ -260,6 +260,8 @@ spec: - ALL securityContext: runAsNonRoot: true + seccompProfile: + type: RuntimeDefault serviceAccountName: sveltos-agent-manager terminationGracePeriodSeconds: 10 `) diff --git a/pkg/agent/sveltos-agent.yaml b/pkg/agent/sveltos-agent.yaml index 9c3d1357..76f0347b 100644 --- a/pkg/agent/sveltos-agent.yaml +++ b/pkg/agent/sveltos-agent.yaml @@ -203,7 +203,7 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace - image: docker.io/projectsveltos/sveltos-agent@sha256:b2b23716cad70b4e876bd7f61353c3187976bdd80ae127c4890f46dbfdb5f56c + image: docker.io/projectsveltos/sveltos-agent@sha256:4fbd6863da36457a98c2020bc4543e3896849809404cd37f5d148299f1c14e22 livenessProbe: failureThreshold: 3 httpGet: @@ -242,5 +242,7 @@ spec: - ALL securityContext: runAsNonRoot: true + seccompProfile: + type: RuntimeDefault serviceAccountName: sveltos-agent-manager terminationGracePeriodSeconds: 10 diff --git a/test/clusterapi-workload.yaml b/test/clusterapi-workload.yaml index 94fad17d..3cccaf7b 100644 --- a/test/clusterapi-workload.yaml +++ b/test/clusterapi-workload.yaml @@ -24,7 +24,7 @@ spec: machineInfrastructure: templateRef: apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 - kind: DockerMachineTemplate + kind: DevMachineTemplate name: quick-start-control-plane templateRef: apiVersion: controlplane.cluster.x-k8s.io/v1beta2 @@ -33,7 +33,7 @@ spec: infrastructure: templateRef: apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 - kind: DockerClusterTemplate + kind: DevClusterTemplate name: quick-start-cluster patches: - definitions: @@ -84,43 +84,30 @@ spec: - definitions: - jsonPatches: - op: add - path: /spec/template/spec/customImage + path: /spec/template/spec/backend/docker/customImage valueFrom: template: | kindest/node:{{ .builtin.machineDeployment.version | replace "+" "_" }} selector: apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 - kind: DockerMachineTemplate + kind: DevMachineTemplate matchResources: machineDeploymentClass: names: - default-worker - jsonPatches: - op: add - path: /spec/template/spec/template/customImage - valueFrom: - template: | - kindest/node:{{ .builtin.machinePool.version | replace "+" "_" }} - selector: - apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 - kind: DockerMachinePoolTemplate - matchResources: - machinePoolClass: - names: - - default-worker - - jsonPatches: - - op: add - path: /spec/template/spec/customImage + path: /spec/template/spec/backend/docker/customImage valueFrom: template: | kindest/node:{{ .builtin.controlPlane.version | replace "+" "_" }} selector: apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 - kind: DockerMachineTemplate + kind: DevMachineTemplate matchResources: controlPlane: true - description: Sets the container image that is used for running dockerMachines - for the controlPlane and default-worker machineDeployments. + description: Sets the container image that is used for running devMachines for + the controlPlane and default-worker machineDeployments. name: customImage - definitions: - jsonPatches: @@ -248,29 +235,36 @@ spec: infrastructure: templateRef: apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 - kind: DockerMachineTemplate + kind: DevMachineTemplate name: quick-start-default-worker-machinetemplate - machinePools: - - bootstrap: - templateRef: - apiVersion: bootstrap.cluster.x-k8s.io/v1beta2 - kind: KubeadmConfigTemplate - name: quick-start-default-worker-bootstraptemplate - class: default-worker - infrastructure: - templateRef: - apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 - kind: DockerMachinePoolTemplate - name: quick-start-default-worker-machinepooltemplate --- apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 -kind: DockerClusterTemplate +kind: DevClusterTemplate metadata: name: quick-start-cluster namespace: default spec: template: - spec: {} + spec: + backend: + docker: + failureDomains: + - controlPlane: true + name: fd1 + - controlPlane: true + name: fd2 + - controlPlane: true + name: fd3 + - controlPlane: true + name: fd4 + - controlPlane: true + name: fd5 + - controlPlane: false + name: fd6 + - controlPlane: false + name: fd7 + - controlPlane: false + name: fd8 --- apiVersion: controlplane.cluster.x-k8s.io/v1beta2 kind: KubeadmControlPlaneTemplate @@ -300,38 +294,32 @@ spec: value: nodefs.available<0%,nodefs.inodesFree<0%,imagefs.available<0% --- apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 -kind: DockerMachineTemplate +kind: DevMachineTemplate metadata: name: quick-start-control-plane namespace: default spec: template: spec: - extraMounts: - - containerPath: /var/run/docker.sock - hostPath: /var/run/docker.sock + backend: + docker: + extraMounts: + - containerPath: /var/run/docker.sock + hostPath: /var/run/docker.sock --- apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 -kind: DockerMachineTemplate +kind: DevMachineTemplate metadata: name: quick-start-default-worker-machinetemplate namespace: default spec: template: spec: - extraMounts: - - containerPath: /var/run/docker.sock - hostPath: /var/run/docker.sock ---- -apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 -kind: DockerMachinePoolTemplate -metadata: - name: quick-start-default-worker-machinepooltemplate - namespace: default -spec: - template: - spec: - template: {} + backend: + docker: + extraMounts: + - containerPath: /var/run/docker.sock + hostPath: /var/run/docker.sock --- apiVersion: bootstrap.cluster.x-k8s.io/v1beta2 kind: KubeadmConfigTemplate @@ -381,7 +369,7 @@ spec: enabled: true enforce: baseline warn: restricted - version: v1.35.0 + version: v1.36.1 workers: machineDeployments: - class: default-worker diff --git a/test/fv/mgmtcluster_classifier_test.go b/test/fv/mgmtcluster_classifier_test.go new file mode 100644 index 00000000..72dc5c08 --- /dev/null +++ b/test/fv/mgmtcluster_classifier_test.go @@ -0,0 +1,569 @@ +/* +Copyright 2024. 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 fv_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" +) + +const ( + mgmtLabelEnv = "mgmt-env" + mgmtLabelTier = "mgmt-tier" + capiClusterKind = "Cluster" + coreAPIVersion = "v1" + kindConfigMap = "ConfigMap" +) + +var _ = Describe("ManagementClusterClassifier: labels on managed cluster", func() { + const ( + namePrefix = "mgmt-classifier-" + ) + + It("adds, updates and removes labels on the managed cluster", Label("FV"), func() { + verifyMgmtClassifierFlow(namePrefix) + }) +}) + +func verifyMgmtClassifierFlow(namePrefix string) { + clusterNS := kindWorkloadCluster.GetNamespace() + clusterName := kindWorkloadCluster.GetName() + clusterKind := capiClusterKind + if kindWorkloadCluster.GetKind() == libsveltosv1beta1.SveltosClusterKind { + clusterKind = libsveltosv1beta1.SveltosClusterKind + } + + // Create a ConfigMap in the management cluster; classificationLua will use its presence + // to decide which cluster to label. + cmNamespace := deplNamespace + cmName := namePrefix + randomString() + cmLabelKey := "mgmt-classifier-trigger" + cmLabelValue := randomString() + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: cmNamespace, + Name: cmName, + Labels: map[string]string{cmLabelKey: cmLabelValue}, + }, + } + + Byf("Creating trigger ConfigMap %s/%s in management cluster", cmNamespace, cmName) + Expect(k8sClient.Create(context.TODO(), cm)).To(Succeed()) + defer func() { + _ = k8sClient.Delete(context.TODO(), cm) + }() + + // classificationLua: whenever at least one ConfigMap is present, label the managed cluster. + classificationLua := fmt.Sprintf(` +function evaluate(resources) + local result = {} + if #resources > 0 then + table.insert(result, {namespace=%q, name=%q, kind=%q}) + end + return result +end +`, clusterNS, clusterName, clusterKind) + + initialLabels := map[string]string{ + mgmtLabelEnv: "test", + mgmtLabelTier: "dev", + } + + mcc := buildManagementClusterClassifier(namePrefix, cmNamespace, cmLabelKey, cmLabelValue, + classificationLua, initialLabels) + + Byf("Creating ManagementClusterClassifier %s", mcc.Name) + Expect(k8sClient.Create(context.TODO(), mcc)).To(Succeed()) + + // Wait for labels to appear on the managed cluster. + Byf("Verifying initial labels are applied to cluster %s/%s", clusterNS, clusterName) + verifyMgmtClusterLabels(mcc.Spec.ClassifierLabels) + + // Verify that a ManagementClusterClassifierReport was created in the cluster namespace. + clusterType := libsveltosv1beta1.ClusterTypeCapi + if clusterKind == libsveltosv1beta1.SveltosClusterKind { + clusterType = libsveltosv1beta1.ClusterTypeSveltos + } + verifyMgmtClassifierReport(mcc.Name, clusterNS, clusterName, clusterType, initialLabels) + + // Update classifierLabels and verify the cluster picks up the new labels. + updatedLabels := map[string]string{ + mgmtLabelEnv: "staging", + mgmtLabelTier: "prod", + } + Byf("Updating ManagementClusterClassifier %s classifierLabels", mcc.Name) + currentMCC := &libsveltosv1beta1.ManagementClusterClassifier{} + Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: mcc.Name}, currentMCC)).To(Succeed()) + currentMCC.Spec.ClassifierLabels = toClassifierLabels(updatedLabels) + Expect(k8sClient.Update(context.TODO(), currentMCC)).To(Succeed()) + + Byf("Verifying updated labels are applied to cluster %s/%s", clusterNS, clusterName) + verifyMgmtClusterLabels(currentMCC.Spec.ClassifierLabels) + verifyMgmtClassifierReport(mcc.Name, clusterNS, clusterName, clusterType, updatedLabels) + + // Delete the ManagementClusterClassifier and verify labels are removed. + Byf("Deleting ManagementClusterClassifier %s", mcc.Name) + deleteMCC(mcc.Name) + + Byf("Verifying labels are removed from cluster %s/%s", clusterNS, clusterName) + verifyMgmtClusterLabelsGone(currentMCC.Spec.ClassifierLabels) + + // Clean up any residual labels on the cluster. + removeMgmtLabels(currentMCC.Spec.ClassifierLabels) +} + +// buildManagementClusterClassifier creates a ManagementClusterClassifier that watches ConfigMaps +// with the given label and applies classifierLabels to the cluster returned by classificationLua. +func buildManagementClusterClassifier( + namePrefix, cmNamespace, cmLabelKey, cmLabelValue string, + classificationLua string, + clusterLabels map[string]string, +) *libsveltosv1beta1.ManagementClusterClassifier { + + selector := &metav1.LabelSelector{ + MatchLabels: map[string]string{cmLabelKey: cmLabelValue}, + } + + return &libsveltosv1beta1.ManagementClusterClassifier{ + ObjectMeta: metav1.ObjectMeta{ + Name: namePrefix + randomString(), + }, + Spec: libsveltosv1beta1.ManagementClusterClassifierSpec{ + MatchResources: []libsveltosv1beta1.ResourceSelector{ + { + Group: "", + Version: coreAPIVersion, + Kind: kindConfigMap, + Namespace: cmNamespace, + Selector: selector, + }, + }, + ClassificationLua: classificationLua, + ClassifierLabels: toClassifierLabels(clusterLabels), + }, + } +} + +// toClassifierLabels converts a plain map to []ClassifierLabel. +func toClassifierLabels(m map[string]string) []libsveltosv1beta1.ClassifierLabel { + labels := make([]libsveltosv1beta1.ClassifierLabel, 0, len(m)) + for k, v := range m { + labels = append(labels, libsveltosv1beta1.ClassifierLabel{Key: k, Value: v}) + } + return labels +} + +// verifyMgmtClusterLabels waits until all classifierLabels are present on the managed cluster. +func verifyMgmtClusterLabels(classifierLabels []libsveltosv1beta1.ClassifierLabel) { + Byf("Verifying management-cluster-classifier labels are applied to cluster %s/%s", + kindWorkloadCluster.GetNamespace(), kindWorkloadCluster.GetName()) + Eventually(func() bool { + current, err := getCluster() + if err != nil { + return false + } + lbls := current.GetLabels() + if lbls == nil { + return false + } + for i := range classifierLabels { + cl := classifierLabels[i] + v, ok := lbls[cl.Key] + if !ok || v != cl.Value { + return false + } + } + return true + }, timeout, pollingInterval).Should(BeTrue()) +} + +// verifyMgmtClusterLabelsGone waits until all classifierLabels are absent from the managed cluster. +func verifyMgmtClusterLabelsGone(classifierLabels []libsveltosv1beta1.ClassifierLabel) { + Byf("Verifying management-cluster-classifier labels are removed from cluster %s/%s", + kindWorkloadCluster.GetNamespace(), kindWorkloadCluster.GetName()) + Eventually(func() bool { + current, err := getCluster() + if err != nil { + return false + } + lbls := current.GetLabels() + for i := range classifierLabels { + if _, ok := lbls[classifierLabels[i].Key]; ok { + return false + } + } + return true + }, timeout, pollingInterval).Should(BeTrue()) +} + +// verifyMgmtClassifierReport waits until a ManagementClusterClassifierReport exists for the pair +// and its Status.ManagedLabels contains exactly the expected label keys. +func verifyMgmtClassifierReport(classifierName, clusterNamespace, clusterName string, + clusterType libsveltosv1beta1.ClusterType, expectedLabels map[string]string) { + + reportName := libsveltosv1beta1.GetManagementClusterClassifierReportName( + classifierName, clusterName, &clusterType) + Byf("Verifying ManagementClusterClassifierReport %s/%s has ManagedLabels set", + clusterNamespace, reportName) + + Eventually(func() bool { + report := &libsveltosv1beta1.ManagementClusterClassifierReport{} + if err := k8sClient.Get(context.TODO(), + types.NamespacedName{Namespace: clusterNamespace, Name: reportName}, report); err != nil { + return false + } + if len(report.Status.ManagedLabels) != len(expectedLabels) { + return false + } + managed := make(map[string]bool, len(report.Status.ManagedLabels)) + for _, k := range report.Status.ManagedLabels { + managed[k] = true + } + for k := range expectedLabels { + if !managed[k] { + return false + } + } + return true + }, timeout, pollingInterval).Should(BeTrue()) +} + +var _ = Describe("ManagementClusterClassifier: label conflict between two instances", func() { + const ( + conflictNamePrefix = "mgmt-conflict-" + ) + + It("first instance wins; after deletion second instance takes over", Label("FV"), func() { + verifyMgmtClassifierConflictFlow(conflictNamePrefix) + }) +}) + +func verifyMgmtClassifierConflictFlow(namePrefix string) { + clusterNS := kindWorkloadCluster.GetNamespace() + clusterName := kindWorkloadCluster.GetName() + clusterKind := capiClusterKind + if kindWorkloadCluster.GetKind() == libsveltosv1beta1.SveltosClusterKind { + clusterKind = libsveltosv1beta1.SveltosClusterKind + } + + clusterType := libsveltosv1beta1.ClusterTypeCapi + if clusterKind == libsveltosv1beta1.SveltosClusterKind { + clusterType = libsveltosv1beta1.ClusterTypeSveltos + } + + // Shared ConfigMap trigger used by both classifiers. + cmNamespace := deplNamespace + cmName := namePrefix + randomString() + cmLabelKey := "mgmt-conflict-trigger" + cmLabelValue := randomString() + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: cmNamespace, + Name: cmName, + Labels: map[string]string{cmLabelKey: cmLabelValue}, + }, + } + Byf("Creating shared trigger ConfigMap %s/%s", cmNamespace, cmName) + Expect(k8sClient.Create(context.TODO(), cm)).To(Succeed()) + defer func() { + _ = k8sClient.Delete(context.TODO(), cm) + }() + + classificationLua := fmt.Sprintf(` +function evaluate(resources) + local result = {} + if #resources > 0 then + table.insert(result, {namespace=%q, name=%q, kind=%q}) + end + return result +end +`, clusterNS, clusterName, clusterKind) + + // Both MCCs claim the same label keys on the same cluster — a conflict. + conflictKey1 := "mgmt-conflict-" + randomString() + conflictKey2 := "mgmt-conflict-" + randomString() + + labels1 := map[string]string{conflictKey1: "v1-first", conflictKey2: "v2-first"} + labels2 := map[string]string{conflictKey1: "v1-second", conflictKey2: "v2-second"} + + mcc1 := buildManagementClusterClassifier(namePrefix, cmNamespace, cmLabelKey, cmLabelValue, + classificationLua, labels1) + mcc2 := buildManagementClusterClassifier(namePrefix, cmNamespace, cmLabelKey, cmLabelValue, + classificationLua, labels2) + + Byf("Creating first ManagementClusterClassifier %s", mcc1.Name) + Expect(k8sClient.Create(context.TODO(), mcc1)).To(Succeed()) + + Byf("Waiting for mcc1 labels to be applied to cluster %s/%s", clusterNS, clusterName) + verifyMgmtClusterLabels(mcc1.Spec.ClassifierLabels) + verifyMgmtClassifierReport(mcc1.Name, clusterNS, clusterName, clusterType, labels1) + + Byf("Creating second ManagementClusterClassifier %s (conflicting keys)", mcc2.Name) + Expect(k8sClient.Create(context.TODO(), mcc2)).To(Succeed()) + + conflictKeys := []string{conflictKey1, conflictKey2} + + Byf("Verifying mcc2 report has UnManagedLabels (conflict with mcc1)") + verifyMgmtClassifierReportUnManagedLabels(mcc2.Name, clusterNS, clusterName, clusterType, conflictKeys) + + Byf("Verifying cluster still has mcc1 labels") + verifyMgmtClusterLabels(mcc1.Spec.ClassifierLabels) + + Byf("Deleting first ManagementClusterClassifier %s", mcc1.Name) + deleteMCC(mcc1.Name) + + Byf("Verifying mcc2 now manages the labels after mcc1 is gone") + verifyMgmtClassifierReport(mcc2.Name, clusterNS, clusterName, clusterType, labels2) + verifyMgmtClusterLabels(mcc2.Spec.ClassifierLabels) + + Byf("Deleting second ManagementClusterClassifier %s", mcc2.Name) + deleteMCC(mcc2.Name) + + Byf("Verifying labels are removed from cluster %s/%s", clusterNS, clusterName) + verifyMgmtClusterLabelsGone(mcc2.Spec.ClassifierLabels) + + removeMgmtLabels(mcc1.Spec.ClassifierLabels) + removeMgmtLabels(mcc2.Spec.ClassifierLabels) +} + +// multiGVKLuaTemplate labels a cluster only when both a ConfigMap and a Secret are present. +// %q placeholders receive (clusterNS, clusterName, clusterKind) at call time. +const multiGVKLuaTemplate = ` +function evaluate(resources) + local hasCM = false + local hasSecret = false + for _, r in ipairs(resources) do + if r.kind == "ConfigMap" then hasCM = true end + if r.kind == "Secret" then hasSecret = true end + end + local result = {} + if hasCM and hasSecret then + table.insert(result, {namespace=%q, name=%q, kind=%q}) + end + return result +end +` + +var _ = Describe("ManagementClusterClassifier: multi-GVK requirement", func() { + const ( + namePrefix = "mgmt-multigvk-" + ) + + It("applies labels only when resources of both GVKs are present", Label("FV"), func() { + verifyMgmtMultiGVKFlow(namePrefix) + }) +}) + +func verifyMgmtMultiGVKFlow(namePrefix string) { + clusterNS := kindWorkloadCluster.GetNamespace() + clusterName := kindWorkloadCluster.GetName() + clusterKind := capiClusterKind + if kindWorkloadCluster.GetKind() == libsveltosv1beta1.SveltosClusterKind { + clusterKind = libsveltosv1beta1.SveltosClusterKind + } + + clusterType := libsveltosv1beta1.ClusterTypeCapi + if clusterKind == libsveltosv1beta1.SveltosClusterKind { + clusterType = libsveltosv1beta1.ClusterTypeSveltos + } + + // Unique label values per run so resources from parallel tests don't interfere. + cmLabelKey := "mgmt-multigvk-cm" + cmLabelValue := randomString() + svcLabelKey := "mgmt-multigvk-svc" + svcLabelValue := randomString() + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: deplNamespace, + Name: namePrefix + randomString(), + Labels: map[string]string{cmLabelKey: cmLabelValue}, + }, + } + Byf("Creating trigger ConfigMap %s/%s", deplNamespace, cm.Name) + Expect(k8sClient.Create(context.TODO(), cm)).To(Succeed()) + defer func() { _ = k8sClient.Delete(context.TODO(), cm) }() + + svcRes := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: deplNamespace, + Name: namePrefix + randomString(), + Labels: map[string]string{svcLabelKey: svcLabelValue}, + }, + } + Byf("Creating trigger Secret %s/%s", deplNamespace, svcRes.Name) + Expect(k8sClient.Create(context.TODO(), svcRes)).To(Succeed()) + defer func() { _ = k8sClient.Delete(context.TODO(), svcRes) }() + + classificationLua := fmt.Sprintf(multiGVKLuaTemplate, clusterNS, clusterName, clusterKind) + + multiGVKLabels := map[string]string{"mgmt-multigvk-role": "combined"} + + cmSelector := libsveltosv1beta1.ResourceSelector{ + Group: "", + Version: coreAPIVersion, + Kind: kindConfigMap, + Namespace: deplNamespace, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{cmLabelKey: cmLabelValue}}, + } + secretSelector := libsveltosv1beta1.ResourceSelector{ + Group: "", + Version: coreAPIVersion, + Kind: "Secret", + Namespace: deplNamespace, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{svcLabelKey: svcLabelValue}}, + } + mcc := buildMCCFromSelectors(namePrefix, + []libsveltosv1beta1.ResourceSelector{cmSelector, secretSelector}, + classificationLua, multiGVKLabels) + + Byf("Creating ManagementClusterClassifier %s", mcc.Name) + Expect(k8sClient.Create(context.TODO(), mcc)).To(Succeed()) + + Byf("Verifying labels are applied when both ConfigMap and Secret are present") + verifyMgmtClusterLabels(mcc.Spec.ClassifierLabels) + verifyMgmtClassifierReport(mcc.Name, clusterNS, clusterName, clusterType, multiGVKLabels) + + Byf("Deleting ConfigMap %s/%s to break the multi-GVK requirement", deplNamespace, cm.Name) + currentCM := &corev1.ConfigMap{} + Expect(k8sClient.Get(context.TODO(), + types.NamespacedName{Namespace: deplNamespace, Name: cm.Name}, currentCM)).To(Succeed()) + Expect(k8sClient.Delete(context.TODO(), currentCM)).To(Succeed()) + + Byf("Verifying labels are removed when only one GVK remains") + verifyMgmtClusterLabelsGone(mcc.Spec.ClassifierLabels) + + newCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: deplNamespace, + Name: namePrefix + randomString(), + Labels: map[string]string{cmLabelKey: cmLabelValue}, + }, + } + Byf("Re-creating ConfigMap %s/%s to restore both GVKs", deplNamespace, newCM.Name) + Expect(k8sClient.Create(context.TODO(), newCM)).To(Succeed()) + defer func() { _ = k8sClient.Delete(context.TODO(), newCM) }() + + Byf("Verifying labels reappear once both GVKs are present again") + verifyMgmtClusterLabels(mcc.Spec.ClassifierLabels) + + Byf("Deleting ManagementClusterClassifier %s", mcc.Name) + deleteMCC(mcc.Name) + + verifyMgmtClusterLabelsGone(mcc.Spec.ClassifierLabels) + removeMgmtLabels(mcc.Spec.ClassifierLabels) +} + +// buildMCCFromSelectors creates a ManagementClusterClassifier with an arbitrary set of ResourceSelectors. +func buildMCCFromSelectors( + namePrefix string, + selectors []libsveltosv1beta1.ResourceSelector, + classificationLua string, + clusterLabels map[string]string, +) *libsveltosv1beta1.ManagementClusterClassifier { + + return &libsveltosv1beta1.ManagementClusterClassifier{ + ObjectMeta: metav1.ObjectMeta{ + Name: namePrefix + randomString(), + }, + Spec: libsveltosv1beta1.ManagementClusterClassifierSpec{ + MatchResources: selectors, + ClassificationLua: classificationLua, + ClassifierLabels: toClassifierLabels(clusterLabels), + }, + } +} + +// deleteMCC deletes a ManagementClusterClassifier by name and waits until it is gone. +func deleteMCC(name string) { + current := &libsveltosv1beta1.ManagementClusterClassifier{} + Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: name}, current)).To(Succeed()) + Expect(k8sClient.Delete(context.TODO(), current)).To(Succeed()) + Eventually(func() bool { + obj := &libsveltosv1beta1.ManagementClusterClassifier{} + err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: name}, obj) + return apierrors.IsNotFound(err) + }, timeout, pollingInterval).Should(BeTrue()) +} + +// verifyMgmtClassifierReportUnManagedLabels waits until the report for the given (classifier, cluster) +// pair has UnManagedLabels containing exactly the expected keys. +func verifyMgmtClassifierReportUnManagedLabels(classifierName, clusterNamespace, clusterName string, + clusterType libsveltosv1beta1.ClusterType, expectedKeys []string) { + + reportName := libsveltosv1beta1.GetManagementClusterClassifierReportName( + classifierName, clusterName, &clusterType) + Byf("Verifying ManagementClusterClassifierReport %s/%s has UnManagedLabels set", + clusterNamespace, reportName) + + Eventually(func() bool { + report := &libsveltosv1beta1.ManagementClusterClassifierReport{} + if err := k8sClient.Get(context.TODO(), + types.NamespacedName{Namespace: clusterNamespace, Name: reportName}, report); err != nil { + return false + } + if len(report.Status.UnManagedLabels) != len(expectedKeys) { + return false + } + unmanaged := make(map[string]bool, len(report.Status.UnManagedLabels)) + for _, ul := range report.Status.UnManagedLabels { + unmanaged[ul.Key] = true + } + for _, k := range expectedKeys { + if !unmanaged[k] { + return false + } + } + return true + }, timeout, pollingInterval).Should(BeTrue()) +} + +// removeMgmtLabels removes any residual classifier-applied labels from the cluster. +func removeMgmtLabels(classifierLabels []libsveltosv1beta1.ClassifierLabel) { + current, err := getCluster() + if err != nil { + return + } + lbls := current.GetLabels() + if lbls == nil { + return + } + changed := false + for i := range classifierLabels { + if _, ok := lbls[classifierLabels[i].Key]; ok { + delete(lbls, classifierLabels[i].Key) + changed = true + } + } + if !changed { + return + } + current.SetLabels(lbls) + _ = k8sClient.Update(context.TODO(), current) +}