@@ -20,8 +20,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
2020package operations
2121
2222import (
23+ "context"
2324 "encoding/json"
2425 "fmt"
26+ "strings"
2527 "time"
2628
2729 corev1 "k8s.io/api/core/v1"
@@ -44,6 +46,11 @@ type RestoreOpsHandler struct{}
4446
4547var _ OpsHandler = RestoreOpsHandler {}
4648
49+ const (
50+ restoreFailureGateRequeueAfter = 30 * time .Second
51+ restoreCRPreCreateFailureTimeout = 5 * time .Minute
52+ )
53+
4754func init () {
4855 // register restore operation, it will create a new cluster
4956 // so set IsClusterCreationEnabled to true
@@ -102,10 +109,28 @@ func (r RestoreOpsHandler) Action(reqCtx intctrlutil.RequestCtx, cli client.Clie
102109}
103110
104111// ReconcileAction implements the restore action.
105- // It will check the cluster status and update the OpsRequest status.
106- // If the cluster is running, it will update the OpsRequest status to Complete.
107- // If the cluster is failed, it will update the OpsRequest status to Failed.
108- // If the cluster is not running, it will update the OpsRequest status to Running.
112+ //
113+ // Failure-gate ordering:
114+ //
115+ // 1. cluster.Status.Phase == Running -> OpsSucceedPhase. The cluster
116+ // Running phase is authoritative for restore success even when a
117+ // Restore CR is still in progress.
118+ // 2. cluster.IsDeleting() -> OpsFailedPhase. A deleting cluster is a
119+ // terminal restore failure regardless of Restore CR state.
120+ // 3. cluster.Status.Phase == Failed -> consult Restore CRs through the
121+ // restore-aware failure gate. A transiently Failed cluster while
122+ // Restore CRs are still running is not a terminal restore failure;
123+ // it can recover once the restore workflow completes. The failure
124+ // gate distinguishes:
125+ // - any Restore CR Failed: terminal restore failure.
126+ // - all Restore CRs Completed but cluster still Failed: terminal
127+ // restore failure.
128+ // - some Restore CR still in progress: keep OpsRequest Running.
129+ // - empty Restore CR list: short pre-create window only; after the
130+ // bounded window expires it is a terminal restore failure.
131+ // - Restore CR list API error: non-terminal explicit error,
132+ // controller-runtime will requeue.
133+ // 4. otherwise -> OpsRunningPhase.
109134func (r RestoreOpsHandler ) ReconcileAction (reqCtx intctrlutil.RequestCtx , cli client.Client , opsRes * OpsResource ) (opsv1alpha1.OpsPhase , time.Duration , error ) {
110135 opsRequest := opsRes .OpsRequest
111136 clusterDef := opsRequest .Spec .GetClusterName ()
@@ -122,15 +147,142 @@ func (r RestoreOpsHandler) ReconcileAction(reqCtx intctrlutil.RequestCtx, cli cl
122147 return opsv1alpha1 .OpsFailedPhase , 0 , err
123148 }
124149 opsRes .Cluster = cluster
125- // check if the cluster is running
150+
151+ // Step 1: cluster Running -> Succeed (success contract preserved).
126152 if cluster .Status .Phase == appsv1 .RunningClusterPhase {
127153 return opsv1alpha1 .OpsSucceedPhase , 0 , nil
128- } else if cluster .Status .Phase == appsv1 .FailedClusterPhase || cluster .IsDeleting () {
154+ }
155+ // Step 2: cluster IsDeleting -> Failed (existing deleting contract).
156+ if cluster .IsDeleting () {
129157 return opsv1alpha1 .OpsFailedPhase , 0 , fmt .Errorf ("restore failed" )
130158 }
159+ // Step 3: cluster Failed -> D restore-semantic-aware failure gate.
160+ if cluster .Status .Phase == appsv1 .FailedClusterPhase {
161+ restoreCRs , listErr := r .listRestoreCRsForRestoreOps (reqCtx .Ctx , cli , opsRequest , cluster )
162+ if listErr != nil {
163+ // List API error: non-terminal + explicit error so the controller
164+ // re-queues loudly. Avoids silent allow.
165+ return opsv1alpha1 .OpsRunningPhase , 0 , fmt .Errorf ("list Restore CRs for restore failure gate failed: %w" , listErr )
166+ }
167+ if len (restoreCRs ) == 0 {
168+ if r .restorePreCreateWindowExpired (opsRequest ) {
169+ return opsv1alpha1 .OpsFailedPhase , 0 , fmt .Errorf ("restore failed: no Restore CRs found within %s after OpsRequest started" , restoreCRPreCreateFailureTimeout )
170+ }
171+ // Empty list: possibly a normal pre-create window. Non-terminal
172+ // retry with bounded backoff plus an explicit log so the state is
173+ // observable. Not silent Running, not terminal Failed.
174+ reqCtx .Log .Info ("restore failure-gate: cluster Failed but no Restore CRs found yet; requeue" ,
175+ "cluster" , cluster .Name , "namespace" , cluster .Namespace , "timeout" , restoreCRPreCreateFailureTimeout .String ())
176+ return opsv1alpha1 .OpsRunningPhase , restoreFailureGateRequeueAfter , nil
177+ }
178+ anyRestoreFailed := false
179+ allRestoresCompleted := true
180+ for i := range restoreCRs {
181+ switch restoreCRs [i ].Status .Phase {
182+ case dpv1alpha1 .RestorePhaseFailed :
183+ anyRestoreFailed = true
184+ case dpv1alpha1 .RestorePhaseCompleted :
185+ // counts toward all-completed
186+ default :
187+ // Running, AsDataSource, or empty -> not yet terminal-completed.
188+ allRestoresCompleted = false
189+ }
190+ }
191+ if anyRestoreFailed {
192+ return opsv1alpha1 .OpsFailedPhase , 0 , fmt .Errorf ("restore failed" )
193+ }
194+ if allRestoresCompleted {
195+ return opsv1alpha1 .OpsFailedPhase , 0 , fmt .Errorf ("restore failed" )
196+ }
197+ // Race case: at least one Restore CR is still in progress; cluster
198+ // transient Failed during restore is NOT a terminal restore failure.
199+ return opsv1alpha1 .OpsRunningPhase , 0 , nil
200+ }
201+ // Step 4: other cluster phases -> Running.
131202 return opsv1alpha1 .OpsRunningPhase , 0 , nil
132203}
133204
205+ // listRestoreCRsForRestoreOps returns the Restore CRs that belong to the
206+ // cluster being restored by this OpsRequest. It is used by the restore
207+ // failure gate to distinguish "in-progress restore" from "terminal restore
208+ // failure" when the cluster Status.Phase is Failed.
209+ //
210+ // Lookup convention: same namespace as the cluster, first label-selected by
211+ // `app.kubernetes.io/instance=<cluster.Name>`, then scoped to the current
212+ // restore run. Current Restore CRs generated from the restore annotation carry
213+ // the current cluster UID prefix as a hyphen-delimited name segment. The start
214+ // timestamp guard prevents older Restore CRs for the same cluster name from
215+ // deciding the current OpsRequest outcome.
216+ func (r RestoreOpsHandler ) listRestoreCRsForRestoreOps (
217+ ctx context.Context ,
218+ cli client.Client ,
219+ opsRequest * opsv1alpha1.OpsRequest ,
220+ cluster * appsv1.Cluster ,
221+ ) ([]dpv1alpha1.Restore , error ) {
222+ restoreList := & dpv1alpha1.RestoreList {}
223+ if err := cli .List (ctx , restoreList ,
224+ client .InNamespace (cluster .Namespace ),
225+ client.MatchingLabels {constant .AppInstanceLabelKey : cluster .Name },
226+ ); err != nil {
227+ return nil , err
228+ }
229+ restoreCRs := make ([]dpv1alpha1.Restore , 0 , len (restoreList .Items ))
230+ for i := range restoreList .Items {
231+ if r .restoreCRBelongsToRestoreOps (& restoreList .Items [i ], opsRequest , cluster ) {
232+ restoreCRs = append (restoreCRs , restoreList .Items [i ])
233+ }
234+ }
235+ return restoreCRs , nil
236+ }
237+
238+ func (r RestoreOpsHandler ) restoreCRBelongsToRestoreOps (
239+ restoreCR * dpv1alpha1.Restore ,
240+ opsRequest * opsv1alpha1.OpsRequest ,
241+ cluster * appsv1.Cluster ,
242+ ) bool {
243+ if opsName := restoreCR .Labels [constant .OpsRequestNameLabelKey ]; opsName != "" {
244+ return opsName == opsRequest .Name
245+ }
246+ if ! restoreNameContainsClusterUIDPrefix (restoreCR .Name , cluster ) {
247+ return false
248+ }
249+ if startTime , ok := restoreFailureGateStartTime (opsRequest ); ok {
250+ start := metav1.Time {Time : startTime }
251+ if restoreCR .CreationTimestamp .Before (& start ) {
252+ return false
253+ }
254+ }
255+ return true
256+ }
257+
258+ func (r RestoreOpsHandler ) restorePreCreateWindowExpired (opsRequest * opsv1alpha1.OpsRequest ) bool {
259+ startTime , ok := restoreFailureGateStartTime (opsRequest )
260+ return ok && time .Now ().After (startTime .Add (restoreCRPreCreateFailureTimeout ))
261+ }
262+
263+ func restoreFailureGateStartTime (opsRequest * opsv1alpha1.OpsRequest ) (time.Time , bool ) {
264+ if ! opsRequest .Status .StartTimestamp .IsZero () {
265+ return opsRequest .Status .StartTimestamp .Time , true
266+ }
267+ if ! opsRequest .CreationTimestamp .IsZero () {
268+ return opsRequest .CreationTimestamp .Time , true
269+ }
270+ return time.Time {}, false
271+ }
272+
273+ func restoreClusterUIDPrefix (cluster * appsv1.Cluster ) string {
274+ uid := string (cluster .UID )
275+ if len (uid ) > 8 {
276+ return uid [:8 ]
277+ }
278+ return uid
279+ }
280+
281+ func restoreNameContainsClusterUIDPrefix (restoreName string , cluster * appsv1.Cluster ) bool {
282+ uidPrefix := restoreClusterUIDPrefix (cluster )
283+ return uidPrefix == "" || strings .Contains (restoreName , "-" + uidPrefix + "-" )
284+ }
285+
134286// SaveLastConfiguration saves last configuration to the OpsRequest.status.lastConfiguration
135287func (r RestoreOpsHandler ) SaveLastConfiguration (reqCtx intctrlutil.RequestCtx , cli client.Client , opsResource * OpsResource ) error {
136288 return nil
0 commit comments