package controller import ( "context" "errors" "strings" k8upv1 "github.com/k8up-io/k8up/v2/api/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" ) // PVCReconciler backs up annotated PersistentVolumeClaims by provisioning an S3 // bucket (via cephrgw) and a k8up Schedule scoped to the PVC. type PVCReconciler struct { baseReconciler } // +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch // +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch // +kubebuilder:rbac:groups=ceph.unkin.net,resources=objectstoreusers;buckets;bucketaccesses,verbs=get;list;watch;create;update;patch // +kubebuilder:rbac:groups=ceph.unkin.net,resources=bucketaccesses/status,verbs=get // +kubebuilder:rbac:groups=k8up.io,resources=schedules,verbs=get;list;watch;create;update;patch;delete func (r *PVCReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) var pvc corev1.PersistentVolumeClaim if err := r.Get(ctx, req.NamespacedName, &pvc); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } schedule := pvc.Annotations[annSchedule] if schedule == "" { return r.teardown(ctx, &pvc) } k8upSchedule, err := ScheduleForK8up(schedule) if err != nil { r.Recorder.Event(&pvc, corev1.EventTypeWarning, "InvalidSchedule", err.Error()) return ctrl.Result{}, nil } destName := pvc.Annotations[annDestination] if destName == "" { r.Recorder.Event(&pvc, corev1.EventTypeWarning, "MissingDestination", "backups.unkin.net/destination annotation is required") return ctrl.Result{}, nil } dest, err := r.resolveDest(ctx, destName) if err != nil { if errors.Is(err, errDestinationNotFound) { r.Recorder.Event(&pvc, corev1.EventTypeWarning, "UnknownDestination", err.Error()) return ctrl.Result{RequeueAfter: requeueShort}, nil } return ctrl.Result{}, err } ready, credSecret, err := r.ensureBucketStack(ctx, &pvc, dest) if err != nil { return ctrl.Result{}, err } if !ready { return ctrl.Result{RequeueAfter: requeueShort}, nil } resticSecret, err := r.ensureResticSecret(ctx, &pvc) if err != nil { return ctrl.Result{}, err } if err := r.ensureSchedule(ctx, &pvc, k8upSchedule, credSecret, resticSecret, dest); err != nil { return ctrl.Result{}, err } logger.Info("pvc backup reconciled", "pvc", pvc.Name, "destination", destName) return ctrl.Result{RequeueAfter: requeueSteady}, nil } // teardown removes the k8up Schedule when the annotation is gone, and (only on // explicit opt-in) the bucket stack. Bucket data is retained by default. func (r *PVCReconciler) teardown(ctx context.Context, pvc *corev1.PersistentVolumeClaim) (ctrl.Result, error) { if err := deleteIfExists(ctx, r.Client, &k8upv1.Schedule{ ObjectMeta: metav1.ObjectMeta{Name: scheduleName(pvc.Name), Namespace: pvc.Namespace}, }); err != nil { return ctrl.Result{}, err } if !strings.EqualFold(pvc.Annotations[annPurge], "true") { return ctrl.Result{}, nil } for _, obj := range bucketStackObjects(pvc.Namespace, pvc.Name) { if err := deleteIfExists(ctx, r.Client, obj); err != nil { return ctrl.Result{}, err } } return ctrl.Result{}, nil } func (r *PVCReconciler) ensureSchedule(ctx context.Context, pvc *corev1.PersistentVolumeClaim, schedule, credSecret, resticSecret string, dest Destination) error { sched := &k8upv1.Schedule{ObjectMeta: metav1.ObjectMeta{Name: scheduleName(pvc.Name), Namespace: pvc.Namespace}} _, err := controllerutil.CreateOrUpdate(ctx, r.Client, sched, func() error { sched.Spec.Backend = &k8upv1.Backend{ RepoPasswordSecretRef: secretKeyRef(resticSecret, "password"), S3: &k8upv1.S3Spec{ Endpoint: dest.Endpoint, Bucket: bucketName(pvc.Namespace, pvc.Name), AccessKeyIDSecretRef: secretKeyRef(credSecret, "AWS_ACCESS_KEY_ID"), SecretAccessKeySecretRef: secretKeyRef(credSecret, "AWS_SECRET_ACCESS_KEY"), }, } sched.Spec.Backup = &k8upv1.BackupSchedule{ ScheduleCommon: &k8upv1.ScheduleCommon{Schedule: k8upv1.ScheduleDefinition(schedule)}, BackupSpec: k8upv1.BackupSpec{ RunnableSpec: k8upv1.RunnableSpec{ Volumes: &[]k8upv1.RunnableVolumeSpec{{ Name: "backup-source", PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: pvc.Name}, }}, }, }, } sched.Spec.Prune = &k8upv1.PruneSchedule{ ScheduleCommon: &k8upv1.ScheduleCommon{Schedule: k8upv1.ScheduleDefinition("@weekly")}, PruneSpec: k8upv1.PruneSpec{Retention: k8upv1.RetentionPolicy{KeepDaily: 7, KeepWeekly: 4}}, } return controllerutil.SetControllerReference(pvc, sched, r.Scheme) }) return err } func secretKeyRef(name, key string) *corev1.SecretKeySelector { return &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: name}, Key: key, } } // hasScheduleAnnotation reports whether an object carries the schedule // annotation that marks it for management. func hasScheduleAnnotation(o client.Object) bool { _, ok := o.GetAnnotations()[annSchedule] return ok } // schedulePredicate limits reconciles to PVCs that carry (or, on update, used to // carry) the schedule annotation, so unannotated PVCs no longer churn the queue // on every resync while the annotation-removed teardown path still fires. var schedulePredicate = predicate.Funcs{ CreateFunc: func(e event.CreateEvent) bool { return hasScheduleAnnotation(e.Object) }, DeleteFunc: func(e event.DeleteEvent) bool { return hasScheduleAnnotation(e.Object) }, GenericFunc: func(e event.GenericEvent) bool { return hasScheduleAnnotation(e.Object) }, UpdateFunc: func(e event.UpdateEvent) bool { return hasScheduleAnnotation(e.ObjectOld) || hasScheduleAnnotation(e.ObjectNew) }, } func (r *PVCReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&corev1.PersistentVolumeClaim{}, builder.WithPredicates(schedulePredicate)). Owns(&k8upv1.Schedule{}). Named("pvc-autobackup"). Complete(r) }