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) })