@@ -20,6 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
2020package instanceset
2121
2222import (
23+ "context"
2324 "slices"
2425 "time"
2526
@@ -39,6 +40,7 @@ import (
3940 "github.com/apecloud/kubeblocks/pkg/constant"
4041 "github.com/apecloud/kubeblocks/pkg/controller/builder"
4142 "github.com/apecloud/kubeblocks/pkg/controller/kubebuilderx"
43+ "github.com/apecloud/kubeblocks/pkg/controller/lifecycle"
4244 intctrlutil "github.com/apecloud/kubeblocks/pkg/controllerutil"
4345 viper "github.com/apecloud/kubeblocks/pkg/viperx"
4446)
@@ -410,5 +412,124 @@ var _ = Describe("update reconciler test", func() {
410412 It ("inplace updates pod resource using resize subresource" , func () {
411413 testInplacePodVerticalScaling (true )
412414 })
415+
416+ It ("patches pod without calling switchover for metadata-only in-place updates" , func () {
417+ // This test exercises the Reconcile call site (not just the
418+ // safeMetadataOnlyInPlaceUpdate helper) to assert the contract
419+ // from PR #10252: when the only difference between the existing
420+ // pod and the rebuilt pod is non-restart metadata, the pod is
421+ // patched but the switchover lifecycle action must not be
422+ // invoked.
423+ origSupportResize := intctrlutil .SupportResizeSubResource
424+ intctrlutil .SupportResizeSubResource = func () (bool , error ) { return false , nil }
425+ defer func () { intctrlutil .SupportResizeSubResource = origSupportResize }()
426+
427+ spy := & lifecycleCallSpy {}
428+ origNewLifecycleAction := newLifecycleAction
429+ newLifecycleAction = func (_ string , _ string , _ string , _ * kbappsv1.ComponentLifecycleActions , _ map [string ]any , _ * corev1.Pod , _ ... * corev1.Pod ) (lifecycle.Lifecycle , error ) {
430+ return spy , nil
431+ }
432+ defer func () { newLifecycleAction = origNewLifecycleAction }()
433+
434+ tree := kubebuilderx .NewObjectTree ()
435+ its .Spec .PodManagementPolicy = appsv1 .ParallelPodManagement
436+ its .Spec .Replicas = ptr.To [int32 ](1 )
437+ its .Spec .PodUpdatePolicy = kbappsv1 .PreferInPlacePodUpdatePolicyType
438+ // Configure a switchover action so r.switchover would invoke
439+ // newLifecycleAction (and thus the spy) if the gate did not
440+ // suppress it.
441+ its .Spec .MembershipReconfiguration = & workloads.MembershipReconfiguration {
442+ Switchover : & kbappsv1.Action {
443+ Exec : & kbappsv1.ExecAction {Command : []string {"true" }},
444+ },
445+ }
446+ tree .SetRoot (its )
447+
448+ // Run the revision pipeline against the original template so the
449+ // generated pod's controller-revision label matches
450+ // its.Status.UpdateRevisions. This avoids the revision-mismatch
451+ // branch in getPodUpdatePolicy that would force recreatePolicy.
452+ prepareForUpdate (tree )
453+
454+ pods := tree .List (& corev1.Pod {})
455+ Expect (pods ).Should (HaveLen (1 ))
456+ pod := pods [0 ].(* corev1.Pod )
457+ pod .Status .Phase = corev1 .PodRunning
458+ pod .Status .Conditions = append (pod .Status .Conditions , corev1.PodCondition {
459+ Type : corev1 .PodReady ,
460+ Status : corev1 .ConditionTrue ,
461+ LastTransitionTime : metav1 .NewTime (time .Now ().Add (- 1 * minReadySeconds * time .Second )),
462+ })
463+
464+ // Mutate the template AFTER prepareForUpdate so the rebuilt pod
465+ // differs from the existing pod only in the config-hash
466+ // annotation. its.Status.UpdateRevisions is no longer
467+ // recomputed, so the pod's revision still matches and the
468+ // reconciler enters the in-place branch via basicUpdate=true.
469+ if its .Spec .Template .ObjectMeta .Annotations == nil {
470+ its .Spec .Template .ObjectMeta .Annotations = map [string ]string {}
471+ }
472+ its .Spec .Template .ObjectMeta .Annotations [constant .CMInsConfigurationHashLabelKey ] = "new-hash"
473+
474+ reconciler = NewUpdateReconciler ()
475+ res , err := reconciler .Reconcile (tree )
476+ Expect (err ).Should (BeNil ())
477+ Expect (res ).Should (Equal (kubebuilderx .Continue ))
478+
479+ postPods := tree .List (& corev1.Pod {})
480+ Expect (postPods ).Should (HaveLen (1 ),
481+ "pod should be in-place updated, not deleted/recreated" )
482+ updatedPod := postPods [0 ].(* corev1.Pod )
483+ Expect (updatedPod .Annotations ).Should (HaveKeyWithValue (constant .CMInsConfigurationHashLabelKey , "new-hash" ),
484+ "pod should be patched with the new config-hash annotation even though switchover is skipped" )
485+ Expect (spy .switchoverCalls ).Should (Equal (0 ),
486+ "switchover must not be invoked when only the config-hash annotation differs" )
487+ })
413488 })
414489})
490+
491+ // lifecycleCallSpy is a test double for lifecycle.Lifecycle used to assert
492+ // that switchover is or is not invoked during reconciliation. Methods that
493+ // the call-site tests do not exercise return nil to satisfy the interface.
494+ type lifecycleCallSpy struct {
495+ switchoverCalls int
496+ reconfigureCalls int
497+ }
498+
499+ func (s * lifecycleCallSpy ) PostProvision (_ context.Context , _ client.Reader , _ * lifecycle.Options ) error {
500+ return nil
501+ }
502+
503+ func (s * lifecycleCallSpy ) PreTerminate (_ context.Context , _ client.Reader , _ * lifecycle.Options ) error {
504+ return nil
505+ }
506+
507+ func (s * lifecycleCallSpy ) RoleProbe (_ context.Context , _ client.Reader , _ * lifecycle.Options ) ([]byte , error ) {
508+ return nil , nil
509+ }
510+
511+ func (s * lifecycleCallSpy ) Switchover (_ context.Context , _ client.Reader , _ * lifecycle.Options , _ string ) error {
512+ s .switchoverCalls ++
513+ return nil
514+ }
515+
516+ func (s * lifecycleCallSpy ) MemberJoin (_ context.Context , _ client.Reader , _ * lifecycle.Options ) error {
517+ return nil
518+ }
519+
520+ func (s * lifecycleCallSpy ) MemberLeave (_ context.Context , _ client.Reader , _ * lifecycle.Options ) error {
521+ return nil
522+ }
523+
524+ func (s * lifecycleCallSpy ) Reconfigure (_ context.Context , _ client.Reader , _ * lifecycle.Options , _ map [string ]string ) error {
525+ s .reconfigureCalls ++
526+ return nil
527+ }
528+
529+ func (s * lifecycleCallSpy ) AccountProvision (_ context.Context , _ client.Reader , _ * lifecycle.Options , _ , _ , _ string ) error {
530+ return nil
531+ }
532+
533+ func (s * lifecycleCallSpy ) UserDefined (_ context.Context , _ client.Reader , _ * lifecycle.Options , _ string , _ * kbappsv1.Action , _ map [string ]string ) error {
534+ return nil
535+ }
0 commit comments