Repository: cloudnative-pg/cloudnative-pg

Issue:
[Bug]: kubectl plugin command `promote` is not working
### Is there an existing issue already for this bug?

- [x] I have searched for an existing issue, and could not find anything. I believe this is a new bug.

### I have read the troubleshooting guide

- [x] I have read the troubleshooting guide and I think this is a new bug.

### I am running a supported version of CloudNativePG

- [x] I have read the troubleshooting guide and I think this is a new bug.

### Contact Details

jaime.silvela@enterprisedb.com

### Version

1.25 (latest patch)

### What version of Kubernetes are you using?

1.32

### What is your Kubernetes environment?

Self-managed: kind (evaluation)

### How did you install the operator?

YAML manifest

### What happened?

After creating a simple cluster (cluster-example.yaml ) and waiting for it to be healthy,
and with the newly-compiled kubectl plugin, I run

``` sh
kubectl cnpg promote cluster-example cluster-example-2
```

Right away I get two lines in response

``` sh
{"level":"info","ts":"2024-12-30T15:55:38.989549+01:00","msg":"Cluster is not healthy"}
Node cluster-example-2 in cluster cluster-example will be promoted
```

However nothing happens, and I notice that the TargetPrimary in the cluster status is unchanged.

### Cluster resource

```shell

```

### Relevant log output

```shell
Command output:

{"level":"info","ts":"2024-12-30T15:55:38.989549+01:00","msg":"Cluster is not healthy"}
Node cluster-example-2 in cluster cluster-example will be promoted

Operator logs:

{"level":"debug","ts":"2024-12-30T13:56:47.576467088Z","msg":"Reconciliation loop start","controller":"cluster","controllerGroup":"postgresql.cnpg.io","controllerKind":"Cluster","Cluster":{"name":"cluster-example","namespace":"default"},"namespace":"default","name":"cluster-example","reconcileID":"079abde6-16ba-4fff-a147-a3eb581db71f","caller":"internal/controller/cluster_controller.go:145"}
{"level":"info","ts":"2024-12-30T13:56:47.694710129Z","msg":"Cluster is healthy","controller":"cluster","controllerGroup":"postgresql.cnpg.io","controllerKind":"Cluster","Cluster":{"name":"cluster-example","namespace":"default"},"namespace":"default","name":"cluster-example","reconcileID":"079abde6-16ba-4fff-a147-a3eb581db71f"}
{"level":"debug","ts":"2024-12-30T13:56:47.694881921Z","msg":"Reconciliation loop end","controller":"cluster","controllerGroup":"postgresql.cnpg.io","controllerKind":"Cluster","Cluster":{"name":"cluster-example","namespace":"default"},"namespace":"default","name":"cluster-example","reconcileID":"079abde6-16ba-4fff-a147-a3eb581db71f","caller":"internal/controller/cluster_controller.go:147"}
```

### Code of Conduct

- [x] I agree to follow this project's Code of Conduct

Discussion:
@JordanP: Could you please tag a release with this fix please ? Or is there any workaround in the mean time ?@facchettos: Hey, sorry to pile up on this, but has there been any progress with the release of the fix?@sxd: @facchettos as you can see it was merged and will be on the next release =)@jurim76: I guess the fix should be available ASAP, not with the next release, because it breaks the core functionality@0x366: hello guys, any plans to release the fix? Rn its not possible to do the restart of primary, without downtime @jsilvela: Yes, the release is coming soon. In the community meeting (which happened yesterday) we explained the release has been delayed due to the release of Postgres 17.4.
Just a reminder that we have public and open community meetings.
https://github.com/cloudnative-pg#cloudnativepg-community-meetings@0x366: got it, thanks.

BTW is slack no more active (the link in readme is broken)?@jsilvela: ??? There are two links to the slack in that page, and both work for me.@jakubhajek: Could you please let us know when we can expect a release with that fix?  Thank you, @sxd: Next week@0x366: ```
root@kub-server-1 ~ # k cnpg status holders-db -n holders-db
Cluster Summary
Name                 holders-db/holders-db
System ID:           7476883937198448674
PostgreSQL Image:    ghcr.io/clevyr/cloudnativepg-timescale:17.2-34-ts2.18.1
Primary instance:    holders-db-1
Primary start time:  2025-03-01 16:48:29 +0000 UTC (uptime 15h24m59s)
Status:              Cluster in healthy state 
Instances:           3
Ready instances:     3
Size:                3.8G
Current Write LSN:   1/3DF25078 (Timeline: 1 - WAL File: 00000001000000010000003D)

Continuous Backup status
First Point of Recoverability:  Not Available
Working WAL archiving:          OK
WALs waiting to be archived:    7
Last Archived WAL:              000000010000000100000035   @   2025-03-02T08:13:22.620069Z
Last Failed WAL:                -

Streaming Replication status
Replication Slots Enabled
Name          Sent LSN    Write LSN   Flush LSN   Replay LSN  Write Lag        Flush Lag        Replay Lag       State      Sync State  Sync Priority  Replication Slot
----          --------    ---------   ---------   ----------  ---------        ---------        ----------       -----      ----------  -------------  ----------------
holders-db-2  1/3DF25078  1/3DF25078  1/3DF25078  1/3DF25078  00:00:00.003243  00:00:00.003322  00:00:00.003971  streaming  async       0              active
holders-db-3  1/3DF25078  1/3DF25078  1/3DF25078  1/3DF25078  00:00:00.003236  00:00:00.003266  00:00:00.003981  streaming  async       0              active

Instances status
Name          Current LSN  Replication role  Status  QoS         Manager Version  Node
----          -----------  ----------------  ------  ---         ---------------  ----
holders-db-1  1/3DF25078   Primary           OK      BestEffort  1.25.1           holders-db-1
holders-db-2  1/3DF25078   Standby (async)   OK      BestEffort  1.25.1           holders-db-2
holders-db-3  1/3DF25078   Standby (async)   OK      BestEffort  1.25.1           holders-db-3

root@kub-server-1 ~ # k cnpg promote holders-db  holders-db-3 -n holders-db
{"level":"info","ts":"2025-03-02T08:13:30.006560194Z","msg":"Cluster is not healthy"}
Node holders-db-3 in cluster holders-db will be promoted
```

after upgrading to 1.25.1 - it seems promote is still not working @jurim76: @0x366 did you run `kubectl krew update cnpg`? Works for me.@0x366: @jurim76 hm, I just applied 1.25.1 manifest, ig it s the same. On the status of cluster at least it shows that every manger version is 1.25.1

UPDATE: sorry my bad, `kubectl cnpg version` still returns 1.25.0
UPDATE2: all works now 

Solution patch:
diff --git a/internal/cmd/plugin/promote/cmd.go b/internal/cmd/plugin/promote/cmd.go
index 111c4291..c8e0db5b 100644
--- a/internal/cmd/plugin/promote/cmd.go
+++ b/internal/cmd/plugin/promote/cmd.go
@@ -40,7 +40,7 @@ func NewCmd() *cobra.Command {
 			if _, err := strconv.Atoi(args[1]); err == nil {
 				node = fmt.Sprintf("%s-%s", clusterName, node)
 			}
-			return Promote(ctx, clusterName, node)
+			return Promote(ctx, plugin.Client, plugin.Namespace, clusterName, node)
 		},
 	}
 
diff --git a/internal/cmd/plugin/promote/promote.go b/internal/cmd/plugin/promote/promote.go
index daba43bf..a2e160cb 100644
--- a/internal/cmd/plugin/promote/promote.go
+++ b/internal/cmd/plugin/promote/promote.go
@@ -26,18 +26,19 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
-	"github.com/cloudnative-pg/cloudnative-pg/internal/cmd/plugin"
 	"github.com/cloudnative-pg/cloudnative-pg/pkg/resources/status"
 )
 
-// Promote command implementation
-func Promote(ctx context.Context, clusterName string, serverName string) error {
+// Promote promotes an instance in a cluster
+func Promote(ctx context.Context, cli client.Client,
+	namespace, clusterName, serverName string,
+) error {
 	var cluster apiv1.Cluster
 
 	// Get the Cluster object
-	err := plugin.Client.Get(ctx, client.ObjectKey{Namespace: plugin.Namespace, Name: clusterName}, &cluster)
+	err := cli.Get(ctx, client.ObjectKey{Namespace: namespace, Name: clusterName}, &cluster)
 	if err != nil {
-		return fmt.Errorf("cluster %s not found in namespace %s: %w", clusterName, plugin.Namespace, err)
+		return fmt.Errorf("cluster %s not found in namespace %s: %w", clusterName, namespace, err)
 	}
 
 	// If server name is equal to target primary, there is no need to promote
@@ -49,22 +50,21 @@ func Promote(ctx context.Context, clusterName string, serverName string) error {
 
 	// Check if the Pod exist
 	var pod v1.Pod
-	err = plugin.Client.Get(ctx, client.ObjectKey{Namespace: plugin.Namespace, Name: serverName}, &pod)
+	err = cli.Get(ctx, client.ObjectKey{Namespace: namespace, Name: serverName}, &pod)
 	if err != nil {
-		return fmt.Errorf("new primary node %s not found in namespace %s: %w", serverName, plugin.Namespace, err)
+		return fmt.Errorf("new primary node %s not found in namespace %s: %w", serverName, namespace, err)
 	}
 
-	// The Pod exists, let's update status fields
-	origCluster := cluster.DeepCopy()
-	cluster.Status.TargetPrimary = serverName
-	cluster.Status.TargetPrimaryTimestamp = pgTime.GetCurrentTimestamp()
-	if err := status.RegisterPhaseWithOrigCluster(
-		ctx,
-		plugin.Client,
-		&cluster,
-		origCluster,
-		apiv1.PhaseSwitchover,
-		fmt.Sprintf("Switching over to %v", serverName),
+	// The Pod exists, let's update the cluster's status with the new target primary
+	reconcileTargetPrimaryFunc := func(cluster *apiv1.Cluster) {
+		cluster.Status.TargetPrimary = serverName
+		cluster.Status.TargetPrimaryTimestamp = pgTime.GetCurrentTimestamp()
+		cluster.Status.Phase = apiv1.PhaseSwitchover
+		cluster.Status.PhaseReason = fmt.Sprintf("Switching over to %v", serverName)
+	}
+	if err := status.PatchWithOptimisticLock(ctx, cli, &cluster,
+		reconcileTargetPrimaryFunc,
+		status.SetClusterReadyConditionTX,
 	); err != nil {
 		return err
 	}
diff --git a/internal/cmd/plugin/restart/restart.go b/internal/cmd/plugin/restart/restart.go
index ba248454..4b863e42 100644
--- a/internal/cmd/plugin/restart/restart.go
+++ b/internal/cmd/plugin/restart/restart.go
@@ -66,17 +66,14 @@ func instanceRestart(ctx context.Context, clusterName, node string) error {
 	if err != nil {
 		return err
 	}
-	originalCluster := cluster.DeepCopy()
 
 	if cluster.Status.CurrentPrimary == node {
-		cluster.ManagedFields = nil
-		if err := status.RegisterPhaseWithOrigCluster(
+		if err := status.PatchWithOptimisticLock(
 			ctx,
 			plugin.Client,
 			&cluster,
-			originalCluster,
-			apiv1.PhaseInplacePrimaryRestart,
-			"Requested by the user",
+			status.SetPhaseTX(apiv1.PhaseInplacePrimaryRestart, "Requested by the user"),
+			status.SetClusterReadyConditionTX,
 		); err != nil {
 			return fmt.Errorf("while requesting restart on primary POD for cluster %v: %w", clusterName, err)
 		}
diff --git a/internal/controller/cluster_status.go b/internal/controller/cluster_status.go
index 4f5a2e67..a092f955 100644
--- a/internal/controller/cluster_status.go
+++ b/internal/controller/cluster_status.go
@@ -732,7 +732,13 @@ func (r *ClusterReconciler) RegisterPhase(ctx context.Context,
 	phase string,
 	reason string,
 ) error {
-	return status.RegisterPhase(ctx, r.Client, cluster, phase, reason)
+	return status.PatchWithOptimisticLock(
+		ctx,
+		r.Client,
+		cluster,
+		status.SetPhaseTX(phase, reason),
+		status.SetClusterReadyConditionTX,
+	)
 }
 
 // updateClusterStatusThatRequiresInstancesState updates all the cluster status fields that require the instances status
diff --git a/internal/management/controller/instance_controller.go b/internal/management/controller/instance_controller.go
index dda32b09..d6900697 100644
--- a/internal/management/controller/instance_controller.go
+++ b/internal/management/controller/instance_controller.go
@@ -327,12 +327,12 @@ func (r *InstanceReconciler) restartPrimaryInplaceIfRequested(
 			return true, err
 		}
 
-		return true, clusterstatus.RegisterPhase(
+		return true, clusterstatus.PatchWithOptimisticLock(
 			ctx,
 			r.client,
 			cluster,
-			apiv1.PhaseHealthy,
-			"Primary instance restarted in-place",
+			clusterstatus.SetPhaseTX(apiv1.PhaseHealthy, "Primary instance restarted in-place"),
+			clusterstatus.SetClusterReadyConditionTX,
 		)
 	}
 	return false, nil
@@ -1065,7 +1065,13 @@ func (r *InstanceReconciler) processConfigReloadAndManageRestart(ctx context.Con
 		return nil
 	}
 
-	return clusterstatus.RegisterPhase(ctx, r.client, cluster, phase, phaseReason)
+	return clusterstatus.PatchWithOptimisticLock(
+		ctx,
+		r.client,
+		cluster,
+		clusterstatus.SetPhaseTX(phase, phaseReason),
+		clusterstatus.SetClusterReadyConditionTX,
+	)
 }
 
 // refreshCertificateFilesFromSecret receive a secret and rewrite the file
diff --git a/pkg/resources/status/phase.go b/pkg/resources/status/phase.go
deleted file mode 100644
index bac80933..00000000
--- a/pkg/resources/status/phase.go
+++ /dev/null
@@ -1,101 +0,0 @@
-/*
-Copyright The CloudNativePG Contributors
-
-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 status
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/cloudnative-pg/machinery/pkg/log"
-	"k8s.io/apimachinery/pkg/api/meta"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
-)
-
-// RegisterPhase update phase in the status cluster with the
-// proper reason
-func RegisterPhase(
-	ctx context.Context,
-	cli client.Client,
-	cluster *apiv1.Cluster,
-	phase string,
-	reason string,
-) error {
-	existingCluster := cluster.DeepCopy()
-	return RegisterPhaseWithOrigCluster(ctx, cli, cluster, existingCluster, phase, reason)
-}
-
-// RegisterPhaseWithOrigCluster update phase in the status cluster with the
-// proper reason, it also receives an origCluster to preserve other modifications done to the status
-func RegisterPhaseWithOrigCluster(
-	ctx context.Context,
-	cli client.Client,
-	modifiedCluster *apiv1.Cluster,
-	origCluster *apiv1.Cluster,
-	phase string,
-	reason string,
-) error {
-	if err := PatchWithOptimisticLock(
-		ctx,
-		cli,
-		modifiedCluster,
-		func(cluster *apiv1.Cluster) {
-			if cluster.Status.Conditions == nil {
-				cluster.Status.Conditions = []metav1.Condition{}
-			}
-
-			cluster.Status.Phase = phase
-			cluster.Status.PhaseReason = reason
-
-			condition := metav1.Condition{
-				Type:    string(apiv1.ConditionClusterReady),
-				Status:  metav1.ConditionFalse,
-				Reason:  string(apiv1.ClusterIsNotReady),
-				Message: "Cluster Is Not Ready",
-			}
-
-			if cluster.Status.Phase == apiv1.PhaseHealthy {
-				condition = metav1.Condition{
-					Type:    string(apiv1.ConditionClusterReady),
-					Status:  metav1.ConditionTrue,
-					Reason:  string(apiv1.ClusterReady),
-					Message: "Cluster is Ready",
-				}
-			}
-
-			meta.SetStatusCondition(&cluster.Status.Conditions, condition)
-		},
-	); err != nil {
-		return fmt.Errorf("while updating phase: %w", err)
-	}
-
-	contextLogger := log.FromContext(ctx)
-
-	modifiedPhase := modifiedCluster.Status.Phase
-	origPhase := origCluster.Status.Phase
-
-	if modifiedPhase != apiv1.PhaseHealthy && origPhase == apiv1.PhaseHealthy {
-		contextLogger.Info("Cluster is not healthy")
-	}
-	if modifiedPhase == apiv1.PhaseHealthy && origPhase != apiv1.PhaseHealthy {
-		contextLogger.Info("Cluster is healthy")
-	}
-
-	return nil
-}
diff --git a/pkg/resources/status/transactions.go b/pkg/resources/status/transactions.go
new file mode 100644
index 00000000..ca04d437
--- /dev/null
+++ b/pkg/resources/status/transactions.go
@@ -0,0 +1,58 @@
+/*
+Copyright The CloudNativePG Contributors
+
+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 status
+
+import (
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+)
+
+// SetClusterReadyConditionTX updates the cluster's readiness condition
+// according to the cluster phase
+func SetClusterReadyConditionTX(cluster *apiv1.Cluster) {
+	if cluster.Status.Conditions == nil {
+		cluster.Status.Conditions = []metav1.Condition{}
+	}
+
+	condition := metav1.Condition{
+		Type:    string(apiv1.ConditionClusterReady),
+		Status:  metav1.ConditionFalse,
+		Reason:  string(apiv1.ClusterIsNotReady),
+		Message: "Cluster Is Not Ready",
+	}
+
+	if cluster.Status.Phase == apiv1.PhaseHealthy {
+		condition = metav1.Condition{
+			Type:    string(apiv1.ConditionClusterReady),
+			Status:  metav1.ConditionTrue,
+			Reason:  string(apiv1.ClusterReady),
+			Message: "Cluster is Ready",
+		}
+	}
+
+	meta.SetStatusCondition(&cluster.Status.Conditions, condition)
+}
+
+// SetPhaseTX is a transaction that sets the cluster phase and reason
+func SetPhaseTX(phase string, reason string) func(cluster *apiv1.Cluster) {
+	return func(cluster *apiv1.Cluster) {
+		cluster.Status.Phase = phase
+		cluster.Status.PhaseReason = reason
+	}
+}
diff --git a/pkg/resources/status/update.go b/pkg/resources/status/update.go
index 0543292d..3f916cd0 100644
--- a/pkg/resources/status/update.go
+++ b/pkg/resources/status/update.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"fmt"
 
+	"github.com/cloudnative-pg/machinery/pkg/log"
 	"k8s.io/apimachinery/pkg/api/equality"
 	"k8s.io/client-go/util/retry"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -28,19 +29,23 @@ import (
 )
 
 // PatchWithOptimisticLock updates the status of the cluster using the passed
-// transaction function.
+// transaction functions (in the given order).
 // Important: after successfully updating the status, this
 // function refreshes it into the passed cluster
 func PatchWithOptimisticLock(
 	ctx context.Context,
 	c client.Client,
 	cluster *apiv1.Cluster,
-	tx func(cluster *apiv1.Cluster),
+	txs ...func(cluster *apiv1.Cluster),
 ) error {
 	if cluster == nil {
 		return nil
 	}
 
+	contextLogger := log.FromContext(ctx)
+
+	origCluster := cluster.DeepCopy()
+
 	if err := retry.RetryOnConflict(retry.DefaultBackoff, func() error {
 		var currentCluster apiv1.Cluster
 		if err := c.Get(ctx, client.ObjectKeyFromObject(cluster), &currentCluster); err != nil {
@@ -48,7 +53,9 @@ func PatchWithOptimisticLock(
 		}
 
 		updatedCluster := currentCluster.DeepCopy()
-		tx(updatedCluster)
+		for _, tx := range txs {
+			tx(updatedCluster)
+		}
 
 		if equality.Semantic.DeepEqual(currentCluster.Status, updatedCluster.Status) {
 			return nil
@@ -69,5 +76,12 @@ func PatchWithOptimisticLock(
 		return fmt.Errorf("while updating conditions: %w", err)
 	}
 
+	if cluster.Status.Phase != apiv1.PhaseHealthy && origCluster.Status.Phase == apiv1.PhaseHealthy {
+		contextLogger.Info("Cluster has become unhealthy")
+	}
+	if cluster.Status.Phase == apiv1.PhaseHealthy && origCluster.Status.Phase != apiv1.PhaseHealthy {
+		contextLogger.Info("Cluster has become healthy")
+	}
+
 	return nil
 }



Test patch:
diff --git a/internal/cmd/plugin/promote/promote_test.go b/internal/cmd/plugin/promote/promote_test.go
new file mode 100644
index 00000000..46ba5ff1
--- /dev/null
+++ b/internal/cmd/plugin/promote/promote_test.go
@@ -0,0 +1,76 @@
+package promote
+
+import (
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	k8client "sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+	"github.com/cloudnative-pg/cloudnative-pg/internal/scheme"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+var _ = Describe("promote subcommand tests", func() {
+	var client k8client.Client
+	const namespace = "theNamespace"
+	BeforeEach(func() {
+		cluster1 := apiv1.Cluster{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "cluster1",
+				Namespace: namespace,
+			},
+			Spec: apiv1.ClusterSpec{},
+			Status: apiv1.ClusterStatus{
+				CurrentPrimary: "cluster1-1",
+				TargetPrimary:  "cluster1-1",
+				Phase:          apiv1.PhaseHealthy,
+				Conditions: []metav1.Condition{
+					{
+						Type:    string(apiv1.ConditionClusterReady),
+						Status:  metav1.ConditionTrue,
+						Reason:  string(apiv1.ClusterReady),
+						Message: "Cluster is Ready",
+					},
+				},
+			},
+		}
+		newPod := corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "cluster1-2",
+				Namespace: namespace,
+			},
+		}
+		client = fake.NewClientBuilder().WithScheme(scheme.BuildWithAllKnownScheme()).
+			WithObjects(&cluster1, &newPod).WithStatusSubresource(&cluster1).Build()
+	})
+
+	It("correctly sets the target primary and the phase if the target pod is present", func(ctx SpecContext) {
+		Expect(Promote(ctx, client, namespace, "cluster1", "cluster1-2")).
+			To(Succeed())
+		var cl apiv1.Cluster
+		Expect(client.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "cluster1"}, &cl)).
+			To(Succeed())
+		Expect(cl.Status.TargetPrimary).To(Equal("cluster1-2"))
+		Expect(cl.Status.Phase).To(Equal(apiv1.PhaseSwitchover))
+		Expect(cl.Status.PhaseReason).To(Equal("Switching over to cluster1-2"))
+		Expect(meta.IsStatusConditionTrue(cl.Status.Conditions, string(apiv1.ConditionClusterReady))).
+			To(BeFalse())
+	})
+
+	It("ignores the promotion if the target pod is missing", func(ctx SpecContext) {
+		err := Promote(ctx, client, namespace, "cluster1", "cluster1-missingPod")
+		Expect(err).To(HaveOccurred())
+		var cl apiv1.Cluster
+		Expect(client.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "cluster1"}, &cl)).
+			To(Succeed())
+		Expect(cl.Status.TargetPrimary).To(Equal("cluster1-1"))
+		Expect(cl.Status.Phase).To(Equal(apiv1.PhaseHealthy))
+		Expect(meta.IsStatusConditionTrue(cl.Status.Conditions, string(apiv1.ConditionClusterReady))).
+			To(BeTrue())
+	})
+})
diff --git a/internal/cmd/plugin/promote/suite_test.go b/internal/cmd/plugin/promote/suite_test.go
new file mode 100644
index 00000000..3c0e3633
--- /dev/null
+++ b/internal/cmd/plugin/promote/suite_test.go
@@ -0,0 +1,30 @@
+/*
+Copyright The CloudNativePG Contributors
+
+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 promote
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+func TestPlugin(t *testing.T) {
+	RegisterFailHandler(Fail)
+
+	RunSpecs(t, "Promote plugin Suite")
+}