Repository: cloudnative-pg/cloudnative-pg

Issue:
[Bug]: Failed backups with method=volumeSnapshot do NOT reflect in status
### 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

agelwarg@galileosuite.com

### Version

1.26 (latest patch)

### What version of Kubernetes are you using?

1.31

### What is your Kubernetes environment?

Cloud: Amazon EKS

### How did you install the operator?

Helm

### What happened?

We are trying to create appropriate Prometheus alerting rules to monitor backups, and are looking to utilize the `cnpg_collector_last_failed_backup_timestamp` metric.  However, it does not look like this is updated when (base) backups are configured with method=volumeSnapshot.

### Cluster resource

```shell
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: test-database
spec:
  instances: 1
  imageCatalogRef:
    apiGroup: postgresql.cnpg.io
    kind: ImageCatalog
    name: test
    major: 17
  primaryUpdateMethod: switchover
  bootstrap:
    initdb:
      database: 'test'
      owner: 'test'
  backup:
    retentionPolicy: 3d
    barmanObjectStore:
      destinationPath: "s3://some-bucket/database"
      s3Credentials:
        inheritFromIAMRole: true
      data:
        compression: "snappy"
      wal:
        compression: "snappy"
    volumeSnapshot:
      className: snapshots-nonexistent
      snapshotOwnerReference: backup
  storage:
    size: 50Gi
    storageClass: gp3-encrypted


--

apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
  name: test-database-backup
spec:
  immediate: true
  schedule: "0 25 */4 * * *"
  method: volumeSnapshot
  cluster:
    name: test-database
```

### Relevant log output

```shell
Output from kubectl get backups to demonstrate the failed backup:

test-database-backup-20250625173455   4m7s   test-database   volumeSnapshot      failed      can't execute snapshot backup: Failed to get snapshot class with error volumesnapshotclass.snapshot.storage.k8s.io "snapshots-nonexistent" not found

Yet the metric `cnpg_collector_last_failed_backup_timestamp` in the exporter remains 0 and there is no `lastFailedBackup` in the status of the cluster.

By contrast, if I change the method to barmanObjectStore, these are correctly updated/reported.
```

### Code of Conduct

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

Discussion:
@dosubot: <!-- Answer -->
This is a real gap in CloudNativePG's handling of failed backups with method=volumeSnapshot. When a volumeSnapshot backup fails (for example, due to a missing snapshot class), the cluster status fields like lastFailedBackup and the Prometheus metric cnpg_collector_last_failed_backup_timestamp are not updated. This is different from barmanObjectStore backups, which do update these fields on failure.

The root cause is in the controller logic: if the failure happens before the VolumeSnapshot resource is created (such as when the snapshot class is missing), the error does not propagate to the cluster status or metrics. The reconciler for volumeSnapshot backups does not explicitly update these fields for non-retryable errors, so the exporter never sees the failure to report it as a metric. This is not a limitation of the exporter, but rather a missing update in the backup controller logic itself. You can see this in the code and related issues—there is no explicit update to the cluster status or metrics for these early failures, only for barmanObjectStore or later-stage errors in the backup process ([source](https://github.com/cloudnative-pg/cloudnative-pg/blob/b040f82520f96695d39dff22c4e7dd27307c8ed3/pkg/reconciler/backup/volumesnapshot/reconciler.go)).

There are a few similar reports, but no official tracking issue or fix as of 1.26. This appears to be a new bug and a gap in the current implementation ([related issue](https://github.com/cloudnative-pg/cloudnative-pg/issues/7432)). If you need alerting on failed volumeSnapshot backups, you may need to supplement with direct monitoring of Backup resources for now.


<!-- Dosu Comment Footer -->
*To reply, just mention [@dosu](https://go.dosu.dev/dosubot).*

---
<sup>How did I do? [Good](https://app.dosu.dev/response-feedback/173860f7-9b5d-4295-83ed-b2ded631d6ee?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/173860f7-9b5d-4295-83ed-b2ded631d6ee?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/173860f7-9b5d-4295-83ed-b2ded631d6ee?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/173860f7-9b5d-4295-83ed-b2ded631d6ee?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/173860f7-9b5d-4295-83ed-b2ded631d6ee?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/173860f7-9b5d-4295-83ed-b2ded631d6ee?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/173860f7-9b5d-4295-83ed-b2ded631d6ee?feedback_type=other)</sup>&nbsp;&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/cloudnative-pg/cloudnative-pg/issues/7892)@armru: Thanks for the bug report!

Solution patch:
diff --git a/internal/controller/backup_controller.go b/internal/controller/backup_controller.go
index d2280949..f2316a10 100644
--- a/internal/controller/backup_controller.go
+++ b/internal/controller/backup_controller.go
@@ -132,7 +132,8 @@ func (r *BackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 			return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
 		}
 
-		tryFlagBackupAsFailed(ctx, r.Client, &backup, fmt.Errorf("while getting cluster %s: %w", clusterName, err))
+		_ = resourcestatus.FlagBackupAsFailed(ctx, r.Client, &backup, nil,
+			fmt.Errorf("while getting cluster %s: %w", clusterName, err))
 		r.Recorder.Eventf(&backup, "Warning", "FindingCluster",
 			"Error getting cluster %v, will not retry: %s", clusterName, err.Error())
 		return ctrl.Result{}, nil
@@ -142,7 +143,7 @@ func (r *BackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		message := "cannot proceed with the backup as the cluster has no plugin configured"
 		contextLogger.Warning(message)
 		r.Recorder.Event(&backup, "Warning", "ClusterHasNoBackupExecutorPlugin", message)
-		tryFlagBackupAsFailed(ctx, r.Client, &backup, errors.New(message))
+		_ = resourcestatus.FlagBackupAsFailed(ctx, r.Client, &backup, &cluster, errors.New(message))
 		return ctrl.Result{}, nil
 	}
 
@@ -150,7 +151,7 @@ func (r *BackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		message := "cannot proceed with the backup as the cluster has no backup section"
 		contextLogger.Warning(message)
 		r.Recorder.Event(&backup, "Warning", "ClusterHasBackupConfigured", message)
-		tryFlagBackupAsFailed(ctx, r.Client, &backup, errors.New(message))
+		_ = resourcestatus.FlagBackupAsFailed(ctx, r.Client, &backup, &cluster, errors.New(message))
 		return ctrl.Result{}, nil
 	}
 
@@ -180,7 +181,7 @@ func (r *BackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		message := "cannot proceed with the backup as the Kubernetes cluster has no VolumeSnapshot support"
 		contextLogger.Warning(message)
 		r.Recorder.Event(&backup, "Warning", "ClusterHasNoVolumeSnapshotCRD", message)
-		tryFlagBackupAsFailed(ctx, r.Client, &backup, errors.New(message))
+		_ = resourcestatus.FlagBackupAsFailed(ctx, r.Client, &backup, &cluster, errors.New(message))
 		return ctrl.Result{}, nil
 	}
 
@@ -204,7 +205,7 @@ func (r *BackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 
 	if backup.Spec.Method == apiv1.BackupMethodBarmanObjectStore {
 		if cluster.Spec.Backup == nil || cluster.Spec.Backup.BarmanObjectStore == nil {
-			tryFlagBackupAsFailed(ctx, r.Client, &backup,
+			_ = resourcestatus.FlagBackupAsFailed(ctx, r.Client, &backup, &cluster,
 				errors.New("no barmanObjectStore section defined on the target cluster"))
 			return ctrl.Result{}, nil
 		}
@@ -247,7 +248,7 @@ func (r *BackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 			return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
 		}
 		if err != nil {
-			tryFlagBackupAsFailed(ctx, r.Client, &backup, fmt.Errorf("while getting pod: %w", err))
+			_ = resourcestatus.FlagBackupAsFailed(ctx, r.Client, &backup, &cluster, fmt.Errorf("while getting pod: %w", err))
 			r.Recorder.Eventf(&backup, "Warning", "FindingPod", "Error getting target pod: %s",
 				cluster.Status.TargetPrimary)
 			return ctrl.Result{}, nil
@@ -272,12 +273,13 @@ func (r *BackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		// This backup can be started
 		if err := startInstanceManagerBackup(ctx, r.Client, &backup, pod, &cluster); err != nil {
 			r.Recorder.Eventf(&backup, "Warning", "Error", "Backup exit with error %v", err)
-			tryFlagBackupAsFailed(ctx, r.Client, &backup, fmt.Errorf("encountered an error while taking the backup: %w", err))
+			_ = resourcestatus.FlagBackupAsFailed(ctx, r.Client, &backup, &cluster,
+				fmt.Errorf("encountered an error while taking the backup: %w", err))
 			return ctrl.Result{}, nil
 		}
 	case apiv1.BackupMethodVolumeSnapshot:
 		if cluster.Spec.Backup == nil || cluster.Spec.Backup.VolumeSnapshot == nil {
-			tryFlagBackupAsFailed(ctx, r.Client, &backup,
+			_ = resourcestatus.FlagBackupAsFailed(ctx, r.Client, &backup, &cluster,
 				errors.New("no volumeSnapshot section defined on the target cluster"))
 			return ctrl.Result{}, nil
 		}
@@ -403,7 +405,7 @@ func (r *BackupReconciler) reconcileSnapshotBackup(
 		return &ctrl.Result{RequeueAfter: 30 * time.Second}, nil
 	}
 	if err != nil {
-		tryFlagBackupAsFailed(ctx, r.Client, backup, fmt.Errorf("while getting pod: %w", err))
+		_ = resourcestatus.FlagBackupAsFailed(ctx, r.Client, backup, cluster, fmt.Errorf("while getting pod: %w", err))
 		r.Recorder.Eventf(backup, "Warning", "FindingPod", "Error getting target pod: %s",
 			cluster.Status.TargetPrimary)
 		return &ctrl.Result{}, nil
@@ -472,18 +474,9 @@ func (r *BackupReconciler) reconcileSnapshotBackup(
 		// Volume Snapshot errors are not retryable, we need to set this backup as failed
 		// and un-fence the Pod
 		contextLogger.Error(err, "while executing snapshot backup")
-		// Update backup status in cluster conditions
-		if errCond := resourcestatus.PatchConditionsWithOptimisticLock(
-			ctx,
-			r.Client,
-			cluster,
-			apiv1.BuildClusterBackupFailedCondition(err),
-		); errCond != nil {
-			contextLogger.Error(errCond, "Error while updating backup condition (backup snapshot failed)")
-		}
-
 		r.Recorder.Eventf(backup, "Warning", "Error", "snapshot backup failed: %v", err)
-		tryFlagBackupAsFailed(ctx, r.Client, backup, fmt.Errorf("can't execute snapshot backup: %w", err))
+		_ = resourcestatus.FlagBackupAsFailed(ctx, r.Client, backup, cluster,
+			fmt.Errorf("can't execute snapshot backup: %w", err))
 		return nil, volumesnapshot.EnsurePodIsUnfenced(ctx, r.Client, r.Recorder, cluster, backup, targetPod)
 	}
 
@@ -666,20 +659,10 @@ func startInstanceManagerBackup(
 	})
 	if err != nil {
 		log.FromContext(ctx).Error(err, "executing backup", "stdout", stdout, "stderr", stderr)
-		status.SetAsFailed(fmt.Errorf("can't execute backup: %w", err))
-		status.CommandError = stderr
-		status.CommandError = stdout
-
-		// Update backup status in cluster conditions
-		if errCond := resourcestatus.PatchConditionsWithOptimisticLock(
-			ctx,
-			client,
-			cluster,
-			apiv1.BuildClusterBackupFailedCondition(err),
-		); errCond != nil {
-			log.FromContext(ctx).Error(errCond, "Error while updating backup condition (backup failed)")
+		setCommandErr := func(backup *apiv1.Backup) {
+			backup.Status.CommandError = fmt.Sprintf("with stderr: %s, with stdout: %s", stderr, stdout)
 		}
-		return postgres.PatchBackupStatusAndRetry(ctx, client, backup)
+		return resourcestatus.FlagBackupAsFailed(ctx, client, backup, cluster, err, setCommandErr)
 	}
 
 	return nil
@@ -724,18 +707,3 @@ func (r *BackupReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manage
 	controllerBuilder = controllerBuilder.WithOptions(controller.Options{MaxConcurrentReconciles: 1})
 	return controllerBuilder.Complete(r)
 }
-
-func tryFlagBackupAsFailed(
-	ctx context.Context,
-	cli client.Client,
-	backup *apiv1.Backup,
-	err error,
-) {
-	contextLogger := log.FromContext(ctx)
-	origBackup := backup.DeepCopy()
-	backup.Status.SetAsFailed(err)
-
-	if err := cli.Status().Patch(ctx, backup, client.MergeFrom(origBackup)); err != nil {
-		contextLogger.Error(err, "while flagging backup as failed")
-	}
-}
diff --git a/pkg/management/postgres/backup.go b/pkg/management/postgres/backup.go
index 46648c4a..292d670e 100644
--- a/pkg/management/postgres/backup.go
+++ b/pkg/management/postgres/backup.go
@@ -35,7 +35,6 @@ import (
 	"github.com/cloudnative-pg/machinery/pkg/log"
 	pgTime "github.com/cloudnative-pg/machinery/pkg/postgres/time"
 	"k8s.io/apimachinery/pkg/api/equality"
-	"k8s.io/apimachinery/pkg/api/meta"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apimachinery/pkg/util/wait"
@@ -155,36 +154,11 @@ func (b *BackupCommand) run(ctx context.Context) {
 	)
 
 	if err := b.takeBackup(ctx); err != nil {
-		backupStatus := b.Backup.GetStatus()
-
 		// record the failure
 		b.Log.Error(err, "Backup failed")
 		b.Recorder.Event(b.Backup, "Normal", "Failed", "Backup failed")
 
-		// update backup status as failed
-		backupStatus.SetAsFailed(err)
-		if err := PatchBackupStatusAndRetry(ctx, b.Client, b.Backup); err != nil {
-			b.Log.Error(err, "Can't mark backup as failed")
-			// We do not terminate here because we still want to do the maintenance
-			// activity on the backups and to set the condition on the cluster.
-		}
-
-		// add backup failed condition to the cluster
-		if failErr := b.retryWithRefreshedCluster(ctx, func() error {
-			return status.PatchWithOptimisticLock(
-				ctx,
-				b.Client,
-				b.Cluster,
-				func(cluster *apiv1.Cluster) {
-					meta.SetStatusCondition(&cluster.Status.Conditions, apiv1.BuildClusterBackupFailedCondition(err))
-					cluster.Status.LastFailedBackup = pgTime.GetCurrentTimestampWithFormat(time.RFC3339)
-				},
-			)
-		}); failErr != nil {
-			b.Log.Error(failErr, "while setting cluster condition for failed backup")
-			// We do not terminate here because it's more important to properly handle
-			// the backup maintenance activity than putting a condition in the cluster
-		}
+		_ = status.FlagBackupAsFailed(ctx, b.Client, b.Backup, b.Cluster, err)
 	}
 
 	b.backupMaintenance(ctx)
diff --git a/pkg/management/postgres/webserver/plugin_backup.go b/pkg/management/postgres/webserver/plugin_backup.go
index 98e0415f..a51d25db 100644
--- a/pkg/management/postgres/webserver/plugin_backup.go
+++ b/pkg/management/postgres/webserver/plugin_backup.go
@@ -22,12 +22,9 @@ package webserver
 import (
 	"context"
 	"fmt"
-	"time"
 
 	"github.com/cloudnative-pg/machinery/pkg/log"
-	pgTime "github.com/cloudnative-pg/machinery/pkg/postgres/time"
 	"github.com/cloudnative-pg/machinery/pkg/stringset"
-	"k8s.io/apimachinery/pkg/api/meta"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/tools/record"
 	"k8s.io/utils/ptr"
@@ -166,33 +163,11 @@ func (b *PluginBackupCommand) invokeStart(ctx context.Context) {
 func (b *PluginBackupCommand) markBackupAsFailed(ctx context.Context, failure error) {
 	contextLogger := log.FromContext(ctx)
 
-	backupStatus := b.Backup.GetStatus()
-
 	// record the failure
 	contextLogger.Error(failure, "Backup failed")
 	b.Recorder.Event(b.Backup, "Normal", "Failed", "Backup failed")
 
-	// update backup status as failed
-	backupStatus.SetAsFailed(failure)
-	if err := postgres.PatchBackupStatusAndRetry(ctx, b.Client, b.Backup); err != nil {
-		contextLogger.Error(err, "Can't mark backup as failed")
-		// We do not terminate here because we still want to set the condition on the cluster.
-	}
-
-	// add backup failed condition to the cluster
-	if failErr := b.retryWithRefreshedCluster(ctx, func() error {
-		return status.PatchWithOptimisticLock(
-			ctx,
-			b.Client,
-			b.Cluster,
-			func(cluster *apiv1.Cluster) {
-				meta.SetStatusCondition(&cluster.Status.Conditions, apiv1.BuildClusterBackupFailedCondition(failure))
-				cluster.Status.LastFailedBackup = pgTime.GetCurrentTimestampWithFormat(time.RFC3339)
-			},
-		)
-	}); failErr != nil {
-		contextLogger.Error(failErr, "while setting cluster condition for failed backup")
-	}
+	_ = status.FlagBackupAsFailed(ctx, b.Client, b.Backup, b.Cluster, failure)
 }
 
 func (b *PluginBackupCommand) retryWithRefreshedCluster(
diff --git a/pkg/resources/status/backup.go b/pkg/resources/status/backup.go
new file mode 100644
index 00000000..8850e907
--- /dev/null
+++ b/pkg/resources/status/backup.go
@@ -0,0 +1,134 @@
+/*
+Copyright © contributors to CloudNativePG, established as
+CloudNativePG a Series of LF Projects, LLC.
+
+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.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package status
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/cloudnative-pg/machinery/pkg/log"
+	pgTime "github.com/cloudnative-pg/machinery/pkg/postgres/time"
+	"k8s.io/client-go/util/retry"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+)
+
+// BackupTransaction is a function that modifies a Backup object.
+type BackupTransaction func(*apiv1.Backup)
+
+type flagBackupErrors struct {
+	clusterStatusErr    error
+	backupErr           error
+	clusterConditionErr error
+}
+
+func (f flagBackupErrors) Error() string {
+	var message string
+	if f.clusterStatusErr != nil {
+		message += fmt.Sprintf("error patching cluster status: %v; ", f.clusterStatusErr)
+	}
+	if f.backupErr != nil {
+		message += fmt.Sprintf("error patching backup status: %v; ", f.backupErr)
+	}
+	if f.clusterConditionErr != nil {
+		message += fmt.Sprintf("error patching cluster conditions: %v; ", f.clusterConditionErr)
+	}
+
+	return message
+}
+
+// toError returns the errors encountered or nil
+func (f flagBackupErrors) toError() error {
+	if f.clusterStatusErr != nil || f.backupErr != nil || f.clusterConditionErr != nil {
+		return f
+	}
+	return nil
+}
+
+// FlagBackupAsFailed updates the status of a Backup object to indicate that it has failed.
+func FlagBackupAsFailed(
+	ctx context.Context,
+	cli client.Client,
+	backup *apiv1.Backup,
+	cluster *apiv1.Cluster,
+	err error,
+	transactions ...BackupTransaction,
+) error {
+	contextLogger := log.FromContext(ctx)
+
+	var flagErr flagBackupErrors
+
+	if err := retry.RetryOnConflict(retry.DefaultBackoff, func() error {
+		var livingBackup apiv1.Backup
+		if err := cli.Get(ctx, client.ObjectKeyFromObject(backup), &livingBackup); err != nil {
+			contextLogger.Error(err, "failed to get backup")
+			return err
+		}
+		origBackup := livingBackup.DeepCopy()
+		livingBackup.Status.SetAsFailed(err)
+		livingBackup.Status.Method = livingBackup.Spec.Method
+		for _, transaction := range transactions {
+			transaction(&livingBackup)
+		}
+
+		err := cli.Status().Patch(ctx, &livingBackup, client.MergeFrom(origBackup))
+		if err != nil {
+			contextLogger.Error(err, "while patching backup status")
+			return err
+		}
+		// we mutate the original object
+		backup.Status = livingBackup.Status
+
+		return nil
+	}); err != nil {
+		contextLogger.Error(err, "while flagging backup as failed")
+		flagErr.backupErr = err
+	}
+
+	if cluster == nil {
+		return flagErr.toError()
+	}
+
+	if err := PatchWithOptimisticLock(
+		ctx,
+		cli,
+		cluster,
+		func(cluster *apiv1.Cluster) {
+			cluster.Status.LastFailedBackup = pgTime.GetCurrentTimestampWithFormat(time.RFC3339)
+		},
+	); err != nil {
+		contextLogger.Error(err, "while patching cluster status with last failed backup")
+		flagErr.clusterStatusErr = err
+	}
+
+	if err := PatchConditionsWithOptimisticLock(
+		ctx,
+		cli,
+		cluster,
+		apiv1.BuildClusterBackupFailedCondition(err),
+	); err != nil {
+		contextLogger.Error(err, "while patching backup condition in the cluster status (backup failed)")
+		flagErr.clusterConditionErr = err
+	}
+
+	return flagErr.toError()
+}



Test patch:
diff --git a/pkg/resources/status/backup_test.go b/pkg/resources/status/backup_test.go
new file mode 100644
index 00000000..3727b483
--- /dev/null
+++ b/pkg/resources/status/backup_test.go
@@ -0,0 +1,83 @@
+/*
+Copyright © contributors to CloudNativePG, established as
+CloudNativePG a Series of LF Projects, LLC.
+
+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.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package status
+
+import (
+	"errors"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+	schemeBuilder "github.com/cloudnative-pg/cloudnative-pg/internal/scheme"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+var _ = Describe("FlagBackupAsFailed", func() {
+	scheme := schemeBuilder.BuildWithAllKnownScheme()
+	k8sClient := fake.NewClientBuilder().WithScheme(scheme).
+		WithStatusSubresource(&apiv1.Cluster{}, &apiv1.Backup{}).
+		Build()
+
+	It("selects the new target primary right away", func(ctx SpecContext) {
+		cluster := &apiv1.Cluster{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "cluster-example",
+				Namespace: "default",
+			},
+		}
+
+		backup := &apiv1.Backup{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      cluster.Name,
+				Namespace: cluster.Namespace,
+			},
+			Spec: apiv1.BackupSpec{
+				Cluster: apiv1.LocalObjectReference{
+					Name: cluster.Name,
+				},
+			},
+			Status: apiv1.BackupStatus{
+				Phase: apiv1.BackupPhaseRunning,
+			},
+		}
+		Expect(k8sClient.Create(ctx, cluster)).To(Succeed())
+		Expect(k8sClient.Create(ctx, backup)).To(Succeed())
+
+		err := FlagBackupAsFailed(ctx, k8sClient, backup, cluster, errors.New("my sample error"))
+		Expect(err).NotTo(HaveOccurred())
+
+		// Backup status assertions
+		Expect(backup.Status.Phase).To(BeEquivalentTo(apiv1.BackupPhaseFailed))
+		Expect(backup.Status.Error).To(BeEquivalentTo("my sample error"))
+
+		// Cluster status assertions
+		Expect(cluster.Status.LastFailedBackup).ToNot(BeEmpty())
+		for _, condition := range cluster.Status.Conditions {
+			if condition.Type == string(apiv1.ConditionBackup) {
+				Expect(condition.Status).To(BeEquivalentTo(metav1.ConditionFalse))
+				Expect(condition.Reason).To(BeEquivalentTo(string(apiv1.ConditionReasonLastBackupFailed)))
+				Expect(condition.Message).To(BeEquivalentTo("my sample error"))
+			}
+		}
+	})
+})
diff --git a/pkg/resources/status/suite_test.go b/pkg/resources/status/suite_test.go
new file mode 100644
index 00000000..7ef3812e
--- /dev/null
+++ b/pkg/resources/status/suite_test.go
@@ -0,0 +1,32 @@
+/*
+Copyright © contributors to CloudNativePG, established as
+CloudNativePG a Series of LF Projects, LLC.
+
+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.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package status
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+func TestConfiguration(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Internal Configuration Test Suite")
+}