Repository: cloudnative-pg/cloudnative-pg

Issue:
[Feature]: promote failover quorum to stable
### Is there an existing issue already for this feature request/idea?

- [x] I have searched for an existing issue, and could not find anything. I believe this is a new feature request to be evaluated.

### What problem is this feature going to solve? Why should it be added?

CloudNativePG 1.27 introduces experimental support for failover quorum with #7572 .

### Describe the solution you'd like

For 1.28 the feature should become stable and part of the API. We need proper testing in place, either internally or via an external project.

### Describe alternatives you've considered

N/A

### Additional context

_No response_

### Backport?

No

### Are you willing to actively contribute to this feature?

Yes

### Code of Conduct

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

Discussion:

Solution patch:
diff --git a/.wordlist-en-custom.txt b/.wordlist-en-custom.txt
index 876f8e0a..0f524a9c 100644
--- a/.wordlist-en-custom.txt
+++ b/.wordlist-en-custom.txt
@@ -844,6 +844,7 @@ externalclusters
 facto
 failover
 failoverDelay
+failoverQuorum
 failoverquorums
 failovers
 failureThreshold
diff --git a/api/v1/cluster_defaults.go b/api/v1/cluster_defaults.go
index 707b3f42..6657566d 100644
--- a/api/v1/cluster_defaults.go
+++ b/api/v1/cluster_defaults.go
@@ -22,6 +22,7 @@ package v1
 import (
 	"encoding/json"
 	"fmt"
+	"strconv"
 
 	"github.com/cloudnative-pg/machinery/pkg/log"
 	"github.com/cloudnative-pg/machinery/pkg/stringset"
@@ -142,6 +143,7 @@ func (r *Cluster) setDefaults(preserveUserSettings bool) {
 
 	r.setDefaultPlugins(configuration.Current)
 	r.setProbes()
+	r.tryConvertAlphaFailoverQuorum()
 }
 
 func (r *Cluster) setDefaultPlugins(config *configuration.Data) {
@@ -332,6 +334,28 @@ func (r *Cluster) tryConvertAlphaLivenessPinger() {
 	}
 }
 
+func (r *Cluster) tryConvertAlphaFailoverQuorum() {
+	annotationValue, ok := r.Annotations[utils.FailoverQuorumAnnotationName]
+	if !ok {
+		return
+	}
+
+	v, err := strconv.ParseBool(annotationValue)
+	if err != nil {
+		// The validation webhook will catch this
+		// error and notify the user.
+		return
+	}
+
+	// The webhook prevents the user from enabling
+	// failover quorum without synchronous replication.
+	if r.Spec.PostgresConfiguration.Synchronous == nil {
+		return
+	}
+
+	r.Spec.PostgresConfiguration.Synchronous.FailoverQuorum = v
+}
+
 // NewLivenessPingerConfigFromAnnotations creates a new pinger configuration from the annotations
 // in the cluster definition
 func NewLivenessPingerConfigFromAnnotations(
diff --git a/api/v1/cluster_funcs.go b/api/v1/cluster_funcs.go
index 5690984d..2f29f956 100644
--- a/api/v1/cluster_funcs.go
+++ b/api/v1/cluster_funcs.go
@@ -25,7 +25,6 @@ import (
 	"maps"
 	"regexp"
 	"slices"
-	"strconv"
 	"strings"
 	"time"
 
@@ -1565,16 +1564,10 @@ func (cluster *Cluster) GetEnabledWALArchivePluginName() string {
 
 // IsFailoverQuorumActive check if we should enable the
 // quorum failover protection alpha-feature.
-func (cluster *Cluster) IsFailoverQuorumActive() (bool, error) {
-	failoverQuorumAnnotation, ok := cluster.GetAnnotations()[utils.FailoverQuorumAnnotationName]
-	if !ok || failoverQuorumAnnotation == "" {
-		return false, nil
-	}
-
-	v, err := strconv.ParseBool(failoverQuorumAnnotation)
-	if err != nil {
-		return false, fmt.Errorf("failed to parse failover quorum annotation '%v': %v", failoverQuorumAnnotation, err)
+func (cluster *Cluster) IsFailoverQuorumActive() bool {
+	if cluster.Spec.PostgresConfiguration.Synchronous == nil {
+		return false
 	}
 
-	return v, nil
+	return cluster.Spec.PostgresConfiguration.Synchronous.FailoverQuorum
 }
diff --git a/api/v1/cluster_types.go b/api/v1/cluster_types.go
index 77f9f06c..299162de 100644
--- a/api/v1/cluster_types.go
+++ b/api/v1/cluster_types.go
@@ -1387,6 +1387,12 @@ type SynchronousReplicaConfiguration struct {
 	// +kubebuilder:validation:Enum=required;preferred
 	// +optional
 	DataDurability DataDurabilityLevel `json:"dataDurability,omitempty"`
+
+	// FailoverQuorum enables a quorum-based check before failover, improving
+	// data durability and safety during failover events in CloudNativePG-managed
+	// PostgreSQL clusters.
+	// +optional
+	FailoverQuorum bool `json:"failoverQuorum"`
 }
 
 // PostgresConfiguration defines the PostgreSQL configuration
diff --git a/config/crd/bases/postgresql.cnpg.io_clusters.yaml b/config/crd/bases/postgresql.cnpg.io_clusters.yaml
index 558e406a..e03d5dc0 100644
--- a/config/crd/bases/postgresql.cnpg.io_clusters.yaml
+++ b/config/crd/bases/postgresql.cnpg.io_clusters.yaml
@@ -4290,6 +4290,12 @@ spec:
                         - required
                         - preferred
                         type: string
+                      failoverQuorum:
+                        description: |-
+                          FailoverQuorum enables a quorum-based check before failover, improving
+                          data durability and safety during failover events in CloudNativePG-managed
+                          PostgreSQL clusters.
+                        type: boolean
                       maxStandbyNamesFromCluster:
                         description: |-
                           Specifies the maximum number of local cluster pods that can be
diff --git a/docs/src/cloudnative-pg.v1.md b/docs/src/cloudnative-pg.v1.md
index a772d014..da695f4e 100644
--- a/docs/src/cloudnative-pg.v1.md
+++ b/docs/src/cloudnative-pg.v1.md
@@ -6466,6 +6466,15 @@ to allow for operational continuity. This setting is only applicable if both
 <code>standbyNamesPre</code> and <code>standbyNamesPost</code> are unset (empty).</p>
 </td>
 </tr>
+<tr><td><code>failoverQuorum</code><br/>
+<i>bool</i>
+</td>
+<td>
+   <p>FailoverQuorum enables a quorum-based check before failover, improving
+data durability and safety during failover events in CloudNativePG-managed
+PostgreSQL clusters.</p>
+</td>
+</tr>
 </tbody>
 </table>
 
diff --git a/docs/src/failover.md b/docs/src/failover.md
index ab78aa70..1608f2ae 100644
--- a/docs/src/failover.md
+++ b/docs/src/failover.md
@@ -133,13 +133,42 @@ the instance to promote, and it does not occur otherwise.
 This feature allows users to choose their preferred trade-off between data
 durability and data availability.
 
-Failover quorum can be enabled by setting the annotation
-`alpha.cnpg.io/failoverQuorum="true"` in the `Cluster` resource.
+Failover quorum can be enabled by setting the
+`.spec.postgresql.synchronous.failoverQuorum` field to `true`:
 
-!!! info
-    When this feature is out of the experimental phase, the annotation
-    `alpha.cnpg.io/failoverQuorum` will be replaced by a configuration option in
-    the `Cluster` resource.
+```yaml
+apiVersion: postgresql.cnpg.io/v1
+kind: Cluster
+metadata:
+  name: cluster-example
+spec:
+  instances: 3
+
+  postgresql:
+    synchronous:
+      method: any
+      number: 1
+      failoverQuorum: true
+
+  storage:
+    size: 1G
+```
+
+For backward compatibility, the legacy annotation
+`alpha.cnpg.io/failoverQuorum` is still supported by the admission webhook and
+takes precedence over the `Cluster` spec option:
+
+- If the annotation evaluates to `"true"` and a synchronous replication stanza
+  is present, the webhook automatically sets
+  `.spec.postgresql.synchronous.failoverQuorum` to `true`.
+- If the annotation evaluates to `"false"`, the feature is always disabled
+
+!!! Important
+    Because the annotation overrides the spec, we recommend that users of this
+    experimental feature migrate to the native
+    `.spec.postgresql.synchronous.failoverQuorum` option and remove the annotation
+    from their manifests. The annotation is **deprecated** and will be removed in a
+    future release.
 
 ### How it works
 
diff --git a/docs/src/samples/cluster-example-syncreplicas-quorum.yaml b/docs/src/samples/cluster-example-syncreplicas-quorum.yaml
index ba3b6e92..72dc03b7 100644
--- a/docs/src/samples/cluster-example-syncreplicas-quorum.yaml
+++ b/docs/src/samples/cluster-example-syncreplicas-quorum.yaml
@@ -2,8 +2,6 @@ apiVersion: postgresql.cnpg.io/v1
 kind: Cluster
 metadata:
   name: cluster-example
-  annotations:
-    alpha.cnpg.io/failoverQuorum: "true"
 spec:
   instances: 3
 
@@ -11,6 +9,7 @@ spec:
     synchronous:
       method: any
       number: 1
+      failoverQuorum: true
 
   storage:
     size: 1G
diff --git a/internal/controller/replicas.go b/internal/controller/replicas.go
index bad7f515..a3e29c0f 100644
--- a/internal/controller/replicas.go
+++ b/internal/controller/replicas.go
@@ -111,15 +111,8 @@ func (r *ClusterReconciler) reconcileTargetPrimaryForNonReplicaCluster(
 	}
 
 	// If quorum check is active, ensure we don't failover in unsafe scenarios.
-	isFailoverQuorumActive, err := cluster.IsFailoverQuorumActive()
-	if err != nil {
-		contextLogger.Error(err, "Failed to determine if failover quorum is active")
-		isFailoverQuorumActive = false
-	}
-
 	if cluster.Status.TargetPrimary == cluster.Status.CurrentPrimary &&
-		cluster.Spec.PostgresConfiguration.Synchronous != nil &&
-		isFailoverQuorumActive {
+		cluster.IsFailoverQuorumActive() {
 		if status, err := r.evaluateQuorumCheck(ctx, cluster, status); err != nil {
 			return "", err
 		} else if !status {
diff --git a/internal/controller/replicas_quorum.go b/internal/controller/replicas_quorum.go
index 07943655..707fe148 100644
--- a/internal/controller/replicas_quorum.go
+++ b/internal/controller/replicas_quorum.go
@@ -134,14 +134,7 @@ func (r *ClusterReconciler) evaluateQuorumCheckWithStatus(
 }
 
 func (r *ClusterReconciler) reconcileFailoverQuorumObject(ctx context.Context, cluster *apiv1.Cluster) error {
-	contextLogger := log.FromContext(ctx).WithValues("tag", "quorumCheck")
-
-	syncConfig := cluster.Spec.PostgresConfiguration.Synchronous
-	failoverQuorumActive, err := cluster.IsFailoverQuorumActive()
-	if err != nil {
-		contextLogger.Error(err, "Failed to determine if failover quorum is active")
-	}
-	if syncConfig != nil && failoverQuorumActive {
+	if cluster.IsFailoverQuorumActive() {
 		return r.ensureFailoverQuorumObjectExists(ctx, cluster)
 	}
 
diff --git a/internal/management/controller/instance_sync.go b/internal/management/controller/instance_sync.go
index 6d9809fc..af5df5d5 100644
--- a/internal/management/controller/instance_sync.go
+++ b/internal/management/controller/instance_sync.go
@@ -22,7 +22,6 @@ package controller
 import (
 	"context"
 
-	"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"
@@ -33,7 +32,7 @@ import (
 // resetFailoverQuorumObject resets the content of the sync quorum object
 // to prevent unsafe failovers when we are changing the configuration
 func (r *InstanceReconciler) resetFailoverQuorumObject(ctx context.Context, cluster *apiv1.Cluster) error {
-	if !r.shouldManageFailoverQuorumObject(ctx, cluster) {
+	if !r.shouldManageFailoverQuorumObject(cluster) {
 		return nil
 	}
 
@@ -53,7 +52,7 @@ func (r *InstanceReconciler) resetFailoverQuorumObject(ctx context.Context, clus
 // updateFailoverQuorumObject updates the sync quorum object reading the
 // current synchronous replica metadata from the PG instance
 func (r *InstanceReconciler) updateFailoverQuorumObject(ctx context.Context, cluster *apiv1.Cluster) error {
-	if !r.shouldManageFailoverQuorumObject(ctx, cluster) {
+	if !r.shouldManageFailoverQuorumObject(cluster) {
 		return nil
 	}
 
@@ -96,9 +95,7 @@ func (r *InstanceReconciler) updateFailoverQuorumObject(ctx context.Context, clu
 	})
 }
 
-func (r *InstanceReconciler) shouldManageFailoverQuorumObject(ctx context.Context, cluster *apiv1.Cluster) bool {
-	contextLogger := log.FromContext(ctx)
-
+func (r *InstanceReconciler) shouldManageFailoverQuorumObject(cluster *apiv1.Cluster) bool {
 	if cluster.Status.TargetPrimary != r.instance.GetPodName() {
 		return false
 	}
@@ -109,15 +106,5 @@ func (r *InstanceReconciler) shouldManageFailoverQuorumObject(ctx context.Contex
 		return false
 	}
 
-	failoverQuorumActive, err := cluster.IsFailoverQuorumActive()
-	if err != nil {
-		contextLogger.Error(err, "Failed to determine if sync quorum is active")
-		failoverQuorumActive = false
-	}
-
-	if !failoverQuorumActive {
-		return false
-	}
-
-	return true
+	return cluster.IsFailoverQuorumActive()
 }
diff --git a/internal/webhook/v1/cluster_webhook.go b/internal/webhook/v1/cluster_webhook.go
index 807e215f..109413e6 100644
--- a/internal/webhook/v1/cluster_webhook.go
+++ b/internal/webhook/v1/cluster_webhook.go
@@ -207,6 +207,7 @@ func (v *ClusterCustomValidator) validate(r *apiv1.Cluster) (allErrs field.Error
 		v.validateRetentionPolicy,
 		v.validateConfiguration,
 		v.validateSynchronousReplicaConfiguration,
+		v.validateFailoverQuorumAlphaAnnotation,
 		v.validateFailoverQuorum,
 		v.validateLDAP,
 		v.validateReplicationSlots,
@@ -993,23 +994,47 @@ func (v *ClusterCustomValidator) validateSynchronousReplicaConfiguration(r *apiv
 	return result
 }
 
-func (v *ClusterCustomValidator) validateFailoverQuorum(r *apiv1.Cluster) field.ErrorList {
-	var result field.ErrorList
+func (v *ClusterCustomValidator) validateFailoverQuorumAlphaAnnotation(r *apiv1.Cluster) field.ErrorList {
+	annotationValue, ok := r.Annotations[utils.FailoverQuorumAnnotationName]
+	if !ok {
+		return nil
+	}
 
-	failoverQuorumActive, err := r.IsFailoverQuorumActive()
+	failoverQuorumActive, err := strconv.ParseBool(annotationValue)
 	if err != nil {
-		err := field.Invalid(
-			field.NewPath("metadata", "annotations", utils.FailoverQuorumAnnotationName),
-			r.Annotations[utils.FailoverQuorumAnnotationName],
-			"Invalid failoverQuorum annotation value, expected boolean.",
-		)
-		result = append(result, err)
-		return result
+		return field.ErrorList{
+			field.Invalid(
+				field.NewPath("metadata", "annotations", utils.FailoverQuorumAnnotationName),
+				r.Annotations[utils.FailoverQuorumAnnotationName],
+				"Invalid failoverQuorum annotation value, expected boolean.",
+			),
+		}
 	}
+
 	if !failoverQuorumActive {
 		return nil
 	}
 
+	if r.Spec.PostgresConfiguration.Synchronous == nil {
+		return field.ErrorList{
+			field.Required(
+				field.NewPath("spec", "postgresql", "synchronous"),
+				"Invalid failoverQuorum configuration: synchronous replication configuration "+
+					"is required.",
+			),
+		}
+	}
+
+	return nil
+}
+
+func (v *ClusterCustomValidator) validateFailoverQuorum(r *apiv1.Cluster) field.ErrorList {
+	var result field.ErrorList
+
+	if !r.IsFailoverQuorumActive() {
+		return nil
+	}
+
 	cfg := r.Spec.PostgresConfiguration.Synchronous
 	if cfg == nil {
 		err := field.Required(
@@ -1025,7 +1050,7 @@ func (v *ClusterCustomValidator) validateFailoverQuorum(r *apiv1.Cluster) field.
 		err := field.Invalid(
 			field.NewPath("spec", "postgresql", "synchronous"),
 			cfg,
-			"Invalid failoverQuorum configuration: spec.postgresql.synchronous.number must the greater than "+
+			"Invalid failoverQuorum configuration: spec.postgresql.synchronous.number must be greater than "+
 				"the total number of instances in spec.postgresql.synchronous.standbyNamesPre and "+
 				"spec.postgresql.synchronous.standbyNamesPost to allow automatic failover.",
 		)



Test patch:
diff --git a/api/v1/cluster_defaults_test.go b/api/v1/cluster_defaults_test.go
index 608e9940..257891a8 100644
--- a/api/v1/cluster_defaults_test.go
+++ b/api/v1/cluster_defaults_test.go
@@ -515,3 +515,63 @@ var _ = Describe("probe defaults", func() {
 		Expect(cluster.Spec.Probes.Liveness.IsolationCheck.ConnectionTimeout).To(Equal(600))
 	})
 })
+
+var _ = Describe("failover quorum defaults", func() {
+	clusterWithFailoverQuorumAnnotation := func(value string) *Cluster {
+		return &Cluster{
+			ObjectMeta: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					utils.FailoverQuorumAnnotationName: value,
+				},
+			},
+			Spec: ClusterSpec{
+				PostgresConfiguration: PostgresConfiguration{
+					Synchronous: &SynchronousReplicaConfiguration{},
+				},
+			},
+		}
+	}
+
+	It("should convert the annotation if present and set to true", func() {
+		cluster := clusterWithFailoverQuorumAnnotation("t")
+		cluster.Default()
+		Expect(cluster.Spec.PostgresConfiguration.Synchronous.FailoverQuorum).To(BeTrue())
+	})
+
+	It("should convert the annotation if present and set to false", func() {
+		cluster := clusterWithFailoverQuorumAnnotation("f")
+		cluster.Spec.PostgresConfiguration.Synchronous.FailoverQuorum = true
+		cluster.Default()
+		Expect(cluster.Spec.PostgresConfiguration.Synchronous.FailoverQuorum).To(BeFalse())
+	})
+
+	It("should not convert the annotation if the value is wrong", func() {
+		cluster := clusterWithFailoverQuorumAnnotation("toast")
+		cluster.Spec.PostgresConfiguration.Synchronous.FailoverQuorum = true
+		cluster.Default()
+		Expect(cluster.Spec.PostgresConfiguration.Synchronous.FailoverQuorum).To(BeTrue())
+	})
+
+	It("should not convert the annotation if the synchronous replication stanza has not been set", func() {
+		cluster := clusterWithFailoverQuorumAnnotation("t")
+		cluster.Spec.PostgresConfiguration.Synchronous = nil
+
+		// This would panic if the code tried to convert the annotation
+		cluster.Default()
+	})
+
+	It("should not override the existing setting if the annotation has not been set", func() {
+		cluster := &Cluster{
+			Spec: ClusterSpec{
+				PostgresConfiguration: PostgresConfiguration{
+					Synchronous: &SynchronousReplicaConfiguration{
+						FailoverQuorum: true,
+					},
+				},
+			},
+		}
+
+		cluster.Default()
+		Expect(cluster.Spec.PostgresConfiguration.Synchronous.FailoverQuorum).To(BeTrue())
+	})
+})
diff --git a/api/v1/cluster_funcs_test.go b/api/v1/cluster_funcs_test.go
index 5e92d4ea..a33fdec4 100644
--- a/api/v1/cluster_funcs_test.go
+++ b/api/v1/cluster_funcs_test.go
@@ -1750,32 +1750,41 @@ var _ = Describe("Probes configuration", func() {
 	})
 })
 
-var _ = Describe("Failover quorum annotation", func() {
-	clusterWithAnnotation := func(v string) *Cluster {
-		return &Cluster{
-			ObjectMeta: metav1.ObjectMeta{
-				Annotations: map[string]string{
-					utils.FailoverQuorumAnnotationName: v,
+var _ = Describe("Failover quorum", func() {
+	clusterWithoutSynchrousReplication := &Cluster{
+		Spec: ClusterSpec{
+			PostgresConfiguration: PostgresConfiguration{
+				Synchronous: nil,
+			},
+		},
+	}
+	clusterWithFailoverQuorumEnabled := &Cluster{
+		Spec: ClusterSpec{
+			PostgresConfiguration: PostgresConfiguration{
+				Synchronous: &SynchronousReplicaConfiguration{
+					FailoverQuorum: true,
 				},
 			},
-		}
+		},
+	}
+	clusterWithFailoverQuorumDisabled := &Cluster{
+		Spec: ClusterSpec{
+			PostgresConfiguration: PostgresConfiguration{
+				Synchronous: &SynchronousReplicaConfiguration{
+					FailoverQuorum: false,
+				},
+			},
+		},
 	}
 
 	DescribeTable(
-		"annotation parsing",
-		func(cluster *Cluster, valueIsCorrect, expected bool) {
-			actual, err := cluster.IsFailoverQuorumActive()
-			if valueIsCorrect {
-				Expect(err).ToNot(HaveOccurred())
-			} else {
-				Expect(err).To(HaveOccurred())
-			}
+		"failover quorum getter",
+		func(cluster *Cluster, expected bool) {
+			actual := cluster.IsFailoverQuorumActive()
 			Expect(actual).To(Equal(expected))
 		},
-		Entry("with no annotation", &Cluster{}, true, false),
-		Entry("with empty annotation", clusterWithAnnotation(""), true, false),
-		Entry("with true annotation", clusterWithAnnotation("t"), true, true),
-		Entry("with false annotation", clusterWithAnnotation("f"), true, false),
-		Entry("with invalid annotation", clusterWithAnnotation("xxx"), false, false),
+		Entry("with no synchronous replication configuration", clusterWithoutSynchrousReplication, false),
+		Entry("with failover quorum enabled", clusterWithFailoverQuorumEnabled, true),
+		Entry("with failover quorum disabled", clusterWithFailoverQuorumDisabled, false),
 	)
 })
diff --git a/internal/webhook/v1/cluster_webhook_test.go b/internal/webhook/v1/cluster_webhook_test.go
index 526fc5d7..8293e4cd 100644
--- a/internal/webhook/v1/cluster_webhook_test.go
+++ b/internal/webhook/v1/cluster_webhook_test.go
@@ -5796,17 +5796,17 @@ var _ = Describe("getStorageWarnings", func() {
 	})
 })
 
-var _ = Describe("failoverQuorum validation", func() {
+var _ = Describe("failoverQuorum annotation validation", func() {
 	var v *ClusterCustomValidator
 	BeforeEach(func() {
 		v = &ClusterCustomValidator{}
 	})
 
-	It("fails if it is active but no synchronous replication is configured", func() {
+	It("fails if the annotation value is wrong", func() {
 		cluster := &apiv1.Cluster{
 			ObjectMeta: metav1.ObjectMeta{
 				Annotations: map[string]string{
-					utils.FailoverQuorumAnnotationName: "t",
+					utils.FailoverQuorumAnnotationName: "toast",
 				},
 			},
 			Spec: apiv1.ClusterSpec{
@@ -5814,22 +5814,41 @@ var _ = Describe("failoverQuorum validation", func() {
 			},
 		}
 
-		errList := v.validateFailoverQuorum(cluster)
+		errList := v.validateFailoverQuorumAlphaAnnotation(cluster)
 		Expect(errList).To(HaveLen(1))
 	})
 
-	It("requires at least three instances", func() {
+	It("fails if the annotation is active but no synchronous replication is configured", func() {
 		cluster := &apiv1.Cluster{
 			ObjectMeta: metav1.ObjectMeta{
 				Annotations: map[string]string{
 					utils.FailoverQuorumAnnotationName: "t",
 				},
 			},
+			Spec: apiv1.ClusterSpec{
+				Instances: 3,
+			},
+		}
+
+		errList := v.validateFailoverQuorumAlphaAnnotation(cluster)
+		Expect(errList).To(HaveLen(1))
+	})
+})
+
+var _ = Describe("failoverQuorum validation", func() {
+	var v *ClusterCustomValidator
+	BeforeEach(func() {
+		v = &ClusterCustomValidator{}
+	})
+
+	It("requires at least three instances", func() {
+		cluster := &apiv1.Cluster{
 			Spec: apiv1.ClusterSpec{
 				Instances: 3,
 				PostgresConfiguration: apiv1.PostgresConfiguration{
 					Synchronous: &apiv1.SynchronousReplicaConfiguration{
-						Number: 1,
+						Number:         1,
+						FailoverQuorum: true,
 					},
 				},
 			},
@@ -5845,11 +5864,6 @@ var _ = Describe("failoverQuorum validation", func() {
 
 	It("check if the number of external synchronous replicas is coherent", func() {
 		cluster := &apiv1.Cluster{
-			ObjectMeta: metav1.ObjectMeta{
-				Annotations: map[string]string{
-					utils.FailoverQuorumAnnotationName: "t",
-				},
-			},
 			Spec: apiv1.ClusterSpec{
 				Instances: 3,
 				PostgresConfiguration: apiv1.PostgresConfiguration{
@@ -5863,6 +5877,7 @@ var _ = Describe("failoverQuorum validation", func() {
 							"three",
 							"four",
 						},
+						FailoverQuorum: true,
 					},
 				},
 			},