diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 3443439..31de6e8 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 83112b8..bec8837 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 4c310cb..6f36794 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 998dcac..5640336 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 4dda9fb..bc2ef54 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 5a95f66..d414aed 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 0000000..81717ca --- /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 7f5be14..1acaa65 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 1581708..773342d 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 a95fa41..64c5c0a 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 0000000..c1a4135 --- /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 0000000..c9882b2 --- /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 0000000..b4e7fcf --- /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 0000000..1ab3850 --- /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 0000000..6654846 --- /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 ba8d3d4..98ec98f 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 0b276f8..50272c3 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 ca426f8..d099a77 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 cedd753..b898378 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 6f9993c..5156fe9 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 204912e..cf76e69 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 3bf765a..dc207b7 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 5628bd4..d1b5113 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 1e58250..52ad1d0 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 e92cb8e..fefc058 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 cc5b368..2541317 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 9c3d135..76f0347 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 94fad17..3cccaf7 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 0000000..72dc5c0 --- /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) +}