Files
unkin-agent 9da206dc7c 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.
2026-08-14 00:08:37 +10:00

141 lines
5.1 KiB
Go

package controller
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
cephv1 "git.unkin.net/unkin/cephrgw-operator/api/v1alpha1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
const (
annPrefix = "backups.unkin.net/"
annSchedule = annPrefix + "schedule"
annDestination = annPrefix + "destination"
annPurge = annPrefix + "purge-on-delete"
// requeueSteady re-syncs healthy objects so drift heals without waiting for
// an event.
requeueSteady = 10 * time.Minute
// requeueShort backs off while waiting for a dependency (e.g. the
// BucketAccess to become Ready or a destination to appear).
requeueShort = 30 * time.Second
)
// baseReconciler holds the shared client, scheme, recorder and destinations
// ConfigMap location used by both the PVC and CNPG Cluster controllers.
type baseReconciler struct {
client.Client
Scheme *runtime.Scheme
Recorder record.EventRecorder
DestNamespace string
DestConfigMap string
}
func (b *baseReconciler) resolveDest(ctx context.Context, dest string) (Destination, error) {
return resolveDestination(ctx, b.Client, b.DestNamespace, b.DestConfigMap, dest)
}
// ensureBucketStack provisions the ObjectStoreUser, Bucket and BucketAccess for
// an annotated object (all owner-referenced to it) and reports whether the
// BucketAccess is Ready together with the name of its credential Secret.
func (b *baseReconciler) ensureBucketStack(ctx context.Context, owner client.Object, dest Destination) (ready bool, credSecret string, err error) {
ns := owner.GetNamespace()
obj := owner.GetName()
bName := bucketName(ns, obj)
user := &cephv1.ObjectStoreUser{ObjectMeta: metav1.ObjectMeta{Name: userName(obj), Namespace: ns}}
if _, err = controllerutil.CreateOrUpdate(ctx, b.Client, user, func() error {
user.Spec.DisplayName = fmt.Sprintf("autobackup %s/%s", ns, obj)
return controllerutil.SetControllerReference(owner, user, b.Scheme)
}); err != nil {
return false, "", err
}
bucket := &cephv1.Bucket{ObjectMeta: metav1.ObjectMeta{Name: bName, Namespace: ns}}
if _, err = controllerutil.CreateOrUpdate(ctx, b.Client, bucket, func() error {
bucket.Spec.OwnerRef = user.Name
bucket.Spec.BucketName = bName
bucket.Spec.PlacementTarget = dest.PlacementTarget
bucket.Spec.Zonegroup = dest.Zonegroup
bucket.Spec.Versioning = true
// Keep bucket data by default; deleting the owner GCs the CRs but the
// underlying objects are retained for safety.
bucket.Spec.RetainOnDelete = true
return controllerutil.SetControllerReference(owner, bucket, b.Scheme)
}); err != nil {
return false, "", err
}
access := &cephv1.BucketAccess{ObjectMeta: metav1.ObjectMeta{Name: accessName(obj), Namespace: ns}}
if _, err = controllerutil.CreateOrUpdate(ctx, b.Client, access, func() error {
access.Spec.BucketRef = bName
access.Spec.Level = cephv1.AccessReadWrite
access.Spec.SecretName = credSecretName(obj)
return controllerutil.SetControllerReference(owner, access, b.Scheme)
}); err != nil {
return false, "", err
}
if access.Status.Phase != "Ready" || access.Status.SecretName == "" {
return false, "", nil
}
return true, access.Status.SecretName, nil
}
// ensureResticSecret creates (once) an owner-referenced Secret holding a random
// restic repository password under key "password".
func (b *baseReconciler) ensureResticSecret(ctx context.Context, owner client.Object) (string, error) {
name := resticSecretName(owner.GetName())
sec := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: owner.GetNamespace()}}
_, err := controllerutil.CreateOrUpdate(ctx, b.Client, sec, func() error {
sec.Type = corev1.SecretTypeOpaque
if _, ok := sec.Data["password"]; !ok {
pw, err := randomPassword(32)
if err != nil {
return err
}
if sec.Data == nil {
sec.Data = map[string][]byte{}
}
sec.Data["password"] = []byte(pw)
}
return controllerutil.SetControllerReference(owner, sec, b.Scheme)
})
return name, err
}
func randomPassword(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// deleteIfExists best-effort deletes obj, ignoring a NotFound result.
func deleteIfExists(ctx context.Context, c client.Client, obj client.Object) error {
return client.IgnoreNotFound(c.Delete(ctx, obj))
}
// bucketStackObjects lists the cephrgw CRs and credential Secret created for an
// object, for explicit purge-on-delete teardown.
func bucketStackObjects(ns, obj string) []client.Object {
meta := func(name string) metav1.ObjectMeta { return metav1.ObjectMeta{Name: name, Namespace: ns} }
return []client.Object{
&cephv1.BucketAccess{ObjectMeta: meta(accessName(obj))},
&cephv1.Bucket{ObjectMeta: meta(bucketName(ns, obj))},
&cephv1.ObjectStoreUser{ObjectMeta: meta(userName(obj))},
&corev1.Secret{ObjectMeta: meta(credSecretName(obj))},
&corev1.Secret{ObjectMeta: meta(resticSecretName(obj))},
}
}