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

179 lines
6.2 KiB
Go

package controller
import (
"context"
"errors"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
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"
)
// CNPG typed API is deliberately not imported: its module graph (barman-cloud,
// cnpg-i/gRPC, prometheus-operator) is heavy to vendor, so Clusters and
// ScheduledBackups are handled as unstructured objects.
var (
clusterGVK = schema.GroupVersionKind{Group: "postgresql.cnpg.io", Version: "v1", Kind: "Cluster"}
scheduledBackupGVK = schema.GroupVersionKind{Group: "postgresql.cnpg.io", Version: "v1", Kind: "ScheduledBackup"}
)
func newCluster() *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetGroupVersionKind(clusterGVK)
return u
}
func newScheduledBackup() *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetGroupVersionKind(scheduledBackupGVK)
return u
}
// ClusterReconciler backs up annotated CloudNativePG Clusters by provisioning an
// S3 bucket (via cephrgw), patching spec.backup.barmanObjectStore and creating a
// ScheduledBackup.
type ClusterReconciler struct {
baseReconciler
}
// +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=clusters,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=scheduledbackups,verbs=get;list;watch;create;update;patch;delete
func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
cluster := newCluster()
if err := r.Get(ctx, req.NamespacedName, cluster); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
schedule := cluster.GetAnnotations()[annSchedule]
if schedule == "" {
return r.teardown(ctx, cluster)
}
cnpgSchedule, err := ScheduleForCNPG(schedule)
if err != nil {
r.Recorder.Event(cluster, corev1.EventTypeWarning, "InvalidSchedule", err.Error())
return ctrl.Result{}, nil
}
destName := cluster.GetAnnotations()[annDestination]
if destName == "" {
r.Recorder.Event(cluster, 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(cluster, corev1.EventTypeWarning, "UnknownDestination", err.Error())
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
return ctrl.Result{}, err
}
ready, credSecret, err := r.ensureBucketStack(ctx, cluster, dest)
if err != nil {
return ctrl.Result{}, err
}
if !ready {
return ctrl.Result{RequeueAfter: requeueShort}, nil
}
if err := r.patchBarman(ctx, cluster, credSecret, dest); err != nil {
return ctrl.Result{}, err
}
if err := r.ensureScheduledBackup(ctx, cluster, cnpgSchedule); err != nil {
return ctrl.Result{}, err
}
logger.Info("cluster backup reconciled", "cluster", cluster.GetName(), "destination", destName)
return ctrl.Result{RequeueAfter: requeueSteady}, nil
}
func (r *ClusterReconciler) teardown(ctx context.Context, cluster *unstructured.Unstructured) (ctrl.Result, error) {
sb := newScheduledBackup()
sb.SetName(scheduleName(cluster.GetName()))
sb.SetNamespace(cluster.GetNamespace())
return ctrl.Result{}, deleteIfExists(ctx, r.Client, sb)
}
// patchBarman idempotently sets spec.backup.barmanObjectStore on the Cluster. If
// the user already set a different destinationPath it is left untouched and a
// Warning event is emitted instead of clobbering it.
func (r *ClusterReconciler) patchBarman(ctx context.Context, cluster *unstructured.Unstructured, credSecret string, dest Destination) error {
bucket := bucketName(cluster.GetNamespace(), cluster.GetName())
desiredPath := "s3://" + bucket
existing, _, _ := unstructured.NestedString(cluster.Object, "spec", "backup", "barmanObjectStore", "destinationPath")
if existing != "" && existing != desiredPath {
r.Recorder.Eventf(cluster, corev1.EventTypeWarning, "DestinationConflict",
"cluster already configured with destinationPath %q; not overwriting", existing)
return nil
}
patch := client.MergeFrom(cluster.DeepCopy())
barman := map[string]interface{}{
"destinationPath": desiredPath,
"endpointURL": dest.Endpoint,
"serverName": cluster.GetName(),
"s3Credentials": map[string]interface{}{
"accessKeyId": map[string]interface{}{"name": credSecret, "key": "AWS_ACCESS_KEY_ID"},
"secretAccessKey": map[string]interface{}{"name": credSecret, "key": "AWS_SECRET_ACCESS_KEY"},
},
}
if dest.EndpointCASecret != "" {
key := dest.EndpointCAKey
if key == "" {
key = "ca.crt"
}
barman["endpointCA"] = map[string]interface{}{"name": dest.EndpointCASecret, "key": key}
}
if err := unstructured.SetNestedMap(cluster.Object, barman, "spec", "backup", "barmanObjectStore"); err != nil {
return err
}
if rp, _, _ := unstructured.NestedString(cluster.Object, "spec", "backup", "retentionPolicy"); rp == "" {
if err := unstructured.SetNestedField(cluster.Object, "30d", "spec", "backup", "retentionPolicy"); err != nil {
return err
}
}
return r.Patch(ctx, cluster, patch)
}
func (r *ClusterReconciler) ensureScheduledBackup(ctx context.Context, cluster *unstructured.Unstructured, schedule string) error {
sb := newScheduledBackup()
sb.SetName(scheduleName(cluster.GetName()))
sb.SetNamespace(cluster.GetNamespace())
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, sb, func() error {
for _, f := range []struct {
value string
path []string
}{
{cluster.GetName(), []string{"spec", "cluster", "name"}},
{schedule, []string{"spec", "schedule"}},
{"barmanObjectStore", []string{"spec", "method"}},
{"self", []string{"spec", "backupOwnerReference"}},
} {
if err := unstructured.SetNestedField(sb.Object, f.value, f.path...); err != nil {
return err
}
}
return controllerutil.SetControllerReference(cluster, sb, r.Scheme)
})
return err
}
func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(newCluster()).
Owns(newScheduledBackup()).
Named("cluster-autobackup").
Complete(r)
}