@@ -4,16 +4,20 @@ import (
44 "fmt"
55 "reflect"
66 "slices"
7+ "strings"
78
89 cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
910 "github.com/go-test/deep"
11+ storagesnapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1"
1012 nbv1 "github.com/noobaa/noobaa-operator/v5/pkg/apis/noobaa/v1alpha1"
1113 "github.com/noobaa/noobaa-operator/v5/pkg/cnpg"
1214 "github.com/noobaa/noobaa-operator/v5/pkg/options"
1315 "github.com/noobaa/noobaa-operator/v5/pkg/util"
1416 corev1 "k8s.io/api/core/v1"
17+ "k8s.io/apimachinery/pkg/api/errors"
1518 "k8s.io/apimachinery/pkg/api/resource"
1619 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
20+ "sigs.k8s.io/controller-runtime/pkg/client"
1721)
1822
1923const (
@@ -94,6 +98,12 @@ func (r *Reconciler) ReconcileCNPGCluster() error {
9498 return fmt .Errorf ("cnpg cluster is not ready" )
9599 }
96100
101+ // Reconcile backup configuration
102+ if err := r .reconcileDBBackup (); err != nil {
103+ r .cnpgLogError ("got error reconciling backup. error: %v" , err )
104+ return err
105+ }
106+
97107 return nil
98108}
99109
@@ -268,6 +278,28 @@ func (r *Reconciler) reconcileClusterSpec(dbSpec *nbv1.NooBaaDBSpec) error {
268278
269279 r .setPostgresConfig ()
270280
281+ // Configure backup settings if specified
282+ if backupSpec := r .NooBaa .Spec .DBSpec .DBBackup ; backupSpec != nil {
283+ if backupSpec .VolumeSnapshot == nil {
284+ r .cnpgLogError ("volume snapshot backup configuration is not specified" )
285+ return fmt .Errorf ("volume snapshot backup configuration is not specified" )
286+ }
287+ offlineBackup := false
288+ if r .CNPGCluster .Spec .Backup == nil {
289+ r .CNPGCluster .Spec .Backup = & cnpgv1.BackupConfiguration {
290+ VolumeSnapshot : & cnpgv1.VolumeSnapshotConfiguration {
291+ ClassName : backupSpec .VolumeSnapshot .VolumeSnapshotClass ,
292+ Online : & offlineBackup ,
293+ },
294+ }
295+ } else {
296+ r .CNPGCluster .Spec .Backup .VolumeSnapshot .ClassName = backupSpec .VolumeSnapshot .VolumeSnapshotClass
297+ }
298+ } else {
299+ // Remove backup configuration if not specified
300+ r .CNPGCluster .Spec .Backup = nil
301+ }
302+
271303 // TODO: consider specifying a separate WAL storage configuration in Spec.WalStorage
272304 // currently, the same storage will be used for both DB and WAL
273305
@@ -374,6 +406,170 @@ func (r *Reconciler) reconcileClusterCreateOrImport() error {
374406
375407}
376408
409+ // reconcileDBBackup reconciles the backup configuration for the CNPG cluster
410+ func (r * Reconciler ) reconcileDBBackup () error {
411+ if r .NooBaa .Spec .DBSpec .DBBackup == nil {
412+ // Clean up existing backup resources if any
413+ if err := r .cleanupDBBackup (); err != nil {
414+ r .cnpgLogError ("got error cleaning up existing backup resources. error: %v" , err )
415+ return err
416+ }
417+ r .NooBaa .Status .DBStatus .BackupStatus = nil
418+ return nil
419+ }
420+
421+ if r .NooBaa .Status .DBStatus .BackupStatus == nil {
422+ r .NooBaa .Status .DBStatus .BackupStatus = & nbv1.DBBackupStatus {}
423+ }
424+
425+ // currently only volume snapshot backup is supported. VolumeSnapshot is required.
426+ if r .NooBaa .Spec .DBSpec .DBBackup .VolumeSnapshot == nil {
427+ r .cnpgLogError ("volume snapshot backup configuration is not specified" )
428+ return fmt .Errorf ("volume snapshot backup configuration is not specified" )
429+ }
430+
431+ // Create or update ScheduledBackup
432+ if err := r .reconcileScheduledBackup (); err != nil {
433+ r .cnpgLogError ("got error reconciling scheduled backup. error: %v" , err )
434+ return err
435+ }
436+
437+ // reconcile the backup retention
438+ if err := r .reconcileBackupRetention (); err != nil {
439+ r .cnpgLogError ("got error reconciling backup retention. error: %v" , err )
440+ return err
441+ }
442+
443+ return nil
444+ }
445+
446+ func (r * Reconciler ) getBackupResourceName () string {
447+ return r .CNPGCluster .Name + "-backup"
448+ }
449+
450+ func (r * Reconciler ) reconcileScheduledBackup () error {
451+ backupSpec := r .NooBaa .Spec .DBSpec .DBBackup
452+ offlineBackup := false
453+ // convert the standard cron schedule to the cnpg cron schedule
454+ cnpgSchedule , err := convertToSixFieldCron (backupSpec .Schedule )
455+ if err != nil {
456+ r .cnpgLogError ("failed to convert cron schedule to six-field format. error: %v" , err )
457+ return err
458+ }
459+ scheduledBackup := cnpg .GetCnpgScheduledBackupObj (r .CNPGCluster .Namespace , r .getBackupResourceName ())
460+
461+ return r .ReconcileObject (scheduledBackup , func () error {
462+ // Update spec if needed
463+ scheduledBackup .Spec .Schedule = cnpgSchedule
464+ scheduledBackup .Spec .Cluster .Name = r .CNPGCluster .Name
465+ scheduledBackup .Spec .Method = cnpgv1 .BackupMethodVolumeSnapshot
466+ scheduledBackup .Spec .Online = & offlineBackup
467+ if scheduledBackup .Status .LastScheduleTime != nil {
468+ r .NooBaa .Status .DBStatus .BackupStatus .LastBackupTime = scheduledBackup .Status .LastScheduleTime
469+ }
470+ if scheduledBackup .Status .NextScheduleTime != nil {
471+ r .NooBaa .Status .DBStatus .BackupStatus .NextBackupTime = scheduledBackup .Status .NextScheduleTime
472+ }
473+ return nil
474+ })
475+ }
476+
477+ func (r * Reconciler ) reconcileBackupRetention () error {
478+ maxSnapshots := r .NooBaa .Spec .DBSpec .DBBackup .VolumeSnapshot .MaxSnapshots
479+ if maxSnapshots == 0 {
480+ r .cnpgLog ("backup retention is not specified, skipping" )
481+ return nil
482+ }
483+
484+ // list all backupResources created as part of the scheduled backup
485+ backups , err := r .listBackupResourcesOrderByCreate ()
486+ if err != nil {
487+ r .cnpgLogError ("got error listing cluster volume snapshots. error: %v" , err )
488+ return err
489+ }
490+ totalSnapshots := len (backups .Items )
491+ avaialableItems := backups .Items
492+ if totalSnapshots > maxSnapshots {
493+ numToDelete := totalSnapshots - maxSnapshots
494+ avaialableItems = avaialableItems [numToDelete :]
495+ r .cnpgLog ("found %d backups, maxSnapshots is %d, deleting the oldest %d backups" , totalSnapshots , maxSnapshots , numToDelete )
496+ // delete the oldest backups
497+ for _ , backup := range backups .Items [:numToDelete ] {
498+ snapshotName := backup .Name
499+ if len (backup .Status .BackupSnapshotStatus .Elements ) > 0 {
500+ // although the expected snapshot name is the same as the backup name, take it from the backup status to be on the safe side
501+ snapshotName = backup .Status .BackupSnapshotStatus .Elements [0 ].Name
502+ }
503+
504+ r .cnpgLog ("deleting snapshot %s" , snapshotName )
505+ // if encountering an error we only report it and continue with the reconciliation
506+ if err := r .Client .Delete (r .Ctx , & storagesnapshotv1.VolumeSnapshot {
507+ ObjectMeta : metav1.ObjectMeta {
508+ Name : snapshotName ,
509+ Namespace : r .CNPGCluster .Namespace ,
510+ },
511+ }); err != nil {
512+ r .cnpgLogError ("got error deleting snapshot %s. error: %v" , snapshotName , err )
513+ // skipping the deletion of the backup so we can try to delete again next time
514+ continue
515+ }
516+
517+ r .cnpgLog ("deleting backup %s" , backup .Name )
518+ if err := r .Client .Delete (r .Ctx , & backup ); err != nil {
519+ r .cnpgLogError ("got error deleting backup %s. error: %v" , backup .Name , err )
520+ continue
521+ }
522+ totalSnapshots --
523+ }
524+ }
525+
526+ // update the backup status
527+ r .NooBaa .Status .DBStatus .BackupStatus .TotalSnapshots = totalSnapshots
528+ avaiallableSnapshots := []string {}
529+ for _ , item := range avaialableItems {
530+ avaiallableSnapshots = append (avaiallableSnapshots , item .Name )
531+ }
532+ r .NooBaa .Status .DBStatus .BackupStatus .AvailableSnapshots = avaiallableSnapshots
533+
534+ return nil
535+ }
536+
537+ func (r * Reconciler ) listBackupResourcesOrderByCreate () (* cnpgv1.BackupList , error ) {
538+ backupResources := cnpg .GetCnpgBackupListObj (r .CNPGCluster .Namespace )
539+ // list all backup resources having the label cnpg.io/scheduled-backup: <scheduled-backup-name>
540+ if err := r .Client .List (r .Ctx , backupResources , client .InNamespace (r .CNPGCluster .Namespace ), client.MatchingLabels {
541+ "cnpg.io/scheduled-backup" : r .getBackupResourceName (),
542+ }); err != nil {
543+ r .cnpgLogError ("got error listing backup resources. error: %v" , err )
544+ return nil , err
545+ }
546+ // filter to only include completed backups (MatchingFields requires field index which isn't configured)
547+ filteredItems := []cnpgv1.Backup {}
548+ for _ , backup := range backupResources .Items {
549+ if backup .Status .Phase == cnpgv1 .BackupPhaseCompleted {
550+ filteredItems = append (filteredItems , backup )
551+ }
552+ }
553+ // sort the list by creation timestamp
554+ slices .SortFunc (filteredItems , func (a , b cnpgv1.Backup ) int {
555+ return a .CreationTimestamp .Compare (b .CreationTimestamp .Time )
556+ })
557+ backupResources .Items = filteredItems
558+ return backupResources , nil
559+ }
560+
561+ func (r * Reconciler ) cleanupDBBackup () error {
562+ scheduledBackup := cnpg .GetCnpgScheduledBackupObj (r .CNPGCluster .Namespace , r .getBackupResourceName ())
563+
564+ if util .KubeCheckQuiet (scheduledBackup ) {
565+ r .cnpgLog ("removing ScheduledBackup %s" , scheduledBackup .Name )
566+ if err := r .Client .Delete (r .Ctx , scheduledBackup ); err != nil && ! errors .IsNotFound (err ) {
567+ return err
568+ }
569+ }
570+ return nil
571+ }
572+
377573func (r * Reconciler ) setPostgresConfig () {
378574
379575 // set postgresql configuration
@@ -515,11 +711,35 @@ func (r *Reconciler) cnpgLogError(format string, args ...interface{}) {
515711 r .Logger .Errorf ("cnpg:: " + format , args ... )
516712}
517713
714+ // convertToSixFieldCron converts a cron schedule to six-field format.
715+ // cnpg requires the schedule to be in six-field format, so we need to convert the standard cron schedule to six-field format.
716+ func convertToSixFieldCron (schedule string ) (string , error ) {
717+ // If it starts with @, it's a descriptor or interval - return as-is
718+ if len (schedule ) > 0 && schedule [0 ] == '@' {
719+ return schedule , nil
720+ }
721+
722+ // Count the number of space-separated fields
723+ fields := strings .Fields (schedule )
724+
725+ // If already 6 fields, return as-is
726+ if len (fields ) == 6 {
727+ return schedule , nil
728+ }
729+
730+ // If 5 fields (standard cron), prepend "0" for seconds
731+ if len (fields ) == 5 {
732+ return "0 " + schedule , nil
733+ }
734+
735+ // If not 5 or 6 fields, return an error
736+ return "" , fmt .Errorf ("invalid cron schedule %q" , schedule )
737+ }
738+
518739// wasClusterSpecChanged checks if any of the cluster spec fields that matter for us were changed.
519740// This need to be updated if we change more fields in the cluster spec.
520741// For some reason reflect.DeepEqual always returns false when comparing the entire spec.
521742func (r * Reconciler ) wasClusterSpecChanged (existingClusterSpec * cnpgv1.ClusterSpec ) bool {
522-
523743 return ! reflect .DeepEqual (existingClusterSpec .InheritedMetadata , r .CNPGCluster .Spec .InheritedMetadata ) ||
524744 ! reflect .DeepEqual (existingClusterSpec .ImageCatalogRef , r .CNPGCluster .Spec .ImageCatalogRef ) ||
525745 existingClusterSpec .Instances != r .CNPGCluster .Spec .Instances ||
@@ -528,5 +748,6 @@ func (r *Reconciler) wasClusterSpecChanged(existingClusterSpec *cnpgv1.ClusterSp
528748 ! reflect .DeepEqual (existingClusterSpec .StorageConfiguration .StorageClass , r .CNPGCluster .Spec .StorageConfiguration .StorageClass ) ||
529749 ! reflect .DeepEqual (existingClusterSpec .StorageConfiguration .Size , r .CNPGCluster .Spec .StorageConfiguration .Size ) ||
530750 ! reflect .DeepEqual (existingClusterSpec .Monitoring , r .CNPGCluster .Spec .Monitoring ) ||
531- ! reflect .DeepEqual (existingClusterSpec .PostgresConfiguration .Parameters , r .CNPGCluster .Spec .PostgresConfiguration .Parameters )
751+ ! reflect .DeepEqual (existingClusterSpec .PostgresConfiguration .Parameters , r .CNPGCluster .Spec .PostgresConfiguration .Parameters ) ||
752+ ! reflect .DeepEqual (existingClusterSpec .Backup , r .CNPGCluster .Spec .Backup )
532753}
0 commit comments