@@ -641,11 +641,11 @@ func TestCNPGClusterFreshlyRecovered(t *testing.T) {
641641 want : false ,
642642 },
643643 {
644- name : "recovery cluster created exactly at start: fresh (boundary )" ,
644+ name : "recovery cluster created exactly at start: not fresh (conservative tie -> purge )" ,
645645 hasRecovery : true ,
646646 createdAt : & started ,
647647 startedAt : & started ,
648- want : true ,
648+ want : false ,
649649 },
650650 {
651651 name : "missing cluster creation timestamp: not fresh" ,
@@ -1598,6 +1598,149 @@ func TestReconcileCNPGRestore_MissingStrategyFailsClosedAfterDeadline(t *testing
15981598 }
15991599}
16001600
1601+ // TestReconcileCNPGRestore_RepeatInPlacePurgesStaleRecoveryCluster is the
1602+ // reconcile-level regression test for #3311. The pure-function tests
1603+ // (TestCNPGPurgeNeeded / TestCNPGClusterFreshlyRecovered) lock in the truth
1604+ // table, but the bug lived at the call site: a repeat in-place restore fed the
1605+ // purge decision the wrong signal and skipped the destructive purge, so the
1606+ // job reported Succeeded against untouched data. This drives the real
1607+ // reconcileCNPGRestore path with a fake client and asserts the destructive
1608+ // purge actually fires for a stale leftover recovery Cluster - and, in the
1609+ // mirror case, that a Cluster this restore just re-created is NOT re-purged
1610+ // (the status-write-race protection the guard was originally built for).
1611+ func TestReconcileCNPGRestore_RepeatInPlacePurgesStaleRecoveryCluster (t * testing.T ) {
1612+ const (
1613+ ns = "tenant"
1614+ appName = "app"
1615+ clusterName = "postgres-app"
1616+ cnpgBkName = "cnpgbk"
1617+ )
1618+ apiGroup := backupsv1alpha1 .DefaultApplicationAPIGroup
1619+ strategyGroup := strategyv1alpha1 .GroupVersion .Group
1620+ ctx := context .Background ()
1621+
1622+ // startedAt anchors the discriminator: a stale leftover Cluster predates
1623+ // it, a freshly-recovered one postdates it.
1624+ startedAt := metav1 .NewTime (time .Now ())
1625+
1626+ mkBackupArtifact := func (t * testing.T ) * backupsv1alpha1.Backup {
1627+ t .Helper ()
1628+ snap , err := marshalCNPGBackupSnapshot (newPostgresApp (appName , ns ), nil )
1629+ if err != nil {
1630+ t .Fatalf ("marshal snapshot: %v" , err )
1631+ }
1632+ return & backupsv1alpha1.Backup {
1633+ ObjectMeta : metav1.ObjectMeta {Namespace : ns , Name : "bk" },
1634+ Spec : backupsv1alpha1.BackupSpec {
1635+ ApplicationRef : corev1.TypedLocalObjectReference {APIGroup : & apiGroup , Kind : postgresAppKind , Name : appName },
1636+ StrategyRef : corev1.TypedLocalObjectReference {APIGroup : & strategyGroup , Kind : strategyv1alpha1 .CNPGStrategyKind , Name : "cnpg-strategy" },
1637+ DriverMetadata : map [string ]string {
1638+ cnpgServerNameKey : appName ,
1639+ cnpgDestinationPathKey : "s3://bucket/" + appName + "/" ,
1640+ cnpgBackupNameKey : cnpgBkName ,
1641+ },
1642+ },
1643+ Status : backupsv1alpha1.BackupStatus {UnderlyingResources : snap },
1644+ }
1645+ }
1646+ strategy := & strategyv1alpha1.CNPG {
1647+ ObjectMeta : metav1.ObjectMeta {Name : "cnpg-strategy" },
1648+ Spec : strategyv1alpha1.CNPGSpec {
1649+ Template : strategyv1alpha1.CNPGTemplate {
1650+ BarmanObjectStore : strategyv1alpha1.BarmanObjectStoreTemplate {DestinationPath : "s3://bucket/" },
1651+ },
1652+ },
1653+ }
1654+ // A completed cnpg.io/Backup with endWal set clears the WAL-archive gate
1655+ // so the reconcile can reach the purge step.
1656+ cnpgBackup := & cnpgtypes.Backup {
1657+ ObjectMeta : metav1.ObjectMeta {Namespace : ns , Name : cnpgBkName },
1658+ Spec : cnpgtypes.BackupSpec {Cluster : cnpgtypes.ClusterReference {Name : clusterName }},
1659+ Status : cnpgtypes.BackupStatus {Phase : cnpgBackupPhaseComplete , EndWal : "000000010000000000000003" },
1660+ }
1661+ mkRecoveryCluster := func (created metav1.Time ) * cnpgtypes.Cluster {
1662+ return & cnpgtypes.Cluster {
1663+ ObjectMeta : metav1.ObjectMeta {Namespace : ns , Name : clusterName , CreationTimestamp : created },
1664+ Spec : cnpgtypes.ClusterSpec {
1665+ Bootstrap : & cnpgtypes.BootstrapConfiguration {
1666+ Recovery : & cnpgtypes.RecoverySource {Source : appName },
1667+ },
1668+ },
1669+ }
1670+ }
1671+ mkClusterPVC := func () * corev1.PersistentVolumeClaim {
1672+ return & corev1.PersistentVolumeClaim {
1673+ ObjectMeta : metav1.ObjectMeta {Namespace : ns , Name : clusterName + "-1" , Labels : map [string ]string {cnpgClusterLabel : clusterName }},
1674+ }
1675+ }
1676+ mkRestoreJob := func () * backupsv1alpha1.RestoreJob {
1677+ sa := startedAt
1678+ return & backupsv1alpha1.RestoreJob {
1679+ ObjectMeta : metav1.ObjectMeta {Namespace : ns , Name : "rj" },
1680+ Spec : backupsv1alpha1.RestoreJobSpec {BackupRef : corev1.LocalObjectReference {Name : "bk" }},
1681+ Status : backupsv1alpha1.RestoreJobStatus {StartedAt : & sa , Phase : backupsv1alpha1 .RestoreJobPhaseRunning },
1682+ }
1683+ }
1684+
1685+ t .Run ("stale leftover recovery cluster from a prior restore is purged" , func (t * testing.T ) {
1686+ backup := mkBackupArtifact (t )
1687+ // creationTimestamp an hour before StartedAt: leftover from a prior restore.
1688+ stale := metav1 .NewTime (startedAt .Add (- time .Hour ))
1689+ c := newCNPGStrategyTestClient (t , backup , mkRestoreJob (), strategy , cnpgBackup ,
1690+ newPostgresApp (appName , ns ), mkRecoveryCluster (stale ), mkClusterPVC ())
1691+ r := & RestoreJobReconciler {Client : c , Interface : dynamicfake .NewSimpleDynamicClient (testCNPGScheme (t ))}
1692+
1693+ rj := & backupsv1alpha1.RestoreJob {}
1694+ if err := c .Get (ctx , client.ObjectKey {Namespace : ns , Name : "rj" }, rj ); err != nil {
1695+ t .Fatalf ("get seeded RestoreJob: %v" , err )
1696+ }
1697+ if _ , err := r .reconcileCNPGRestore (ctx , rj , backup ); err != nil {
1698+ t .Fatalf ("reconcileCNPGRestore: %v" , err )
1699+ }
1700+
1701+ // The stale Cluster (and its labelled PVC) must have been purged.
1702+ err := c .Get (ctx , client.ObjectKey {Namespace : ns , Name : clusterName }, & cnpgtypes.Cluster {})
1703+ if ! apierrors .IsNotFound (err ) {
1704+ t .Fatalf ("expected stale Cluster to be purged (NotFound), got err=%v" , err )
1705+ }
1706+ pvcs := & corev1.PersistentVolumeClaimList {}
1707+ if err := c .List (ctx , pvcs , client .InNamespace (ns ), client.MatchingLabels {cnpgClusterLabel : clusterName }); err != nil {
1708+ t .Fatalf ("list PVCs: %v" , err )
1709+ }
1710+ if len (pvcs .Items ) != 0 {
1711+ t .Fatalf ("expected cluster PVCs purged, still have %d" , len (pvcs .Items ))
1712+ }
1713+ })
1714+
1715+ t .Run ("freshly-recovered cluster from this restore is not re-purged" , func (t * testing.T ) {
1716+ backup := mkBackupArtifact (t )
1717+ // creationTimestamp a minute after StartedAt: this restore's own re-render.
1718+ fresh := metav1 .NewTime (startedAt .Add (time .Minute ))
1719+ c := newCNPGStrategyTestClient (t , backup , mkRestoreJob (), strategy , cnpgBackup ,
1720+ newPostgresApp (appName , ns ), mkRecoveryCluster (fresh ))
1721+ r := & RestoreJobReconciler {Client : c , Interface : dynamicfake .NewSimpleDynamicClient (testCNPGScheme (t ))}
1722+
1723+ rj := & backupsv1alpha1.RestoreJob {}
1724+ if err := c .Get (ctx , client.ObjectKey {Namespace : ns , Name : "rj" }, rj ); err != nil {
1725+ t .Fatalf ("get seeded RestoreJob: %v" , err )
1726+ }
1727+ if _ , err := r .reconcileCNPGRestore (ctx , rj , backup ); err != nil {
1728+ t .Fatalf ("reconcileCNPGRestore: %v" , err )
1729+ }
1730+
1731+ // The freshly-recovered Cluster must survive - re-purging it would
1732+ // destroy the recovery this restore just started (the status-write-race
1733+ // protection the guard was built for).
1734+ got := & cnpgtypes.Cluster {}
1735+ if err := c .Get (ctx , client.ObjectKey {Namespace : ns , Name : clusterName }, got ); err != nil {
1736+ t .Fatalf ("expected freshly-recovered Cluster to survive, got err=%v" , err )
1737+ }
1738+ if ! got .DeletionTimestamp .IsZero () {
1739+ t .Fatalf ("freshly-recovered Cluster must not be marked for deletion" )
1740+ }
1741+ })
1742+ }
1743+
16011744// newCNPGStrategyTestClient returns a fake client.Client wired up with
16021745// the schemes the CNPG-strategy reconciler needs.
16031746func newCNPGStrategyTestClient (t * testing.T , objs ... client.Object ) client.Client {
0 commit comments