Skip to content

Commit 32a3621

Browse files
authored
fix(operations): make restore ops failure gate restore-aware (#10238)
1 parent 2807dae commit 32a3621

2 files changed

Lines changed: 481 additions & 6 deletions

File tree

pkg/operations/restore.go

Lines changed: 158 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
2020
package operations
2121

2222
import (
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

4547
var _ OpsHandler = RestoreOpsHandler{}
4648

49+
const (
50+
restoreFailureGateRequeueAfter = 30 * time.Second
51+
restoreCRPreCreateFailureTimeout = 5 * time.Minute
52+
)
53+
4754
func 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.
109134
func (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
135287
func (r RestoreOpsHandler) SaveLastConfiguration(reqCtx intctrlutil.RequestCtx, cli client.Client, opsResource *OpsResource) error {
136288
return nil

0 commit comments

Comments
 (0)