Skip to content

Commit e356a37

Browse files
stubbiclaude
andcommitted
feat(controller): BackupReconciler with r.Patch-only finalizer mutation (lesson #437) and RunOneShot helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9765a55 commit e356a37

1 file changed

Lines changed: 259 additions & 0 deletions

File tree

internal/controller/backup.go

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
package controller
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
batchv1 "k8s.io/api/batch/v1"
9+
corev1 "k8s.io/api/core/v1"
10+
apierrors "k8s.io/apimachinery/pkg/api/errors"
11+
"k8s.io/apimachinery/pkg/api/meta"
12+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
13+
"k8s.io/apimachinery/pkg/runtime"
14+
"k8s.io/apimachinery/pkg/types"
15+
"k8s.io/client-go/tools/record"
16+
ctrl "sigs.k8s.io/controller-runtime"
17+
"sigs.k8s.io/controller-runtime/pkg/client"
18+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
19+
"sigs.k8s.io/controller-runtime/pkg/log"
20+
21+
hermesv1 "github.com/stubbi/hermes-operator/api/v1"
22+
"github.com/stubbi/hermes-operator/internal/resources"
23+
)
24+
25+
// +kubebuilder:rbac:groups=batch,resources=jobs;cronjobs,verbs=get;list;watch;create;update;patch;delete
26+
27+
// BackupReconciler is a sub-controller invoked by HermesInstanceReconciler.
28+
// Not a controller-runtime Reconciler — the main reconciler drives it.
29+
type BackupReconciler struct {
30+
client.Client
31+
Scheme *runtime.Scheme
32+
Recorder record.EventRecorder
33+
}
34+
35+
// EnsureFinalizer adds the backup-on-delete finalizer when spec.backup.onDelete is true.
36+
//
37+
// CRITICAL — lesson #437: finalizer mutation uses r.Patch(ctx, inst, client.MergeFrom(original)),
38+
// NEVER r.Update — Update bumps metadata.generation and triggers a pod-replace.
39+
func (b *BackupReconciler) EnsureFinalizer(ctx context.Context, inst *hermesv1.HermesInstance) error {
40+
if !inst.Spec.Backup.OnDelete {
41+
return nil
42+
}
43+
if controllerutil.ContainsFinalizer(inst, hermesv1.FinalizerBackupOnDelete) {
44+
return nil
45+
}
46+
original := inst.DeepCopy()
47+
controllerutil.AddFinalizer(inst, hermesv1.FinalizerBackupOnDelete)
48+
if err := b.Patch(ctx, inst, client.MergeFrom(original)); err != nil {
49+
return fmt.Errorf("patch finalizer add: %w", err)
50+
}
51+
return nil
52+
}
53+
54+
// RemoveFinalizer removes the backup-on-delete finalizer via r.Patch (NOT r.Update).
55+
func (b *BackupReconciler) RemoveFinalizer(ctx context.Context, inst *hermesv1.HermesInstance) error {
56+
if !controllerutil.ContainsFinalizer(inst, hermesv1.FinalizerBackupOnDelete) {
57+
return nil
58+
}
59+
original := inst.DeepCopy()
60+
controllerutil.RemoveFinalizer(inst, hermesv1.FinalizerBackupOnDelete)
61+
if err := b.Patch(ctx, inst, client.MergeFrom(original)); err != nil {
62+
return fmt.Errorf("patch finalizer remove: %w", err)
63+
}
64+
return nil
65+
}
66+
67+
// ReconcileCronJob creates/updates/deletes the periodic backup CronJob based on spec.backup.schedule.
68+
func (b *BackupReconciler) ReconcileCronJob(ctx context.Context, inst *hermesv1.HermesInstance) error {
69+
if inst.Spec.Backup.Schedule == "" || inst.Spec.Backup.S3 == nil {
70+
return b.deleteCronJob(ctx, inst, resources.BackupCronJobName(inst))
71+
}
72+
73+
obj := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{
74+
Name: resources.BackupCronJobName(inst),
75+
Namespace: inst.Namespace,
76+
}}
77+
_, err := controllerutil.CreateOrUpdate(ctx, b.Client, obj, func() error {
78+
desired := resources.BuildBackupCronJob(inst)
79+
obj.Labels = resources.MergePreservingForeign(obj.Labels, desired.Labels, "hermes.agent/")
80+
obj.Spec = desired.Spec
81+
return controllerutil.SetControllerReference(inst, obj, b.Scheme)
82+
})
83+
if err != nil {
84+
return fmt.Errorf("reconcile backup CronJob: %w", err)
85+
}
86+
87+
prune := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{
88+
Name: resources.BackupPruneCronJobName(inst),
89+
Namespace: inst.Namespace,
90+
}}
91+
_, err = controllerutil.CreateOrUpdate(ctx, b.Client, prune, func() error {
92+
desired := resources.BuildBackupPruneCronJob(inst)
93+
prune.Labels = resources.MergePreservingForeign(prune.Labels, desired.Labels, "hermes.agent/")
94+
prune.Spec = desired.Spec
95+
return controllerutil.SetControllerReference(inst, prune, b.Scheme)
96+
})
97+
if err != nil {
98+
return fmt.Errorf("reconcile prune CronJob: %w", err)
99+
}
100+
101+
meta.SetStatusCondition(&inst.Status.Conditions, metav1.Condition{
102+
Type: hermesv1.ConditionBackupReady,
103+
Status: metav1.ConditionTrue,
104+
Reason: "Scheduled",
105+
Message: fmt.Sprintf("Backup CronJob %q scheduled %q", obj.Name, inst.Spec.Backup.Schedule),
106+
ObservedGeneration: inst.Generation,
107+
})
108+
return nil
109+
}
110+
111+
func (b *BackupReconciler) deleteCronJob(ctx context.Context, inst *hermesv1.HermesInstance, name string) error {
112+
cj := &batchv1.CronJob{}
113+
err := b.Get(ctx, types.NamespacedName{Name: name, Namespace: inst.Namespace}, cj)
114+
if apierrors.IsNotFound(err) {
115+
meta.RemoveStatusCondition(&inst.Status.Conditions, hermesv1.ConditionBackupReady)
116+
return nil
117+
}
118+
if err != nil {
119+
return err
120+
}
121+
if err := b.Delete(ctx, cj); err != nil && !apierrors.IsNotFound(err) {
122+
return err
123+
}
124+
meta.RemoveStatusCondition(&inst.Status.Conditions, hermesv1.ConditionBackupReady)
125+
return nil
126+
}
127+
128+
// HandleDeletion runs the backup-on-delete state machine.
129+
// Returns (Result, finalizerStillHeld, error). When finalizerStillHeld=true the caller must requeue.
130+
func (b *BackupReconciler) HandleDeletion(ctx context.Context, inst *hermesv1.HermesInstance) (ctrl.Result, bool, error) {
131+
logger := log.FromContext(ctx)
132+
133+
if !controllerutil.ContainsFinalizer(inst, hermesv1.FinalizerBackupOnDelete) {
134+
return ctrl.Result{}, false, nil
135+
}
136+
137+
if inst.Annotations[hermesv1.AnnotationSkipFinalBackup] == "true" {
138+
b.Recorder.Eventf(inst, corev1.EventTypeWarning, "FinalBackupSkipped",
139+
"Skipping final backup because annotation %q is true", hermesv1.AnnotationSkipFinalBackup)
140+
if err := b.RemoveFinalizer(ctx, inst); err != nil {
141+
return ctrl.Result{}, true, err
142+
}
143+
return ctrl.Result{}, false, nil
144+
}
145+
146+
if inst.Spec.Backup.S3 == nil {
147+
b.Recorder.Eventf(inst, corev1.EventTypeWarning, "FinalBackupSkipped",
148+
"spec.backup.s3 is unset; cannot run final backup")
149+
if err := b.RemoveFinalizer(ctx, inst); err != nil {
150+
return ctrl.Result{}, true, err
151+
}
152+
return ctrl.Result{}, false, nil
153+
}
154+
155+
jobName := FinalBackupJobName(inst)
156+
job, err := GetJob(ctx, b.Client, jobName, inst.Namespace)
157+
if err != nil {
158+
return ctrl.Result{}, true, err
159+
}
160+
161+
if job == nil {
162+
ts := time.Now().UTC().Format("2006-01-02T15-04-05Z")
163+
key := SnapshotKey(inst, "onDelete", ts)
164+
desired := resources.BuildBackupOneShotJob(inst, resources.BackupJobOpts{
165+
Name: jobName,
166+
SnapshotKey: key,
167+
Kind: "onDelete",
168+
})
169+
if err := controllerutil.SetControllerReference(inst, desired, b.Scheme); err != nil {
170+
return ctrl.Result{}, true, err
171+
}
172+
if err := b.Create(ctx, desired); err != nil && !apierrors.IsAlreadyExists(err) { // reconcile-guard:allow — final backup Job is create-only
173+
return ctrl.Result{}, true, fmt.Errorf("create final backup Job: %w", err)
174+
}
175+
inst.Status.Backup.FinalBackupJobName = jobName
176+
if err := b.Status().Update(ctx, inst); err != nil {
177+
return ctrl.Result{}, true, err
178+
}
179+
b.Recorder.Eventf(inst, corev1.EventTypeNormal, "FinalBackupStarted",
180+
"Final backup Job %q started; snapshot key %q", jobName, key)
181+
return ctrl.Result{RequeueAfter: 10 * time.Second}, true, nil
182+
}
183+
184+
finished, cond := IsJobFinished(job)
185+
if !finished {
186+
logger.Info("final backup still running", "job", jobName)
187+
return ctrl.Result{RequeueAfter: 10 * time.Second}, true, nil
188+
}
189+
190+
if cond == batchv1.JobFailed {
191+
b.Recorder.Eventf(inst, corev1.EventTypeWarning, "FinalBackupFailed",
192+
"Final backup Job %q failed. Inspect logs, delete the Job to retry, or annotate %q=true to skip.",
193+
jobName, hermesv1.AnnotationSkipFinalBackup)
194+
now := metav1.Now()
195+
inst.Status.Backup.LastFailureTime = &now
196+
inst.Status.Backup.LastFailureReason = "FinalBackupJobFailed"
197+
if err := b.Status().Update(ctx, inst); err != nil {
198+
return ctrl.Result{}, true, err
199+
}
200+
return ctrl.Result{RequeueAfter: 30 * time.Second}, true, nil
201+
}
202+
203+
now := metav1.Now()
204+
inst.Status.Backup.LastSuccessTime = &now
205+
if err := b.Status().Update(ctx, inst); err != nil {
206+
return ctrl.Result{}, true, err
207+
}
208+
if err := b.RemoveFinalizer(ctx, inst); err != nil {
209+
return ctrl.Result{}, true, err
210+
}
211+
return ctrl.Result{}, false, nil
212+
}
213+
214+
// RunOneShot creates a one-shot pre-update backup Job and waits for it across reconciles.
215+
// Returns (snapshotKey, done, err). Called by the auto-update controller.
216+
func (b *BackupReconciler) RunOneShot(ctx context.Context, inst *hermesv1.HermesInstance) (string, bool, error) {
217+
jobName := PreUpdateBackupJobName(inst)
218+
ts := time.Now().UTC().Format("2006-01-02T15-04-05Z")
219+
key := SnapshotKey(inst, "preUpdate", ts)
220+
221+
job, err := GetJob(ctx, b.Client, jobName, inst.Namespace)
222+
if err != nil {
223+
return "", false, err
224+
}
225+
if job == nil {
226+
if inst.Status.AutoUpdate.PreUpdateSnapshot == "" {
227+
inst.Status.AutoUpdate.PreUpdateSnapshot = key
228+
if err := b.Status().Update(ctx, inst); err != nil {
229+
return "", false, err
230+
}
231+
} else {
232+
key = inst.Status.AutoUpdate.PreUpdateSnapshot
233+
}
234+
235+
desired := resources.BuildBackupOneShotJob(inst, resources.BackupJobOpts{
236+
Name: jobName,
237+
SnapshotKey: key,
238+
Kind: "preUpdate",
239+
})
240+
if err := controllerutil.SetControllerReference(inst, desired, b.Scheme); err != nil {
241+
return "", false, err
242+
}
243+
if err := b.Create(ctx, desired); err != nil && !apierrors.IsAlreadyExists(err) { // reconcile-guard:allow — pre-update backup Job is create-only
244+
return "", false, fmt.Errorf("create pre-update backup Job: %w", err)
245+
}
246+
b.Recorder.Eventf(inst, corev1.EventTypeNormal, "PreUpdateBackupStarted",
247+
"Pre-update backup Job %q started; snapshot %q", jobName, key)
248+
return key, false, nil
249+
}
250+
251+
finished, cond := IsJobFinished(job)
252+
if !finished {
253+
return inst.Status.AutoUpdate.PreUpdateSnapshot, false, nil
254+
}
255+
if cond == batchv1.JobFailed {
256+
return inst.Status.AutoUpdate.PreUpdateSnapshot, false, fmt.Errorf("pre-update backup Job %q failed", jobName)
257+
}
258+
return inst.Status.AutoUpdate.PreUpdateSnapshot, true, nil
259+
}

0 commit comments

Comments
 (0)