a3339a30b5
Addresses PR review + acceptance findings: - Add leader-election RBAC: namespaced Role (coordination leases + events) and RoleBinding to the operator ServiceAccount, so controllers actually start under --leader-elect instead of looping on "leases forbidden". Verified in a kind cluster: lease acquired, both controllers start workers. - Align Go versions: bump Dockerfile.operator to golang:1.26-alpine and CI images to golang:1.26 to match go.mod (go 1.26.5); set GOTOOLCHAIN=local so the image build stays hermetic (no toolchain download). - PVC controller: add an annotation predicate so only PVCs carrying (or transitioning off of) backups.unkin.net/schedule enqueue, eliminating reconcile churn from unannotated PVCs while keeping the teardown path. - Cron validator: accept alphabetic month/day-of-week names (MON, JAN, MON-FRI) that k8up and CNPG's robfig/cron accept, still rejecting unknown names; add unit tests for both mapping paths. - Deploy ordering: extract the Namespace into its own manifest and add a config/kustomization.yaml so `kubectl apply -k config` creates the namespace first; document it in the README. - Note the credential-less push precedent (jellyfin-ha) in docker.yaml.
172 lines
6.4 KiB
Go
172 lines
6.4 KiB
Go
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)
|
|
}
|