From 97bb5f1b52d64d1a3e8b99996876fa9e6e6e9fb4 Mon Sep 17 00:00:00 2001 From: Gianluca Mardente Date: Tue, 8 Sep 2026 18:00:17 +0200 Subject: [PATCH] feat: Smooth ClusterProfile/Profile transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a __TransitionFrom__ field to `ClusterProfile`/`Profile` to allow a successor profile to declare its predecessor. When migrating a cluster between profiles via a label swap, shared resources are taken over in place—eliminating the unnecessary teardown and re-deployment cycle. Currently, moving a cluster from ClusterProfile1 to ClusterProfile2 via a label swap forces a full tear-down-then-build lifecycle. ClusterProfile1 undeploys all of its managed resources before ClusterProfile2 begins deploying. Even for resources shared or identically configured between both profiles. Example: Changing a cluster's label from sbom: v1 (matching ClusterProfile1) to sbom: v2 (matching ClusterProfile2) triggers a complete undeployment of ClusterProfile1's resources before ClusterProfile2 deploys its workload. - **API Spec**: add `TransitionFrom` field to profile spec. - **Defer predecessor teardown until the successor is ready**: hold off on deleting the old profile's resources until the new profile is fully up and running. To avoid timing issues caused by parallel updates, code check the cluster's labels directly to identify the incoming profile rather than waiting for it to register in the system. If multiple new profiles are taking over, wait until all of them are ready before removing the old one. - **Let the successor win the resource-ownership conflict**: a second, tier-independent path to "takeover allowed" when the resource's current owner is named in the claiming profile's `TransitionFrom`. - **Same for Helm**: a mirrored bypass against `chartmanager`'s tier check, since Helm ownership is a separate subsystem from raw-resource ownership. - **Pull mode**: `TransitionFrom` staged onto `ConfigurationGroup` so `sveltos-applier` can grant the same takeover locally. --- api/v1beta1/clusterpromotion_types.go | 10 + api/v1beta1/spec.go | 10 + api/v1beta1/zz_generated.deepcopy.go | 10 + ...fig.projectsveltos.io_clusterprofiles.yaml | 12 + ...g.projectsveltos.io_clusterpromotions.yaml | 12 + ...ig.projectsveltos.io_clustersummaries.yaml | 12 + .../config.projectsveltos.io_profiles.yaml | 12 + controllers/clustersummary_controller.go | 13 + controllers/clustersummary_transition.go | 211 +++++++++ controllers/clustersummary_transition_test.go | 400 ++++++++++++++++++ controllers/export_test.go | 3 + controllers/handlers_helm.go | 4 +- controllers/handlers_utils.go | 2 + controllers/profile_hash_test.go | 1 + controllers/profile_utils.go | 3 + go.mod | 2 +- go.sum | 4 +- lib/crd/clusterprofiles.go | 12 + lib/crd/clusterpromotions.go | 12 + lib/crd/clustersummaries.go | 12 + lib/crd/profiles.go | 12 + manifest/manifest.yaml | 48 +++ test/fv/helm_handoff_test.go | 4 +- test/fv/transition_test.go | 251 +++++++++++ test/fv/utils_test.go | 4 +- 25 files changed, 1068 insertions(+), 8 deletions(-) create mode 100644 controllers/clustersummary_transition.go create mode 100644 controllers/clustersummary_transition_test.go create mode 100644 test/fv/transition_test.go diff --git a/api/v1beta1/clusterpromotion_types.go b/api/v1beta1/clusterpromotion_types.go index 584e200c..bab8f200 100644 --- a/api/v1beta1/clusterpromotion_types.go +++ b/api/v1beta1/clusterpromotion_types.go @@ -124,6 +124,16 @@ type ProfileSpec struct { // until all add-ons and applications in the profiles listed as dependencies are deployed. DependsOn []string `json:"dependsOn,omitempty"` + // TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + // For matching target clusters, teardown of the replaced profiles is deferred + // until this instance reaches Provisioned. This instance is also permitted to + // adopt resources owned by the replaced profiles, ignoring tier restrictions. + // Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + // other Profiles in the same namespace. + // +listType=atomic + // +optional + TransitionFrom []string `json:"transitionFrom,omitempty"` + // PolicyRefs references all the ConfigMaps/Secrets/Flux Sources containing kubernetes resources // that need to be deployed in the matching managed clusters. // The values contained in those resources can be static or leverage Go templates for dynamic customization. diff --git a/api/v1beta1/spec.go b/api/v1beta1/spec.go index ff722905..6999cc2b 100644 --- a/api/v1beta1/spec.go +++ b/api/v1beta1/spec.go @@ -1050,6 +1050,16 @@ type Spec struct { // until all add-ons and applications in the profiles listed as dependencies are deployed. DependsOn []string `json:"dependsOn,omitempty"` + // TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + // For matching target clusters, teardown of the replaced profiles is deferred + // until this instance reaches Provisioned. This instance is also permitted to + // adopt resources owned by the replaced profiles, ignoring tier restrictions. + // Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + // other Profiles in the same namespace. + // +listType=atomic + // +optional + TransitionFrom []string `json:"transitionFrom,omitempty"` + // PolicyRefs references all the ConfigMaps/Secrets/Flux Sources containing kubernetes resources // that need to be deployed in the matching managed clusters. // The values contained in those resources can be static or leverage Go templates for dynamic customization. diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index ae5338bc..33868623 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1211,6 +1211,11 @@ func (in *ProfileSpec) DeepCopyInto(out *ProfileSpec) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.TransitionFrom != nil { + in, out := &in.TransitionFrom, &out.TransitionFrom + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.PolicyRefs != nil { in, out := &in.PolicyRefs, &out.PolicyRefs *out = make([]PolicyRef, len(*in)) @@ -1433,6 +1438,11 @@ func (in *Spec) DeepCopyInto(out *Spec) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.TransitionFrom != nil { + in, out := &in.TransitionFrom, &out.TransitionFrom + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.PolicyRefs != nil { in, out := &in.PolicyRefs, &out.PolicyRefs *out = make([]PolicyRef, len(*in)) diff --git a/config/crd/bases/config.projectsveltos.io_clusterprofiles.yaml b/config/crd/bases/config.projectsveltos.io_clusterprofiles.yaml index 5ecae5ba..a0418b6c 100644 --- a/config/crd/bases/config.projectsveltos.io_clusterprofiles.yaml +++ b/config/crd/bases/config.projectsveltos.io_clusterprofiles.yaml @@ -2096,6 +2096,18 @@ spec: format: int32 minimum: 1 type: integer + transitionFrom: + description: |- + TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + For matching target clusters, teardown of the replaced profiles is deferred + until this instance reaches Provisioned. This instance is also permitted to + adopt resources owned by the replaced profiles, ignoring tier restrictions. + Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + other Profiles in the same namespace. + items: + type: string + type: array + x-kubernetes-list-type: atomic validateHealths: description: |- ValidateHealths is a slice of checks to run against the managed cluster diff --git a/config/crd/bases/config.projectsveltos.io_clusterpromotions.yaml b/config/crd/bases/config.projectsveltos.io_clusterpromotions.yaml index 47636852..5bbb2606 100644 --- a/config/crd/bases/config.projectsveltos.io_clusterpromotions.yaml +++ b/config/crd/bases/config.projectsveltos.io_clusterpromotions.yaml @@ -1993,6 +1993,18 @@ spec: format: int32 minimum: 1 type: integer + transitionFrom: + description: |- + TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + For matching target clusters, teardown of the replaced profiles is deferred + until this instance reaches Provisioned. This instance is also permitted to + adopt resources owned by the replaced profiles, ignoring tier restrictions. + Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + other Profiles in the same namespace. + items: + type: string + type: array + x-kubernetes-list-type: atomic validateHealths: description: |- ValidateHealths is a slice of checks to run against the managed cluster diff --git a/config/crd/bases/config.projectsveltos.io_clustersummaries.yaml b/config/crd/bases/config.projectsveltos.io_clustersummaries.yaml index ae415a95..35de7103 100644 --- a/config/crd/bases/config.projectsveltos.io_clustersummaries.yaml +++ b/config/crd/bases/config.projectsveltos.io_clustersummaries.yaml @@ -2138,6 +2138,18 @@ spec: format: int32 minimum: 1 type: integer + transitionFrom: + description: |- + TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + For matching target clusters, teardown of the replaced profiles is deferred + until this instance reaches Provisioned. This instance is also permitted to + adopt resources owned by the replaced profiles, ignoring tier restrictions. + Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + other Profiles in the same namespace. + items: + type: string + type: array + x-kubernetes-list-type: atomic validateHealths: description: |- ValidateHealths is a slice of checks to run against the managed cluster diff --git a/config/crd/bases/config.projectsveltos.io_profiles.yaml b/config/crd/bases/config.projectsveltos.io_profiles.yaml index 4ac123d1..ac188b71 100644 --- a/config/crd/bases/config.projectsveltos.io_profiles.yaml +++ b/config/crd/bases/config.projectsveltos.io_profiles.yaml @@ -2096,6 +2096,18 @@ spec: format: int32 minimum: 1 type: integer + transitionFrom: + description: |- + TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + For matching target clusters, teardown of the replaced profiles is deferred + until this instance reaches Provisioned. This instance is also permitted to + adopt resources owned by the replaced profiles, ignoring tier restrictions. + Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + other Profiles in the same namespace. + items: + type: string + type: array + x-kubernetes-list-type: atomic validateHealths: description: |- ValidateHealths is a slice of checks to run against the managed cluster diff --git a/controllers/clustersummary_controller.go b/controllers/clustersummary_controller.go index 01f775af..a3b5bb50 100644 --- a/controllers/clustersummary_controller.go +++ b/controllers/clustersummary_controller.go @@ -430,6 +430,19 @@ func (r *ClusterSummaryReconciler) cleanupBeforeFinalizerRemoval(ctx context.Con return reconcile.Result{Requeue: true, RequeueAfter: deleteRequeueAfter}, nil, true } + // TransitionFrom support: do not undeploy this profile's resources on this cluster while + // a successor profile that currently matches this cluster and names this profile in + // TransitionFrom has not yet reached Provisioned. Lets the successor take over shared + // resources in place instead of undeploy-then-redeploy. + allProvisioned, transitionMsg, err := r.areSuccessorsProvisioned(ctx, clusterSummaryScope, logger) + if err != nil { + return reconcile.Result{Requeue: true, RequeueAfter: deleteRequeueAfter}, nil, true + } + clusterSummaryScope.SetDependenciesMessage(&transitionMsg) + if !allProvisioned { + return reconcile.Result{Requeue: true, RequeueAfter: deleteRequeueAfter}, nil, true + } + // still call undeploy even if cluster is deleted. Sveltos might have deployed resources // in the management cluster and those need to be removed. err = r.undeploy(ctx, clusterSummaryScope, logger) diff --git a/controllers/clustersummary_transition.go b/controllers/clustersummary_transition.go new file mode 100644 index 00000000..a5716470 --- /dev/null +++ b/controllers/clustersummary_transition.go @@ -0,0 +1,211 @@ +/* +Copyright 2026. projectsveltos.io. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/controller-runtime/pkg/client" + + configv1beta1 "github.com/projectsveltos/addon-controller/api/v1beta1" + "github.com/projectsveltos/addon-controller/lib/clusterops" + "github.com/projectsveltos/addon-controller/pkg/scope" + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" + "github.com/projectsveltos/libsveltos/lib/clusterproxy" + logs "github.com/projectsveltos/libsveltos/lib/logsettings" +) + +// This file backs TransitionFrom: a ClusterProfile/Profile can name predecessor profile(s) it is +// replacing on a cluster, so that a plain label swap (moving a cluster from one profile to +// another that deploys mostly the same resources) does not undeploy-then-redeploy shared +// resources. See doc/feature_request_clusterprofile_transition.md for the full design. + +// transitionSuccessor is a (Cluster)Profile that declares, via TransitionFrom, that it is +// replacing another (Cluster)Profile. +type transitionSuccessor struct { + kind string + name string + clusterSelector libsveltosv1beta1.Selector + clusterRefs []corev1.ObjectReference +} + +// getTransitionSuccessors returns every (Cluster)Profile that names predecessorName in its +// TransitionFrom. Same-kind only, matching TransitionFrom's own restriction: a ClusterProfile +// can only be succeeded by another ClusterProfile, a Profile only by another Profile in the +// same namespace (a Profile can only ever match clusters in its own namespace to begin with, +// so scoping this lookup to predecessorNamespace is what keeps the two kinds/namespaces from +// ever being able to cross). +func (r *ClusterSummaryReconciler) getTransitionSuccessors(ctx context.Context, + predecessorKind, predecessorName, predecessorNamespace string) ([]transitionSuccessor, error) { + + successors := make([]transitionSuccessor, 0) + + if predecessorKind == configv1beta1.ProfileKind { + profiles := &configv1beta1.ProfileList{} + if err := r.List(ctx, profiles, client.InNamespace(predecessorNamespace)); err != nil { + return nil, err + } + for i := range profiles.Items { + p := &profiles.Items[i] + if containsName(p.Spec.TransitionFrom, predecessorName) { + successors = append(successors, transitionSuccessor{ + kind: configv1beta1.ProfileKind, name: p.Name, + clusterSelector: p.Spec.ClusterSelector, clusterRefs: p.Spec.ClusterRefs, + }) + } + } + return successors, nil + } + + clusterProfiles := &configv1beta1.ClusterProfileList{} + if err := r.List(ctx, clusterProfiles); err != nil { + return nil, err + } + for i := range clusterProfiles.Items { + cp := &clusterProfiles.Items[i] + if containsName(cp.Spec.TransitionFrom, predecessorName) { + successors = append(successors, transitionSuccessor{ + kind: configv1beta1.ClusterProfileKind, name: cp.Name, + clusterSelector: cp.Spec.ClusterSelector, clusterRefs: cp.Spec.ClusterRefs, + }) + } + } + return successors, nil +} + +// isChartTransitioningFrom returns true if claimingHelmManager's owning profile declares, via +// TransitionFrom, that it is replacing currentHelmManager's owning profile. Mirrors +// isTransitioningFrom in libsveltos/lib/deployer (used for raw resources), but reads ownership +// off the two ClusterSummary's own OwnerReferences instead of an annotation on the deployed +// object, since chartmanager tracks Helm ownership by ClusterSummary rather than by annotation. +func isChartTransitioningFrom(currentHelmManager, claimingHelmManager *configv1beta1.ClusterSummary) bool { + transitionFrom := claimingHelmManager.Spec.ClusterProfileSpec.TransitionFrom + if len(transitionFrom) == 0 { + return false + } + + currentOwner, err := configv1beta1.GetProfileOwnerReference(currentHelmManager) + if err != nil || currentOwner == nil { + return false + } + claimingOwner, err := configv1beta1.GetProfileOwnerReference(claimingHelmManager) + if err != nil || claimingOwner == nil { + return false + } + // Same-kind only, matching TransitionFrom's own restriction. + if currentOwner.Kind != claimingOwner.Kind { + return false + } + + return containsName(transitionFrom, currentOwner.Name) +} + +func containsName(names []string, target string) bool { + for i := range names { + if names[i] == target { + return true + } + } + return false +} + +// areSuccessorsProvisioned is the mirror of areDependentsRemoved for TransitionFrom: it defers +// this ClusterSummary's teardown while another (Cluster)Profile that currently matches this +// cluster names this profile in TransitionFrom and has not reached Provisioned yet, letting the +// successor take over shared resources in place instead of undeploy-then-redeploy. +// +// This cannot key off ClusterSummary existence the way areDependentsRemoved keys off DependsOn: +// the predecessor and successor profiles are woken by the same label-change event on independent +// reconcile loops, so the predecessor's teardown can run before the successor has even created +// its ClusterSummary. At that instant "successor ClusterSummary not found" is indistinguishable +// from "successor was never going to match this cluster." So this gates on whether the +// successor's own ClusterSelector/ClusterRefs match this cluster directly instead - that answer +// is available the instant this reconciles, regardless of whether the successor has reconciled +// yet. +// +// Multiple successors can independently name the same predecessor (fan-in): teardown waits until +// every currently-matching successor reaches Provisioned, not just one, since each may be +// responsible for a different subset of the same resources. +func (r *ClusterSummaryReconciler) areSuccessorsProvisioned(ctx context.Context, + clusterSummaryScope *scope.ClusterSummaryScope, logger logr.Logger) (allProvisioned bool, message string, err error) { + + clusterSummary := clusterSummaryScope.ClusterSummary + + profileReference, err := configv1beta1.GetProfileOwnerReference(clusterSummary) + if err != nil { + logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to get profile owner: %v", err)) + return false, "", fmt.Errorf("failed to get profile owner: %w", err) + } + if profileReference == nil { + return false, "", fmt.Errorf("profile owner not found") + } + + successors, err := r.getTransitionSuccessors(ctx, profileReference.Kind, profileReference.Name, + clusterSummary.Namespace) + if err != nil { + return false, "", err + } + if len(successors) == 0 { + return true, "no transitions pending", nil + } + + cluster, err := clusterproxy.GetCluster(ctx, r.Client, clusterSummary.Spec.ClusterNamespace, + clusterSummary.Spec.ClusterName, clusterSummary.Spec.ClusterType) + if err != nil { + if apierrors.IsNotFound(err) { + // Cluster is gone. Nothing can still be matching it. + return true, "cluster not found", nil + } + return false, "", err + } + clusterLabels := labels.Set(cluster.GetLabels()) + + for i := range successors { + successor := successors[i] + if !selectorMatchesCluster(successor.clusterSelector, successor.clusterRefs, clusterSummary, clusterLabels) { + // This successor does not (or no longer) match this cluster: it is not part of a + // transition on this cluster, whether or not it is still Provisioned elsewhere. + continue + } + + successorClusterSummary, err := clusterops.GetClusterSummary(ctx, r.Client, successor.kind, successor.name, + clusterSummary.Spec.ClusterNamespace, clusterSummary.Spec.ClusterName, clusterSummary.Spec.ClusterType) + if err != nil { + if apierrors.IsNotFound(err) { + msg := fmt.Sprintf("%s %s is replacing this profile on this cluster but has not deployed yet", + successor.kind, successor.name) + logger.V(logs.LogInfo).Info(msg) + return false, msg, nil + } + return false, "", err + } + + if !isCluterSummaryProvisioned(successorClusterSummary) { + msg := fmt.Sprintf("%s %s is replacing this profile on this cluster but is not fully deployed yet", + successor.kind, successor.name) + logger.V(logs.LogInfo).Info(msg) + return false, msg, nil + } + } + + return true, "all transitioning successors are provisioned", nil +} diff --git a/controllers/clustersummary_transition_test.go b/controllers/clustersummary_transition_test.go new file mode 100644 index 00000000..39277493 --- /dev/null +++ b/controllers/clustersummary_transition_test.go @@ -0,0 +1,400 @@ +/* +Copyright 2026. projectsveltos.io. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers_test + +import ( + "context" + "sync" + + . "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/klog/v2/textlogger" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + configv1beta1 "github.com/projectsveltos/addon-controller/api/v1beta1" + "github.com/projectsveltos/addon-controller/controllers" + "github.com/projectsveltos/addon-controller/lib/clusterops" + "github.com/projectsveltos/addon-controller/pkg/scope" + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" + fakedeployer "github.com/projectsveltos/libsveltos/lib/deployer/fake" + libsveltosset "github.com/projectsveltos/libsveltos/lib/set" +) + +var _ = Describe("TransitionFrom", func() { + var predecessor *configv1beta1.ClusterProfile + var predecessorSummary *configv1beta1.ClusterSummary + var cluster *clusterv1.Cluster + var namespace string + var clusterName string + + BeforeEach(func() { + namespace = randomString() + clusterName = randomString() + + cluster = &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: namespace, + Labels: map[string]string{ + testDCLabelKey: testEngValue, + }, + }, + } + + predecessor = &configv1beta1.ClusterProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterProfileNamePrefix + randomString(), + }, + Spec: configv1beta1.Spec{ + ClusterSelector: libsveltosv1beta1.Selector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{testDCLabelKey: testEngValue}, + }, + }, + }, + } + + predecessorSummaryName := clusterops.GetClusterSummaryName(configv1beta1.ClusterProfileKind, + predecessor.Name, clusterName, false) + predecessorSummary = &configv1beta1.ClusterSummary{ + ObjectMeta: metav1.ObjectMeta{ + Name: predecessorSummaryName, + Namespace: namespace, + // Set directly rather than via addOwnerReference: that helper only updates a + // deep copy in the fake client's store, not this local pointer - and it's this + // pointer, not the client's copy, that ends up in the ClusterSummaryScope below. + OwnerReferences: []metav1.OwnerReference{ + {APIVersion: configv1beta1.GroupVersion.String(), Kind: configv1beta1.ClusterProfileKind, Name: predecessor.Name}, + }, + }, + Spec: configv1beta1.ClusterSummarySpec{ + ClusterNamespace: cluster.Namespace, + ClusterName: cluster.Name, + ClusterType: libsveltosv1beta1.ClusterTypeCapi, + }, + } + }) + + newReconciler := func(initObjects ...client.Object) *controllers.ClusterSummaryReconciler { + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(initObjects...).WithObjects(initObjects...).Build() + + deployer := fakedeployer.GetClient(context.TODO(), textlogger.NewLogger(textlogger.NewConfig()), c) + return &controllers.ClusterSummaryReconciler{ + Client: c, + Scheme: scheme, + Deployer: deployer, + ClusterMap: make(map[corev1.ObjectReference]*libsveltosset.Set), + ReferenceMap: make(map[corev1.ObjectReference]*libsveltosset.Set), + PolicyMux: sync.Mutex{}, + } + } + + newScope := func(reconciler *controllers.ClusterSummaryReconciler) *scope.ClusterSummaryScope { + clusterSummaryScope, err := scope.NewClusterSummaryScope(&scope.ClusterSummaryScopeParams{ + Client: reconciler.Client, + Logger: textlogger.NewLogger(textlogger.NewConfig()), + ClusterSummary: predecessorSummary, + ControllerName: testControllerNameSummary, + }) + Expect(err).To(BeNil()) + return clusterSummaryScope + } + + It("getTransitionSuccessors only returns same-kind profiles naming the predecessor", func() { + successor := &configv1beta1.ClusterProfile{ + ObjectMeta: metav1.ObjectMeta{Name: clusterProfileNamePrefix + randomString()}, + Spec: configv1beta1.Spec{TransitionFrom: []string{predecessor.Name}}, + } + unrelated := &configv1beta1.ClusterProfile{ + ObjectMeta: metav1.ObjectMeta{Name: clusterProfileNamePrefix + randomString()}, + Spec: configv1beta1.Spec{TransitionFrom: []string{randomString()}}, + } + // A Profile naming the same string as the predecessor's name must never count: TransitionFrom + // is same-kind only. + crossKind := &configv1beta1.Profile{ + ObjectMeta: metav1.ObjectMeta{Name: randomString(), Namespace: namespace}, + Spec: configv1beta1.Spec{TransitionFrom: []string{predecessor.Name}}, + } + + reconciler := newReconciler(predecessorSummary, predecessor, successor, unrelated, crossKind) + + successors, err := controllers.GetTransitionSuccessors(reconciler, context.TODO(), + configv1beta1.ClusterProfileKind, predecessor.Name, namespace) + Expect(err).To(BeNil()) + Expect(successors).To(HaveLen(1)) + }) + + It("getTransitionSuccessors scopes Profile successors to the predecessor's namespace", func() { + profilePredecessorName := randomString() + sameNamespaceSuccessor := &configv1beta1.Profile{ + ObjectMeta: metav1.ObjectMeta{Name: randomString(), Namespace: namespace}, + Spec: configv1beta1.Spec{TransitionFrom: []string{profilePredecessorName}}, + } + otherNamespaceSuccessor := &configv1beta1.Profile{ + ObjectMeta: metav1.ObjectMeta{Name: randomString(), Namespace: randomString()}, + Spec: configv1beta1.Spec{TransitionFrom: []string{profilePredecessorName}}, + } + + reconciler := newReconciler(predecessorSummary, predecessor, sameNamespaceSuccessor, otherNamespaceSuccessor) + + // A missing (or wrong) namespace scope would return both: proves scoping is enforced, + // not just that a match exists. + successors, err := controllers.GetTransitionSuccessors(reconciler, context.TODO(), + configv1beta1.ProfileKind, profilePredecessorName, namespace) + Expect(err).To(BeNil()) + Expect(successors).To(HaveLen(1)) + }) + + It("areSuccessorsProvisioned returns true when no successor names the predecessor", func() { + reconciler := newReconciler(predecessorSummary, predecessor, cluster) + provisioned, _, err := controllers.AreSuccessorsProvisioned(reconciler, context.TODO(), newScope(reconciler), + textlogger.NewLogger(textlogger.NewConfig())) + Expect(err).To(BeNil()) + Expect(provisioned).To(BeTrue()) + }) + + It("areSuccessorsProvisioned returns true when the successor does not match this cluster", func() { + successor := &configv1beta1.ClusterProfile{ + ObjectMeta: metav1.ObjectMeta{Name: clusterProfileNamePrefix + randomString()}, + Spec: configv1beta1.Spec{ + TransitionFrom: []string{predecessor.Name}, + ClusterSelector: libsveltosv1beta1.Selector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{randomString(): randomString()}, + }, + }, + }, + } + + reconciler := newReconciler(predecessorSummary, predecessor, cluster, successor) + provisioned, _, err := controllers.AreSuccessorsProvisioned(reconciler, context.TODO(), newScope(reconciler), + textlogger.NewLogger(textlogger.NewConfig())) + Expect(err).To(BeNil()) + Expect(provisioned).To(BeTrue()) + }) + + It("areSuccessorsProvisioned blocks when a matching successor has not created its ClusterSummary yet", func() { + // This is the reconcile-order race: the successor's own reconcile has not run yet, so its + // ClusterSummary does not exist. Gating on existence would let teardown proceed here; gating + // on the selector (as implemented) must not. + successor := &configv1beta1.ClusterProfile{ + ObjectMeta: metav1.ObjectMeta{Name: clusterProfileNamePrefix + randomString()}, + Spec: configv1beta1.Spec{ + TransitionFrom: []string{predecessor.Name}, + ClusterSelector: libsveltosv1beta1.Selector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{testDCLabelKey: testEngValue}, + }, + }, + }, + } + + reconciler := newReconciler(predecessorSummary, predecessor, cluster, successor) + provisioned, msg, err := controllers.AreSuccessorsProvisioned(reconciler, context.TODO(), newScope(reconciler), + textlogger.NewLogger(textlogger.NewConfig())) + Expect(err).To(BeNil()) + Expect(provisioned).To(BeFalse()) + Expect(msg).To(ContainSubstring(successor.Name)) + }) + + It("areSuccessorsProvisioned blocks while the matching successor's ClusterSummary is not yet Provisioned", func() { + successor := &configv1beta1.ClusterProfile{ + ObjectMeta: metav1.ObjectMeta{Name: clusterProfileNamePrefix + randomString()}, + Spec: configv1beta1.Spec{ + TransitionFrom: []string{predecessor.Name}, + ClusterSelector: libsveltosv1beta1.Selector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{testDCLabelKey: testEngValue}, + }, + }, + }, + } + successorSummaryName := clusterops.GetClusterSummaryName(configv1beta1.ClusterProfileKind, + successor.Name, clusterName, false) + successorSummary := &configv1beta1.ClusterSummary{ + ObjectMeta: metav1.ObjectMeta{Name: successorSummaryName, Namespace: namespace}, + Spec: configv1beta1.ClusterSummarySpec{ + ClusterNamespace: cluster.Namespace, + ClusterName: cluster.Name, + ClusterType: libsveltosv1beta1.ClusterTypeCapi, + ClusterProfileSpec: configv1beta1.Spec{ + PolicyRefs: []configv1beta1.PolicyRef{ + {Kind: string(libsveltosv1beta1.ConfigMapReferencedResourceKind), Namespace: namespace, Name: randomString()}, + }, + }, + }, + // No FeatureSummaries yet: not Provisioned. + } + successorSummary.Labels = map[string]string{ + clusterops.ClusterProfileLabelName: successor.Name, + configv1beta1.ClusterNameLabel: clusterName, + configv1beta1.ClusterTypeLabel: string(libsveltosv1beta1.ClusterTypeCapi), + } + + reconciler := newReconciler(predecessorSummary, predecessor, cluster, successor, successorSummary) + provisioned, msg, err := controllers.AreSuccessorsProvisioned(reconciler, context.TODO(), newScope(reconciler), + textlogger.NewLogger(textlogger.NewConfig())) + Expect(err).To(BeNil()) + Expect(provisioned).To(BeFalse()) + Expect(msg).To(ContainSubstring(successor.Name)) + }) + + It("areSuccessorsProvisioned proceeds once the matching successor is Provisioned", func() { + successor := &configv1beta1.ClusterProfile{ + ObjectMeta: metav1.ObjectMeta{Name: clusterProfileNamePrefix + randomString()}, + Spec: configv1beta1.Spec{ + TransitionFrom: []string{predecessor.Name}, + ClusterSelector: libsveltosv1beta1.Selector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{testDCLabelKey: testEngValue}, + }, + }, + }, + } + successorSummaryName := clusterops.GetClusterSummaryName(configv1beta1.ClusterProfileKind, + successor.Name, clusterName, false) + // Empty spec (no helm/policyRefs/kustomize) and no FeatureSummaries: trivially Provisioned, + // same shape isCluterSummaryProvisioned already treats as done elsewhere. + successorSummary := &configv1beta1.ClusterSummary{ + ObjectMeta: metav1.ObjectMeta{Name: successorSummaryName, Namespace: namespace}, + Spec: configv1beta1.ClusterSummarySpec{ + ClusterNamespace: cluster.Namespace, + ClusterName: cluster.Name, + ClusterType: libsveltosv1beta1.ClusterTypeCapi, + }, + } + successorSummary.Labels = map[string]string{ + clusterops.ClusterProfileLabelName: successor.Name, + configv1beta1.ClusterNameLabel: clusterName, + configv1beta1.ClusterTypeLabel: string(libsveltosv1beta1.ClusterTypeCapi), + } + + reconciler := newReconciler(predecessorSummary, predecessor, cluster, successor, successorSummary) + provisioned, _, err := controllers.AreSuccessorsProvisioned(reconciler, context.TODO(), newScope(reconciler), + textlogger.NewLogger(textlogger.NewConfig())) + Expect(err).To(BeNil()) + Expect(provisioned).To(BeTrue()) + }) + + It("areSuccessorsProvisioned waits for every matching successor when more than one names the predecessor (fan-in)", func() { + matchingSelector := libsveltosv1beta1.Selector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{testDCLabelKey: testEngValue}, + }, + } + readySuccessor := &configv1beta1.ClusterProfile{ + ObjectMeta: metav1.ObjectMeta{Name: clusterProfileNamePrefix + randomString()}, + Spec: configv1beta1.Spec{TransitionFrom: []string{predecessor.Name}, ClusterSelector: matchingSelector}, + } + notReadySuccessor := &configv1beta1.ClusterProfile{ + ObjectMeta: metav1.ObjectMeta{Name: clusterProfileNamePrefix + randomString()}, + Spec: configv1beta1.Spec{TransitionFrom: []string{predecessor.Name}, ClusterSelector: matchingSelector}, + } + + readySummaryName := clusterops.GetClusterSummaryName(configv1beta1.ClusterProfileKind, readySuccessor.Name, clusterName, false) + readySummary := &configv1beta1.ClusterSummary{ + ObjectMeta: metav1.ObjectMeta{Name: readySummaryName, Namespace: namespace}, + Spec: configv1beta1.ClusterSummarySpec{ + ClusterNamespace: cluster.Namespace, ClusterName: cluster.Name, ClusterType: libsveltosv1beta1.ClusterTypeCapi, + }, + } + readySummary.Labels = map[string]string{ + clusterops.ClusterProfileLabelName: readySuccessor.Name, + configv1beta1.ClusterNameLabel: clusterName, + configv1beta1.ClusterTypeLabel: string(libsveltosv1beta1.ClusterTypeCapi), + } + + // notReadySuccessor matches too, but never created a ClusterSummary: fan-in must wait for it too. + reconciler := newReconciler(predecessorSummary, predecessor, cluster, readySuccessor, notReadySuccessor, readySummary) + provisioned, msg, err := controllers.AreSuccessorsProvisioned(reconciler, context.TODO(), newScope(reconciler), + textlogger.NewLogger(textlogger.NewConfig())) + Expect(err).To(BeNil()) + Expect(provisioned).To(BeFalse()) + Expect(msg).To(ContainSubstring(notReadySuccessor.Name)) + }) +}) + +var _ = Describe("isChartTransitioningFrom", func() { + ownedBy := func(kind, name string) []metav1.OwnerReference { + return []metav1.OwnerReference{ + {APIVersion: configv1beta1.GroupVersion.String(), Kind: kind, Name: name}, + } + } + + It("returns true when the claiming profile's TransitionFrom names the current owner (same kind)", func() { + predecessorName := randomString() + current := &configv1beta1.ClusterSummary{ + ObjectMeta: metav1.ObjectMeta{OwnerReferences: ownedBy(configv1beta1.ClusterProfileKind, predecessorName)}, + } + claiming := &configv1beta1.ClusterSummary{ + ObjectMeta: metav1.ObjectMeta{OwnerReferences: ownedBy(configv1beta1.ClusterProfileKind, randomString())}, + Spec: configv1beta1.ClusterSummarySpec{ + ClusterProfileSpec: configv1beta1.Spec{TransitionFrom: []string{predecessorName}}, + }, + } + + Expect(controllers.IsChartTransitioningFrom(current, claiming)).To(BeTrue()) + }) + + It("returns false when TransitionFrom is unset", func() { + predecessorName := randomString() + current := &configv1beta1.ClusterSummary{ + ObjectMeta: metav1.ObjectMeta{OwnerReferences: ownedBy(configv1beta1.ClusterProfileKind, predecessorName)}, + } + claiming := &configv1beta1.ClusterSummary{ + ObjectMeta: metav1.ObjectMeta{OwnerReferences: ownedBy(configv1beta1.ClusterProfileKind, randomString())}, + } + + Expect(controllers.IsChartTransitioningFrom(current, claiming)).To(BeFalse()) + }) + + It("returns false when TransitionFrom names the owner but the owner kinds differ", func() { + // TransitionFrom is same-kind only: a ClusterProfile named by a Profile's TransitionFrom + // (or vice versa) must never grant a takeover, even on a name match. + predecessorName := randomString() + current := &configv1beta1.ClusterSummary{ + ObjectMeta: metav1.ObjectMeta{OwnerReferences: ownedBy(configv1beta1.ProfileKind, predecessorName)}, + } + claiming := &configv1beta1.ClusterSummary{ + ObjectMeta: metav1.ObjectMeta{OwnerReferences: ownedBy(configv1beta1.ClusterProfileKind, randomString())}, + Spec: configv1beta1.ClusterSummarySpec{ + ClusterProfileSpec: configv1beta1.Spec{TransitionFrom: []string{predecessorName}}, + }, + } + + Expect(controllers.IsChartTransitioningFrom(current, claiming)).To(BeFalse()) + }) + + It("returns false when TransitionFrom names an unrelated profile", func() { + current := &configv1beta1.ClusterSummary{ + ObjectMeta: metav1.ObjectMeta{OwnerReferences: ownedBy(configv1beta1.ClusterProfileKind, randomString())}, + } + claiming := &configv1beta1.ClusterSummary{ + ObjectMeta: metav1.ObjectMeta{OwnerReferences: ownedBy(configv1beta1.ClusterProfileKind, randomString())}, + Spec: configv1beta1.ClusterSummarySpec{ + ClusterProfileSpec: configv1beta1.Spec{TransitionFrom: []string{randomString()}}, + }, + } + + Expect(controllers.IsChartTransitioningFrom(current, claiming)).To(BeFalse()) + }) +}) diff --git a/controllers/export_test.go b/controllers/export_test.go index f7102485..d975be9c 100644 --- a/controllers/export_test.go +++ b/controllers/export_test.go @@ -72,6 +72,9 @@ var ( ReconcileDelete = (*ClusterSummaryReconciler).reconcileDelete AreDependenciesDeployed = (*ClusterSummaryReconciler).areDependenciesDeployed AreDependentsRemoved = (*ClusterSummaryReconciler).areDependentsRemoved + AreSuccessorsProvisioned = (*ClusterSummaryReconciler).areSuccessorsProvisioned + GetTransitionSuccessors = (*ClusterSummaryReconciler).getTransitionSuccessors + IsChartTransitioningFrom = isChartTransitioningFrom SetFailureMessage = (*ClusterSummaryReconciler).setFailureMessage ResetFeatureStatus = (*ClusterSummaryReconciler).resetFeatureStatus PrepareForDeployment = (*ClusterSummaryReconciler).prepareForDeployment diff --git a/controllers/handlers_helm.go b/controllers/handlers_helm.go index c61ae5d1..f61ddb93 100644 --- a/controllers/handlers_helm.go +++ b/controllers/handlers_helm.go @@ -1566,7 +1566,9 @@ func determineChartOwnership(ctx context.Context, c client.Client, claimingHelmM return false, err } - if deployer.HasHigherOwnershipPriority(currentHelmManager.Spec.ClusterProfileSpec.Tier, claimingHelmManager.Spec.ClusterProfileSpec.Tier) { + if deployer.HasHigherOwnershipPriority(currentHelmManager.Spec.ClusterProfileSpec.Tier, claimingHelmManager.Spec.ClusterProfileSpec.Tier) || + isChartTransitioningFrom(currentHelmManager, claimingHelmManager) { + if claimingHelmManager.Spec.ClusterProfileSpec.SyncMode == configv1beta1.SyncModeDryRun { return true, nil } diff --git a/controllers/handlers_utils.go b/controllers/handlers_utils.go index 93132c73..cef4032c 100644 --- a/controllers/handlers_utils.go +++ b/controllers/handlers_utils.go @@ -486,6 +486,7 @@ func deployUnstructured(ctx context.Context, deployingToMgmtCluster bool, destCo profile, profileTier, referenceTier, + clusterSummary.Spec.ClusterProfileSpec.TransitionFrom, logger, ) if err != nil { @@ -1725,6 +1726,7 @@ func prepareSetters(ctx context.Context, clusterSummary *configv1beta1.ClusterSu gvks := tranformGroupVersionKindToString(deployedGVKs) setters = append(setters, pullmode.WithTier(clusterSummary.Spec.ClusterProfileSpec.Tier), + pullmode.WithTransitionFrom(clusterSummary.Spec.ClusterProfileSpec.TransitionFrom), pullmode.WithContinueOnConflict(clusterSummary.Spec.ClusterProfileSpec.ContinueOnConflict), pullmode.WithContinueOnError(clusterSummary.Spec.ClusterProfileSpec.ContinueOnError), pullmode.WithDeployedGVKs(gvks)) diff --git a/controllers/profile_hash_test.go b/controllers/profile_hash_test.go index e938d739..c8642795 100644 --- a/controllers/profile_hash_test.go +++ b/controllers/profile_hash_test.go @@ -63,6 +63,7 @@ var _ = Describe("getProfileSpecHash slice-field coverage", func() { "PatchesFrom": true, "DriftExclusions": true, "DependsOn": true, + "TransitionFrom": true, } // Slice fields whose element order is author-determined and therefore // does not need normalisation to produce a stable hash. diff --git a/controllers/profile_utils.go b/controllers/profile_utils.go index 5cb6593b..1476d44a 100644 --- a/controllers/profile_utils.go +++ b/controllers/profile_utils.go @@ -1006,6 +1006,9 @@ func getProfileSpecHash(profileScope *scope.ProfileScope) []byte { specCopy.DependsOn = make([]string, len(specCopy.DependsOn)) copy(specCopy.DependsOn, profileScope.GetSpec().DependsOn) sort.Strings(specCopy.DependsOn) + specCopy.TransitionFrom = make([]string, len(specCopy.TransitionFrom)) + copy(specCopy.TransitionFrom, profileScope.GetSpec().TransitionFrom) + sort.Strings(specCopy.TransitionFrom) if data, err := json.Marshal(specCopy); err == nil { h.Write(data) diff --git a/go.mod b/go.mod index 416fac36..d057d29c 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/onsi/gomega v1.43.0 github.com/opencontainers/image-spec v1.1.1 github.com/pkg/errors v0.9.1 - github.com/projectsveltos/libsveltos v1.14.1-0.20260907061605-a1285524bdc0 + github.com/projectsveltos/libsveltos v1.14.1-0.20260909063818-b0a031992dbc github.com/prometheus/client_golang v1.24.1 github.com/sigstore/cosign/v3 v3.1.3 github.com/sigstore/sigstore v1.10.9 diff --git a/go.sum b/go.sum index 883da7a0..95274b1b 100644 --- a/go.sum +++ b/go.sum @@ -641,8 +641,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= -github.com/projectsveltos/libsveltos v1.14.1-0.20260907061605-a1285524bdc0 h1:P6V3CLijEPQfiCqM684wnzMvnCHBE8Wy8r4ciWmDzwE= -github.com/projectsveltos/libsveltos v1.14.1-0.20260907061605-a1285524bdc0/go.mod h1:U6iGj5KoC/PcTD2vh3XU6gy7g11suThT6sZSEpmLEkU= +github.com/projectsveltos/libsveltos v1.14.1-0.20260909063818-b0a031992dbc h1:yDckm+M/oppO2YOqdooOLN5md+esuJovkrxH8KHojmE= +github.com/projectsveltos/libsveltos v1.14.1-0.20260909063818-b0a031992dbc/go.mod h1:U6iGj5KoC/PcTD2vh3XU6gy7g11suThT6sZSEpmLEkU= 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= diff --git a/lib/crd/clusterprofiles.go b/lib/crd/clusterprofiles.go index c1029950..197e47c4 100644 --- a/lib/crd/clusterprofiles.go +++ b/lib/crd/clusterprofiles.go @@ -2115,6 +2115,18 @@ spec: format: int32 minimum: 1 type: integer + transitionFrom: + description: |- + TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + For matching target clusters, teardown of the replaced profiles is deferred + until this instance reaches Provisioned. This instance is also permitted to + adopt resources owned by the replaced profiles, ignoring tier restrictions. + Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + other Profiles in the same namespace. + items: + type: string + type: array + x-kubernetes-list-type: atomic validateHealths: description: |- ValidateHealths is a slice of checks to run against the managed cluster diff --git a/lib/crd/clusterpromotions.go b/lib/crd/clusterpromotions.go index 0ff4021a..d95a6624 100644 --- a/lib/crd/clusterpromotions.go +++ b/lib/crd/clusterpromotions.go @@ -2012,6 +2012,18 @@ spec: format: int32 minimum: 1 type: integer + transitionFrom: + description: |- + TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + For matching target clusters, teardown of the replaced profiles is deferred + until this instance reaches Provisioned. This instance is also permitted to + adopt resources owned by the replaced profiles, ignoring tier restrictions. + Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + other Profiles in the same namespace. + items: + type: string + type: array + x-kubernetes-list-type: atomic validateHealths: description: |- ValidateHealths is a slice of checks to run against the managed cluster diff --git a/lib/crd/clustersummaries.go b/lib/crd/clustersummaries.go index 7613cfe5..54d57b0a 100644 --- a/lib/crd/clustersummaries.go +++ b/lib/crd/clustersummaries.go @@ -2157,6 +2157,18 @@ spec: format: int32 minimum: 1 type: integer + transitionFrom: + description: |- + TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + For matching target clusters, teardown of the replaced profiles is deferred + until this instance reaches Provisioned. This instance is also permitted to + adopt resources owned by the replaced profiles, ignoring tier restrictions. + Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + other Profiles in the same namespace. + items: + type: string + type: array + x-kubernetes-list-type: atomic validateHealths: description: |- ValidateHealths is a slice of checks to run against the managed cluster diff --git a/lib/crd/profiles.go b/lib/crd/profiles.go index 26740c36..c25ebcbb 100644 --- a/lib/crd/profiles.go +++ b/lib/crd/profiles.go @@ -2115,6 +2115,18 @@ spec: format: int32 minimum: 1 type: integer + transitionFrom: + description: |- + TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + For matching target clusters, teardown of the replaced profiles is deferred + until this instance reaches Provisioned. This instance is also permitted to + adopt resources owned by the replaced profiles, ignoring tier restrictions. + Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + other Profiles in the same namespace. + items: + type: string + type: array + x-kubernetes-list-type: atomic validateHealths: description: |- ValidateHealths is a slice of checks to run against the managed cluster diff --git a/manifest/manifest.yaml b/manifest/manifest.yaml index 00a4d69e..667b92f4 100644 --- a/manifest/manifest.yaml +++ b/manifest/manifest.yaml @@ -2405,6 +2405,18 @@ spec: format: int32 minimum: 1 type: integer + transitionFrom: + description: |- + TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + For matching target clusters, teardown of the replaced profiles is deferred + until this instance reaches Provisioned. This instance is also permitted to + adopt resources owned by the replaced profiles, ignoring tier restrictions. + Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + other Profiles in the same namespace. + items: + type: string + type: array + x-kubernetes-list-type: atomic validateHealths: description: |- ValidateHealths is a slice of checks to run against the managed cluster @@ -4880,6 +4892,18 @@ spec: format: int32 minimum: 1 type: integer + transitionFrom: + description: |- + TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + For matching target clusters, teardown of the replaced profiles is deferred + until this instance reaches Provisioned. This instance is also permitted to + adopt resources owned by the replaced profiles, ignoring tier restrictions. + Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + other Profiles in the same namespace. + items: + type: string + type: array + x-kubernetes-list-type: atomic validateHealths: description: |- ValidateHealths is a slice of checks to run against the managed cluster @@ -8535,6 +8559,18 @@ spec: format: int32 minimum: 1 type: integer + transitionFrom: + description: |- + TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + For matching target clusters, teardown of the replaced profiles is deferred + until this instance reaches Provisioned. This instance is also permitted to + adopt resources owned by the replaced profiles, ignoring tier restrictions. + Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + other Profiles in the same namespace. + items: + type: string + type: array + x-kubernetes-list-type: atomic validateHealths: description: |- ValidateHealths is a slice of checks to run against the managed cluster @@ -11095,6 +11131,18 @@ spec: format: int32 minimum: 1 type: integer + transitionFrom: + description: |- + TransitionFrom names the Profiles or ClusterProfiles this instance replaces. + For matching target clusters, teardown of the replaced profiles is deferred + until this instance reaches Provisioned. This instance is also permitted to + adopt resources owned by the replaced profiles, ignoring tier restrictions. + Same-kind only: a ClusterProfile names other ClusterProfiles, a Profile names + other Profiles in the same namespace. + items: + type: string + type: array + x-kubernetes-list-type: atomic validateHealths: description: |- ValidateHealths is a slice of checks to run against the managed cluster diff --git a/test/fv/helm_handoff_test.go b/test/fv/helm_handoff_test.go index 5d816c20..0a143a08 100644 --- a/test/fv/helm_handoff_test.go +++ b/test/fv/helm_handoff_test.go @@ -56,7 +56,7 @@ var _ = Describe("HelmHandoff", Serial, func() { AfterEach(func() { Byf("Restoring cluster label %s=%s", key, value) - setLabelOnCluster(key, value) + setLabelOnCluster(value) if clusterProfileA != nil { deleteClusterProfile(clusterProfileA) @@ -136,7 +136,7 @@ var _ = Describe("HelmHandoff", Serial, func() { Byf("Changing cluster label from %s=%s to %s=%s to trigger handoff", key, value, key, handoffLabelValue) - setLabelOnCluster(key, handoffLabelValue) + setLabelOnCluster(handoffLabelValue) Byf("Verifying dragonfly-operator deployment is never absent and never recreated during the handoff") Consistently(func() error { diff --git a/test/fv/transition_test.go b/test/fv/transition_test.go new file mode 100644 index 00000000..ee7d9b14 --- /dev/null +++ b/test/fv/transition_test.go @@ -0,0 +1,251 @@ +/* +Copyright 2026. projectsveltos.io. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package fv_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" + + configv1beta1 "github.com/projectsveltos/addon-controller/api/v1beta1" + "github.com/projectsveltos/addon-controller/lib/clusterops" + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" + "github.com/projectsveltos/libsveltos/lib/deployer" +) + +const ( + transitionSharedClusterRole = `apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: %s +rules: +- apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list"]` + + // transitionSharedClusterRoleUpdated is the successor's version of the same ClusterRole + // (same name, via %s) with different rules, so the transition proves the successor can + // actually update the resource it took over, not just leave the predecessor's content in + // place untouched. + transitionSharedClusterRoleUpdated = `apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: %s +rules: +- apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "watch"]` + + transitionExclusiveClusterRole = `apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: %s +rules: +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list"]` + + transitionLabelValue = "fv-transition" +) + +// Transition is Serial because it changes cluster labels, which would affect other tests. +var _ = Describe("Transition", Serial, func() { + const ( + namePrefix = "transition-" + ) + + var ( + predecessor *configv1beta1.ClusterProfile + successor *configv1beta1.ClusterProfile + ) + + AfterEach(func() { + Byf("Restoring cluster label %s=%s", key, value) + setLabelOnCluster(value) + + if successor != nil { + deleteClusterProfile(successor) + successor = nil + } + predecessor = nil + }) + + It("A shared resource is taken over and updated in place, and the predecessor's exclusive resource is still cleaned up", + Label("NEW-FV", "EXTENDED"), func() { + Byf("Create predecessor ClusterProfile matching Cluster %s/%s", + kindWorkloadCluster.GetNamespace(), kindWorkloadCluster.GetName()) + predecessor = getClusterProfile(namePrefix+"predecessor-", map[string]string{key: value}) + predecessor.Spec.SyncMode = configv1beta1.SyncModeContinuous + Expect(k8sClient.Create(context.TODO(), predecessor)).To(Succeed()) + verifyClusterProfileMatches(predecessor) + verifyClusterSummary(clusterops.ClusterProfileLabelName, predecessor.Name, &predecessor.Spec, + kindWorkloadCluster.GetNamespace(), kindWorkloadCluster.GetName(), getClusterType()) + + configMapNs := randomString() + Byf("Create configMap's namespace %s", configMapNs) + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapNs, + }, + } + Expect(k8sClient.Create(context.TODO(), ns)).To(Succeed()) + + Byf("Create the predecessor's ConfigMap for the shared ClusterRole") + sharedClusterRoleName := namePrefix + randomString() + sharedConfigMap := createConfigMapWithPolicy(configMapNs, namePrefix+randomString(), + fmt.Sprintf(transitionSharedClusterRole, sharedClusterRoleName)) + Expect(k8sClient.Create(context.TODO(), sharedConfigMap)).To(Succeed()) + + Byf("Create the predecessor-exclusive resource's ConfigMap, referenced only by the predecessor") + exclusiveClusterRoleName := namePrefix + randomString() + exclusiveConfigMap := createConfigMapWithPolicy(configMapNs, namePrefix+randomString(), + fmt.Sprintf(transitionExclusiveClusterRole, exclusiveClusterRoleName)) + Expect(k8sClient.Create(context.TODO(), exclusiveConfigMap)).To(Succeed()) + + Byf("Update predecessor ClusterProfile %s to reference both ConfigMaps", predecessor.Name) + currentPredecessor := &configv1beta1.ClusterProfile{} + Expect(retry.RetryOnConflict(retry.DefaultRetry, func() error { + Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: predecessor.Name}, currentPredecessor)).To(Succeed()) + currentPredecessor.Spec.PolicyRefs = []configv1beta1.PolicyRef{ + {Kind: string(libsveltosv1beta1.ConfigMapReferencedResourceKind), Namespace: sharedConfigMap.Namespace, Name: sharedConfigMap.Name}, + {Kind: string(libsveltosv1beta1.ConfigMapReferencedResourceKind), Namespace: exclusiveConfigMap.Namespace, Name: exclusiveConfigMap.Name}, + } + return k8sClient.Update(context.TODO(), currentPredecessor) + })).To(Succeed()) + + predecessorSummary := verifyClusterSummary(clusterops.ClusterProfileLabelName, predecessor.Name, ¤tPredecessor.Spec, + kindWorkloadCluster.GetNamespace(), kindWorkloadCluster.GetName(), getClusterType()) + verifyFeatureStatusIsProvisioned(kindWorkloadCluster.GetNamespace(), predecessorSummary.Name, libsveltosv1beta1.FeatureResources) + + Byf("Getting client to access the workload cluster") + workloadClient, err := getKindWorkloadClusterKubeconfig() + Expect(err).To(BeNil()) + Expect(workloadClient).ToNot(BeNil()) + + Byf("Verifying both ClusterRoles are created in the workload cluster") + Eventually(func() error { + cr := &rbacv1.ClusterRole{} + return workloadClient.Get(context.TODO(), types.NamespacedName{Name: sharedClusterRoleName}, cr) + }, timeout, pollingInterval).Should(BeNil()) + Eventually(func() error { + cr := &rbacv1.ClusterRole{} + return workloadClient.Get(context.TODO(), types.NamespacedName{Name: exclusiveClusterRoleName}, cr) + }, timeout, pollingInterval).Should(BeNil()) + + Byf("Storing the shared ClusterRole's UID and creation timestamp") + originalSharedRole := &rbacv1.ClusterRole{} + Expect(workloadClient.Get(context.TODO(), types.NamespacedName{Name: sharedClusterRoleName}, originalSharedRole)).To(Succeed()) + originalUID := originalSharedRole.UID + originalCreationTimestamp := originalSharedRole.CreationTimestamp + + Byf("Create the successor's ConfigMap for the shared ClusterRole %s, with different rules than the predecessor's", + sharedClusterRoleName) + successorSharedConfigMap := createConfigMapWithPolicy(configMapNs, namePrefix+randomString(), + fmt.Sprintf(transitionSharedClusterRoleUpdated, sharedClusterRoleName)) + Expect(k8sClient.Create(context.TODO(), successorSharedConfigMap)).To(Succeed()) + + Byf("Create successor ClusterProfile, TransitionFrom predecessor %s, referencing its own version of the shared ConfigMap", + predecessor.Name) + successor = getClusterProfile(namePrefix+"successor-", map[string]string{key: transitionLabelValue}) + successor.Spec.SyncMode = configv1beta1.SyncModeContinuous + successor.Spec.TransitionFrom = []string{predecessor.Name} + successor.Spec.PolicyRefs = []configv1beta1.PolicyRef{ + {Kind: string(libsveltosv1beta1.ConfigMapReferencedResourceKind), Namespace: successorSharedConfigMap.Namespace, Name: successorSharedConfigMap.Name}, + } + Expect(k8sClient.Create(context.TODO(), successor)).To(Succeed()) + + Byf("Changing cluster label from %s=%s to %s=%s to trigger the transition", + key, value, key, transitionLabelValue) + setLabelOnCluster(transitionLabelValue) + + Byf("Verifying the shared ClusterRole is never absent and never recreated during the transition") + Consistently(func() error { + cr := &rbacv1.ClusterRole{} + if err := workloadClient.Get(context.TODO(), types.NamespacedName{Name: sharedClusterRoleName}, cr); err != nil { + return err + } + if cr.UID != originalUID { + return fmt.Errorf("ClusterRole was recreated: original UID %s, current %s", originalUID, cr.UID) + } + if !cr.CreationTimestamp.Equal(&originalCreationTimestamp) { + return fmt.Errorf("ClusterRole was recreated: original creation timestamp %v, current %v", + originalCreationTimestamp, cr.CreationTimestamp) + } + return nil + }, timeout/2, pollingInterval).Should(BeNil()) + + Byf("Verifying successor ClusterProfile eventually matches and is Provisioned") + verifyClusterProfileMatches(successor) + successorSummary := verifyClusterSummary(clusterops.ClusterProfileLabelName, successor.Name, &successor.Spec, + kindWorkloadCluster.GetNamespace(), kindWorkloadCluster.GetName(), getClusterType()) + verifyFeatureStatusIsProvisioned(kindWorkloadCluster.GetNamespace(), successorSummary.Name, libsveltosv1beta1.FeatureResources) + + Byf("Verifying predecessor's ClusterSummary %s is eventually gone, now that the successor took over", + predecessorSummary.Name) + Eventually(func() bool { + currentClusterSummary := &configv1beta1.ClusterSummary{} + err := k8sClient.Get(context.TODO(), + types.NamespacedName{Namespace: predecessorSummary.Namespace, Name: predecessorSummary.Name}, + currentClusterSummary) + return apierrors.IsNotFound(err) + }, timeout, pollingInterval).Should(BeTrue()) + + Byf("Verifying the predecessor-exclusive ClusterRole %s is removed once the predecessor's teardown completes", + exclusiveClusterRoleName) + Eventually(func() bool { + cr := &rbacv1.ClusterRole{} + err := workloadClient.Get(context.TODO(), types.NamespacedName{Name: exclusiveClusterRoleName}, cr) + return apierrors.IsNotFound(err) + }, timeout, pollingInterval).Should(BeTrue()) + + Byf("Verifying the shared ClusterRole %s is still present, now owned by the successor %s", + sharedClusterRoleName, successor.Name) + Eventually(func() bool { + cr := &rbacv1.ClusterRole{} + if err := workloadClient.Get(context.TODO(), types.NamespacedName{Name: sharedClusterRoleName}, cr); err != nil { + return false + } + return cr.UID == originalUID && cr.Annotations[deployer.OwnerName] == successor.Name + }, timeout, pollingInterval).Should(BeTrue()) + + Byf("Verifying the successor actually updated the shared ClusterRole %s to its own content, not just left it alone", + sharedClusterRoleName) + Eventually(func() bool { + cr := &rbacv1.ClusterRole{} + if err := workloadClient.Get(context.TODO(), types.NamespacedName{Name: sharedClusterRoleName}, cr); err != nil { + return false + } + for i := range cr.Rules { + for j := range cr.Rules[i].Verbs { + if cr.Rules[i].Verbs[j] == "watch" { + return true + } + } + } + return false + }, timeout, pollingInterval).Should(BeTrue()) + }) +}) diff --git a/test/fv/utils_test.go b/test/fv/utils_test.go index caeb97a7..74ef370f 100644 --- a/test/fv/utils_test.go +++ b/test/fv/utils_test.go @@ -683,7 +683,7 @@ func setAnnotationOnCluster(key, value string) { Expect(err).To(BeNil()) } -func setLabelOnCluster(labelKey, labelValue string) { +func setLabelOnCluster(labelValue string) { var currentCluster client.Object if kindWorkloadCluster.GetKind() == libsveltosv1beta1.SveltosClusterKind { @@ -706,7 +706,7 @@ func setLabelOnCluster(labelKey, labelValue string) { if updatedLabels == nil { updatedLabels = map[string]string{} } - updatedLabels[labelKey] = labelValue + updatedLabels[key] = labelValue currentCluster.SetLabels(updatedLabels) return k8sClient.Update(context.TODO(), currentCluster) })