Implement autobackup-operator controllers, tests, CI and packaging

PVCs and CloudNativePG Clusters need S3 buckets and backup schedules
provisioned consistently. This operator watches the
backups.unkin.net/{schedule,destination} annotations on those objects and
provisions everything needed to back them up, with no new CRDs.

- Add a PVC controller that provisions cephrgw ObjectStoreUser/Bucket/BucketAccess,
  auto-generates a restic repo-password Secret and creates a k8up Schedule scoped
  to the PVC via spec.backup.volumes[].persistentVolumeClaim.claimName.
- Add a CNPG Cluster controller that provisions the same bucket stack, idempotently
  patches spec.backup.barmanObjectStore (leaving a user-set destinationPath alone
  with a Warning event) and creates a ScheduledBackup.
- Resolve destinations through a ConfigMap lookup table; requeue until the
  BucketAccess is Ready before creating schedule resources; own-reference created
  resources and retain bucket data by default.
- Add schedule-mapping helpers (k8up 5-field/shortcut pass-through, CNPG 6-field
  seconds-first) and deterministic, length-bounded name derivation.
- Add unit tests (schedule mapping, name derivation, destination resolution) and
  envtest controller tests for both paths, wiring the external CRDs into envtest.
- Add kubebuilder-generated RBAC, a Dockerfile (distroless/nonroot), Woodpecker
  lint/test/build pipelines and a tag-triggered image push to the artifactapi
  docker-internal registry, plus a version-bump Makefile and deploy manifests.
This commit is contained in:
2026-08-14 00:08:37 +10:00
parent 3d63975b24
commit 9da206dc7c
4631 changed files with 1334852 additions and 1 deletions
+149
View File
@@ -0,0 +1,149 @@
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/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
)
// 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,
}
}
func (r *PVCReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&corev1.PersistentVolumeClaim{}).
Owns(&k8upv1.Schedule{}).
Named("pvc-autobackup").
Complete(r)
}