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.
184 lines
6.4 KiB
Go
184 lines
6.4 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
cephv1 "git.unkin.net/unkin/cephrgw-operator/api/v1alpha1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
|
"k8s.io/client-go/tools/record"
|
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
)
|
|
|
|
func TestClusterReconcile(t *testing.T) {
|
|
requireEnvtest(t)
|
|
ctx := context.Background()
|
|
ns := newNamespace(t, ctx)
|
|
createDestinations(t, ctx, ns)
|
|
|
|
cluster := newCluster()
|
|
cluster.SetName("pg")
|
|
cluster.SetNamespace(ns)
|
|
cluster.SetAnnotations(map[string]string{
|
|
annSchedule: "@daily",
|
|
annDestination: "cephs3_ec4_1",
|
|
})
|
|
_ = unstructured.SetNestedField(cluster.Object, int64(1), "spec", "instances")
|
|
if err := k8sClient.Create(ctx, cluster); err != nil {
|
|
t.Fatalf("create cluster: %v", err)
|
|
}
|
|
|
|
r := &ClusterReconciler{baseReconciler{
|
|
Client: k8sClient,
|
|
Scheme: clientgoscheme.Scheme,
|
|
Recorder: record.NewFakeRecorder(16),
|
|
DestNamespace: ns,
|
|
DestConfigMap: "autobackup-destinations",
|
|
}}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: "pg"}}
|
|
|
|
// First reconcile provisions the bucket stack but blocks on access readiness.
|
|
if _, err := r.Reconcile(ctx, req); err != nil {
|
|
t.Fatalf("first reconcile: %v", err)
|
|
}
|
|
var access cephv1.BucketAccess
|
|
if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: accessName("pg")}, &access); err != nil {
|
|
t.Fatalf("BucketAccess not created: %v", err)
|
|
}
|
|
access.Status.Phase = "Ready"
|
|
access.Status.SecretName = credSecretName("pg")
|
|
if err := k8sClient.Status().Update(ctx, &access); err != nil {
|
|
t.Fatalf("update access status: %v", err)
|
|
}
|
|
|
|
if _, err := r.Reconcile(ctx, req); err != nil {
|
|
t.Fatalf("second reconcile: %v", err)
|
|
}
|
|
|
|
// Cluster barmanObjectStore patched.
|
|
got := newCluster()
|
|
if err := k8sClient.Get(ctx, req.NamespacedName, got); err != nil {
|
|
t.Fatalf("get cluster: %v", err)
|
|
}
|
|
path, _, _ := unstructured.NestedString(got.Object, "spec", "backup", "barmanObjectStore", "destinationPath")
|
|
if want := "s3://" + bucketName(ns, "pg"); path != want {
|
|
t.Errorf("destinationPath = %q, want %q", path, want)
|
|
}
|
|
endpoint, _, _ := unstructured.NestedString(got.Object, "spec", "backup", "barmanObjectStore", "endpointURL")
|
|
if endpoint != "https://s3.ceph.unkin.net" {
|
|
t.Errorf("endpointURL = %q", endpoint)
|
|
}
|
|
server, _, _ := unstructured.NestedString(got.Object, "spec", "backup", "barmanObjectStore", "serverName")
|
|
if server != "pg" {
|
|
t.Errorf("serverName = %q", server)
|
|
}
|
|
akName, _, _ := unstructured.NestedString(got.Object, "spec", "backup", "barmanObjectStore", "s3Credentials", "accessKeyId", "name")
|
|
if akName != credSecretName("pg") {
|
|
t.Errorf("accessKeyId secret name = %q", akName)
|
|
}
|
|
caName, _, _ := unstructured.NestedString(got.Object, "spec", "backup", "barmanObjectStore", "endpointCA", "name")
|
|
if caName != "vault-ca-cert" {
|
|
t.Errorf("endpointCA name = %q", caName)
|
|
}
|
|
rp, _, _ := unstructured.NestedString(got.Object, "spec", "backup", "retentionPolicy")
|
|
if rp != "30d" {
|
|
t.Errorf("retentionPolicy = %q", rp)
|
|
}
|
|
|
|
// ScheduledBackup created with 6-field schedule.
|
|
sb := newScheduledBackup()
|
|
if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: scheduleName("pg")}, sb); err != nil {
|
|
t.Fatalf("ScheduledBackup not created: %v", err)
|
|
}
|
|
sched, _, _ := unstructured.NestedString(sb.Object, "spec", "schedule")
|
|
if sched != "0 0 0 * * *" {
|
|
t.Errorf("scheduledbackup schedule = %q, want 0 0 0 * * *", sched)
|
|
}
|
|
method, _, _ := unstructured.NestedString(sb.Object, "spec", "method")
|
|
if method != "barmanObjectStore" {
|
|
t.Errorf("method = %q", method)
|
|
}
|
|
clusterName, _, _ := unstructured.NestedString(sb.Object, "spec", "cluster", "name")
|
|
if clusterName != "pg" {
|
|
t.Errorf("cluster.name = %q", clusterName)
|
|
}
|
|
owner, _, _ := unstructured.NestedString(sb.Object, "spec", "backupOwnerReference")
|
|
if owner != "self" {
|
|
t.Errorf("backupOwnerReference = %q", owner)
|
|
}
|
|
}
|
|
|
|
func TestClusterReconcileDestinationConflict(t *testing.T) {
|
|
requireEnvtest(t)
|
|
ctx := context.Background()
|
|
ns := newNamespace(t, ctx)
|
|
createDestinations(t, ctx, ns)
|
|
|
|
cluster := newCluster()
|
|
cluster.SetName("pg2")
|
|
cluster.SetNamespace(ns)
|
|
cluster.SetAnnotations(map[string]string{annSchedule: "@daily", annDestination: "cephs3_ec4_1"})
|
|
_ = unstructured.SetNestedField(cluster.Object, int64(1), "spec", "instances")
|
|
// User already configured a different destinationPath.
|
|
_ = unstructured.SetNestedField(cluster.Object, "s3://user-owned-bucket", "spec", "backup", "barmanObjectStore", "destinationPath")
|
|
if err := k8sClient.Create(ctx, cluster); err != nil {
|
|
t.Fatalf("create cluster: %v", err)
|
|
}
|
|
|
|
rec := record.NewFakeRecorder(16)
|
|
r := &ClusterReconciler{baseReconciler{
|
|
Client: k8sClient, Scheme: clientgoscheme.Scheme, Recorder: rec,
|
|
DestNamespace: ns, DestConfigMap: "autobackup-destinations",
|
|
}}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: "pg2"}}
|
|
|
|
if _, err := r.Reconcile(ctx, req); err != nil {
|
|
t.Fatalf("first reconcile: %v", err)
|
|
}
|
|
var access cephv1.BucketAccess
|
|
if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: accessName("pg2")}, &access); err != nil {
|
|
t.Fatalf("BucketAccess not created: %v", err)
|
|
}
|
|
access.Status.Phase = "Ready"
|
|
access.Status.SecretName = credSecretName("pg2")
|
|
if err := k8sClient.Status().Update(ctx, &access); err != nil {
|
|
t.Fatalf("update access status: %v", err)
|
|
}
|
|
if _, err := r.Reconcile(ctx, req); err != nil {
|
|
t.Fatalf("second reconcile: %v", err)
|
|
}
|
|
|
|
got := newCluster()
|
|
if err := k8sClient.Get(ctx, req.NamespacedName, got); err != nil {
|
|
t.Fatalf("get cluster: %v", err)
|
|
}
|
|
path, _, _ := unstructured.NestedString(got.Object, "spec", "backup", "barmanObjectStore", "destinationPath")
|
|
if path != "s3://user-owned-bucket" {
|
|
t.Errorf("operator clobbered user destinationPath: got %q", path)
|
|
}
|
|
|
|
// ScheduledBackup is still created; only the barman patch is skipped.
|
|
sb := newScheduledBackup()
|
|
if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: scheduleName("pg2")}, sb); err != nil {
|
|
t.Fatalf("ScheduledBackup should still be created: %v", err)
|
|
}
|
|
|
|
var sawConflict bool
|
|
for drained := false; !drained; {
|
|
select {
|
|
case e := <-rec.Events:
|
|
if strings.Contains(e, "DestinationConflict") {
|
|
sawConflict = true
|
|
}
|
|
default:
|
|
drained = true
|
|
}
|
|
}
|
|
if !sawConflict {
|
|
t.Errorf("expected a DestinationConflict warning event")
|
|
}
|
|
}
|