9da206dc7c
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.
141 lines
5.1 KiB
Go
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))},
|
|
}
|
|
}
|