Skip to content

Commit 40d6099

Browse files
stubbiclaude
andauthored
fix(backup): bound the on-delete finalizer so a failing snapshot can't make an instance undeletable (#94)
Fixes #93. ## Problem `BackupReconciler.HandleDeletion` requeued **forever** when the final backup Job failed (or hung): it recorded the failure and requeued every 30s but never released the `hermes.agent/backup-on-delete` finalizer. So a `HermesInstance` with `spec.backup.onDelete` and bad/unreachable S3 creds became **permanently undeletable** (stuck `Terminating`, which also wedges its namespace). This is what hung the conformance `backup-enabled` cleanup. ## Fix Bound the deletion block with a grace window (`finalBackupDeadline`, **30 min** from `deletionTimestamp`). Once it elapses, the operator gives up: - records `status.backup.lastFailureReason = FinalBackupDeadlineExceeded`, - emits a `Warning FinalBackupAbandoned` event (surfacing the data-loss risk), - **releases the finalizer so deletion proceeds.** The `hermes.agent/skip-final-backup=true` annotation remains the explicit, immediate escape hatch. Within the window the behavior is unchanged (attempt the snapshot, hold the finalizer). ## Tests Fake-client unit tests: deadline exceeded → finalizer released (instance GC'd); within the window → finalizer held + final backup Job created. Full controller envtest suite + `golangci-lint` green. Docs updated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 490d8e1 commit 40d6099

3 files changed

Lines changed: 149 additions & 2 deletions

File tree

docs/backup-restore.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,13 @@ When `spec.backup.onDelete = true`, the operator adds the `hermes.agent/backup-o
8080
3. When the Job succeeds, the finalizer is removed via **`r.Patch`** (`client.MergeFrom`), not `r.Update`. This is critical: `r.Update` bumps `metadata.generation` and replaces the pod on the next reconcile. Lesson #437 from openclaw-operator.
8181
4. Kubernetes GC'es the CR + cascades to owned resources.
8282

83+
### Bounded grace window
84+
85+
A failing or stuck final backup must not make the instance **permanently undeletable** (#93). The operator blocks deletion for at most a grace window (`finalBackupDeadline`, **30 minutes** from `deletionTimestamp`); after that it gives up — records `status.backup.lastFailureReason = FinalBackupDeadlineExceeded`, emits a `Warning FinalBackupAbandoned` event, and **releases the finalizer so deletion proceeds**. The final snapshot will not have been taken, so PVC data may be lost — the event makes this visible for post-mortems.
86+
8387
### Skipping the final backup
8488

85-
If the final backup is hanging or the bucket is unreachable:
89+
To delete immediately (without waiting out the grace window) when the final backup is hanging or the bucket is unreachable:
8690

8791
```bash
8892
kubectl annotate hermesinstance/<name> hermes.agent/skip-final-backup=true --overwrite
@@ -100,7 +104,7 @@ A second CronJob (`<name>-backup-prune`) runs daily at 04:17 UTC and runs `resti
100104
|---|---|---|
101105
| Final backup Job fails with `S3 credentials secret missing key`. | Secret missing `S3_ACCESS_KEY_ID` or `S3_SECRET_ACCESS_KEY`. | Patch the Secret. The CR stays in deletion until the next reconcile picks up the new Secret. |
102106
| Scheduled CronJob runs but no snapshot appears. | Likely a network policy blocking egress to S3 endpoint. | Add an egress rule under `spec.networking.egress`. |
103-
| `kubectl delete` hangs forever. | Final backup Job failing repeatedly. | `kubectl describe job <name>-backup-final` for logs; either fix or use the skip annotation. |
107+
| `kubectl delete` blocks (up to the 30-minute grace window, then auto-releases). | Final backup Job failing repeatedly. | `kubectl describe job <name>-backup-final` for logs; fix the cause, or use the skip annotation to release immediately. After the grace window the finalizer auto-releases (`FinalBackupAbandoned` event) and deletion proceeds without a final snapshot. |
104108
| `status.restoredFrom` stays empty after `init-restore` exited 0. | Pod restarted before the operator observed the terminated state. | Force reconcile: `kubectl annotate hermesinstance <name> poke=$(date +%s) --overwrite`. |
105109

106110
## API stability

internal/controller/backup.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ type BackupReconciler struct {
3232
Recorder record.EventRecorder
3333
}
3434

35+
// finalBackupDeadline bounds how long deletion may block on the on-delete final
36+
// backup. A failed or stuck snapshot must not make a HermesInstance permanently
37+
// undeletable (#93): once this grace window from .metadata.deletionTimestamp
38+
// elapses, HandleDeletion gives up — it records the failure, emits a loud
39+
// warning, and releases the finalizer so deletion proceeds. The
40+
// skip-final-backup annotation remains the explicit, immediate escape hatch.
41+
// A var (not const) so tests can shrink it.
42+
var finalBackupDeadline = 30 * time.Minute
43+
3544
// EnsureFinalizer adds the backup-on-delete finalizer when spec.backup.onDelete is true.
3645
//
3746
// CRITICAL: lesson #437: finalizer mutation uses r.Patch(ctx, inst, client.MergeFrom(original)),
@@ -152,6 +161,24 @@ func (b *BackupReconciler) HandleDeletion(ctx context.Context, inst *hermesv1.He
152161
return ctrl.Result{}, false, nil
153162
}
154163

164+
// Bound the deletion block (#93): if the final backup hasn't succeeded within
165+
// finalBackupDeadline of the deletion request, give up so the instance can be
166+
// deleted instead of hanging forever on a failing/stuck snapshot Job.
167+
if dt := inst.DeletionTimestamp; dt != nil && time.Since(dt.Time) > finalBackupDeadline {
168+
b.Recorder.Eventf(inst, corev1.EventTypeWarning, "FinalBackupAbandoned",
169+
"Final backup did not complete within %s of deletion; releasing the finalizer so the instance can be deleted. The final snapshot was NOT taken and PVC data may be lost. Inspect Job %q, or set annotation %q=true to skip explicitly next time.",
170+
finalBackupDeadline, FinalBackupJobName(inst), hermesv1.AnnotationSkipFinalBackup)
171+
now := metav1.Now()
172+
inst.Status.Backup.LastFailureTime = &now
173+
inst.Status.Backup.LastFailureReason = "FinalBackupDeadlineExceeded"
174+
// Best-effort status; deletion must proceed even if this write races the GC.
175+
_ = b.Status().Update(ctx, inst)
176+
if err := b.RemoveFinalizer(ctx, inst); err != nil {
177+
return ctrl.Result{}, true, err
178+
}
179+
return ctrl.Result{}, false, nil
180+
}
181+
155182
jobName := FinalBackupJobName(inst)
156183
job, err := GetJob(ctx, b.Client, jobName, inst.Namespace)
157184
if err != nil {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package controller
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
batchv1 "k8s.io/api/batch/v1"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/runtime"
13+
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
14+
"k8s.io/client-go/tools/record"
15+
"sigs.k8s.io/controller-runtime/pkg/client"
16+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
17+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
18+
19+
hermesv1 "github.com/paperclipinc/hermes-operator/api/v1"
20+
)
21+
22+
// onDeleteInstance returns a HermesInstance that holds the backup-on-delete
23+
// finalizer and has spec.backup.onDelete + a (placeholder) S3 target.
24+
func onDeleteInstance() *hermesv1.HermesInstance {
25+
return &hermesv1.HermesInstance{
26+
ObjectMeta: metav1.ObjectMeta{
27+
Name: "dl",
28+
Namespace: "default",
29+
Finalizers: []string{hermesv1.FinalizerBackupOnDelete},
30+
},
31+
Spec: hermesv1.HermesInstanceSpec{
32+
Backup: hermesv1.BackupSpec{
33+
OnDelete: true,
34+
S3: &hermesv1.BackupS3Spec{
35+
Bucket: "b",
36+
Endpoint: "https://s3.example.com",
37+
CredentialsSecretRef: hermesv1.LocalObjectReference{Name: "creds"},
38+
},
39+
},
40+
},
41+
}
42+
}
43+
44+
func backupTestScheme(t *testing.T) *runtime.Scheme {
45+
t.Helper()
46+
sch := runtime.NewScheme()
47+
require.NoError(t, clientgoscheme.AddToScheme(sch))
48+
require.NoError(t, hermesv1.AddToScheme(sch))
49+
return sch
50+
}
51+
52+
// deletingClient builds a fake client holding inst, then Deletes it so it carries
53+
// a deletionTimestamp (the finalizer keeps it around), and returns the live copy.
54+
func deletingClient(t *testing.T, sch *runtime.Scheme, inst *hermesv1.HermesInstance) (client.Client, *hermesv1.HermesInstance) {
55+
t.Helper()
56+
cl := fake.NewClientBuilder().
57+
WithScheme(sch).
58+
WithObjects(inst).
59+
WithStatusSubresource(&hermesv1.HermesInstance{}).
60+
Build()
61+
ctx := context.Background()
62+
require.NoError(t, cl.Delete(ctx, inst))
63+
got := &hermesv1.HermesInstance{}
64+
require.NoError(t, cl.Get(ctx, client.ObjectKeyFromObject(inst), got))
65+
require.NotNil(t, got.DeletionTimestamp, "expected deletionTimestamp after Delete")
66+
return cl, got
67+
}
68+
69+
// #93: once the grace window elapses, a failing/stuck final backup must not keep
70+
// the instance undeletable — HandleDeletion releases the finalizer.
71+
func TestHandleDeletion_DeadlineReleasesFinalizer(t *testing.T) {
72+
orig := finalBackupDeadline
73+
finalBackupDeadline = 0 // grace window already exceeded
74+
defer func() { finalBackupDeadline = orig }()
75+
76+
sch := backupTestScheme(t)
77+
cl, inst := deletingClient(t, sch, onDeleteInstance())
78+
79+
b := &BackupReconciler{Client: cl, Scheme: sch, Recorder: record.NewFakeRecorder(10)}
80+
_, held, err := b.HandleDeletion(context.Background(), inst)
81+
require.NoError(t, err)
82+
assert.False(t, held, "deadline exceeded: finalizer must be released so deletion proceeds")
83+
84+
// Finalizer gone -> the fake client garbage-collects the terminating object.
85+
after := &hermesv1.HermesInstance{}
86+
if err := cl.Get(context.Background(), client.ObjectKeyFromObject(inst), after); err == nil {
87+
assert.False(t, controllerutil.ContainsFinalizer(after, hermesv1.FinalizerBackupOnDelete),
88+
"finalizer should be removed after the deadline give-up")
89+
}
90+
}
91+
92+
// Within the grace window the finalizer is still held and the final backup Job is
93+
// started — deletion is intentionally blocked while the snapshot is attempted.
94+
func TestHandleDeletion_WithinDeadlineHoldsFinalizer(t *testing.T) {
95+
orig := finalBackupDeadline
96+
finalBackupDeadline = time.Hour // plenty of grace
97+
defer func() { finalBackupDeadline = orig }()
98+
99+
sch := backupTestScheme(t)
100+
cl, inst := deletingClient(t, sch, onDeleteInstance())
101+
102+
b := &BackupReconciler{Client: cl, Scheme: sch, Recorder: record.NewFakeRecorder(10)}
103+
_, held, err := b.HandleDeletion(context.Background(), inst)
104+
require.NoError(t, err)
105+
assert.True(t, held, "within the grace window the finalizer must still be held")
106+
107+
// The final backup Job should have been created, and the finalizer kept.
108+
job := &batchv1.Job{}
109+
require.NoError(t, cl.Get(context.Background(),
110+
client.ObjectKey{Namespace: inst.Namespace, Name: FinalBackupJobName(inst)}, job))
111+
112+
after := &hermesv1.HermesInstance{}
113+
require.NoError(t, cl.Get(context.Background(), client.ObjectKeyFromObject(inst), after))
114+
assert.True(t, controllerutil.ContainsFinalizer(after, hermesv1.FinalizerBackupOnDelete),
115+
"finalizer must remain while the backup is in flight")
116+
}

0 commit comments

Comments
 (0)