Files
unkin-agent a3339a30b5
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Fix leader-election RBAC, Go version drift, PVC churn, cron names, apply order
Addresses PR review + acceptance findings:

- Add leader-election RBAC: namespaced Role (coordination leases +
  events) and RoleBinding to the operator ServiceAccount, so controllers
  actually start under --leader-elect instead of looping on "leases
  forbidden". Verified in a kind cluster: lease acquired, both
  controllers start workers.
- Align Go versions: bump Dockerfile.operator to golang:1.26-alpine and
  CI images to golang:1.26 to match go.mod (go 1.26.5); set
  GOTOOLCHAIN=local so the image build stays hermetic (no toolchain
  download).
- PVC controller: add an annotation predicate so only PVCs carrying (or
  transitioning off of) backups.unkin.net/schedule enqueue, eliminating
  reconcile churn from unannotated PVCs while keeping the teardown path.
- Cron validator: accept alphabetic month/day-of-week names (MON, JAN,
  MON-FRI) that k8up and CNPG's robfig/cron accept, still rejecting
  unknown names; add unit tests for both mapping paths.
- Deploy ordering: extract the Namespace into its own manifest and add a
  config/kustomization.yaml so `kubectl apply -k config` creates the
  namespace first; document it in the README.
- Note the credential-less push precedent (jellyfin-ha) in docker.yaml.
2026-08-14 00:33:49 +10:00

173 lines
6.4 KiB
Go

package controller
import (
"context"
"testing"
cephv1 "git.unkin.net/unkin/cephrgw-operator/api/v1alpha1"
k8upv1 "github.com/k8up-io/k8up/v2/api/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
func pvcWith(ann map[string]string) *corev1.PersistentVolumeClaim {
return &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "data", Namespace: "ns", Annotations: ann}}
}
func TestSchedulePredicate(t *testing.T) {
annotated := pvcWith(map[string]string{annSchedule: "@daily"})
plain := pvcWith(nil)
if schedulePredicate.Create(event.CreateEvent{Object: plain}) {
t.Error("create of unannotated PVC should be filtered out")
}
if !schedulePredicate.Create(event.CreateEvent{Object: annotated}) {
t.Error("create of annotated PVC should enqueue")
}
if schedulePredicate.Generic(event.GenericEvent{Object: plain}) {
t.Error("generic event for unannotated PVC should be filtered out")
}
// Annotation removed: old had it, new does not -> must still enqueue so
// teardown runs.
if !schedulePredicate.Update(event.UpdateEvent{ObjectOld: annotated, ObjectNew: plain}) {
t.Error("annotation removal should enqueue for teardown")
}
// Annotation added.
if !schedulePredicate.Update(event.UpdateEvent{ObjectOld: plain, ObjectNew: annotated}) {
t.Error("annotation addition should enqueue")
}
// Neither old nor new annotated -> no enqueue.
if schedulePredicate.Update(event.UpdateEvent{ObjectOld: plain, ObjectNew: plain}) {
t.Error("update of unannotated PVC should be filtered out")
}
}
func TestPVCReconcile(t *testing.T) {
requireEnvtest(t)
ctx := context.Background()
ns := newNamespace(t, ctx)
createDestinations(t, ctx, ns)
pvc := &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
Name: "data",
Namespace: ns,
Annotations: map[string]string{
annSchedule: "@daily",
annDestination: "cephs3_ec4_1",
},
},
Spec: corev1.PersistentVolumeClaimSpec{
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("1Gi")},
},
},
}
if err := k8sClient.Create(ctx, pvc); err != nil {
t.Fatalf("create pvc: %v", err)
}
r := &PVCReconciler{baseReconciler{
Client: k8sClient,
Scheme: clientgoscheme.Scheme,
Recorder: record.NewFakeRecorder(16),
DestNamespace: ns,
DestConfigMap: "autobackup-destinations",
}}
req := reconcile.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: "data"}}
res, err := r.Reconcile(ctx, req)
if err != nil {
t.Fatalf("first reconcile: %v", err)
}
if res.RequeueAfter != requeueShort {
t.Errorf("expected requeueShort while access not ready, got %v", res.RequeueAfter)
}
// Bucket stack was created and wired.
var user cephv1.ObjectStoreUser
if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: userName("data")}, &user); err != nil {
t.Fatalf("ObjectStoreUser not created: %v", err)
}
var bucket cephv1.Bucket
if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: bucketName(ns, "data")}, &bucket); err != nil {
t.Fatalf("Bucket not created: %v", err)
}
if bucket.Spec.OwnerRef != userName("data") || !bucket.Spec.Versioning || bucket.Spec.PlacementTarget != "cephs3_ec4_1" {
t.Errorf("unexpected bucket spec: %+v", bucket.Spec)
}
if !bucket.Spec.RetainOnDelete {
t.Errorf("expected RetainOnDelete true for data safety")
}
var access cephv1.BucketAccess
if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: accessName("data")}, &access); err != nil {
t.Fatalf("BucketAccess not created: %v", err)
}
if access.Spec.Level != cephv1.AccessReadWrite || access.Spec.SecretName != credSecretName("data") {
t.Errorf("unexpected access spec: %+v", access.Spec)
}
// No Schedule until the BucketAccess is Ready.
var sched k8upv1.Schedule
if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: scheduleName("data")}, &sched); !apierrors.IsNotFound(err) {
t.Fatalf("Schedule should not exist before access Ready, got err=%v", err)
}
// Simulate cephrgw marking the access Ready.
access.Status.Phase = "Ready"
access.Status.SecretName = credSecretName("data")
if err := k8sClient.Status().Update(ctx, &access); err != nil {
t.Fatalf("update access status: %v", err)
}
res, err = r.Reconcile(ctx, req)
if err != nil {
t.Fatalf("second reconcile: %v", err)
}
if res.RequeueAfter != requeueSteady {
t.Errorf("expected requeueSteady after ready, got %v", res.RequeueAfter)
}
// Restic password secret exists with a password.
var restic corev1.Secret
if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: resticSecretName("data")}, &restic); err != nil {
t.Fatalf("restic secret not created: %v", err)
}
if len(restic.Data["password"]) == 0 {
t.Errorf("restic secret has empty password")
}
// Schedule created and wired to the cephrgw + restic secrets.
if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: scheduleName("data")}, &sched); err != nil {
t.Fatalf("Schedule not created: %v", err)
}
if sched.Spec.Backend == nil || sched.Spec.Backend.S3 == nil {
t.Fatalf("Schedule backend not set: %+v", sched.Spec)
}
s3 := sched.Spec.Backend.S3
if s3.Bucket != bucketName(ns, "data") || s3.Endpoint != "https://s3.ceph.unkin.net" {
t.Errorf("unexpected S3 backend: %+v", s3)
}
if s3.AccessKeyIDSecretRef.Name != credSecretName("data") || s3.AccessKeyIDSecretRef.Key != "AWS_ACCESS_KEY_ID" {
t.Errorf("unexpected access key ref: %+v", s3.AccessKeyIDSecretRef)
}
if sched.Spec.Backend.RepoPasswordSecretRef.Name != resticSecretName("data") {
t.Errorf("unexpected repo password ref: %+v", sched.Spec.Backend.RepoPasswordSecretRef)
}
if sched.Spec.Backup == nil || string(sched.Spec.Backup.Schedule) != "@daily" {
t.Errorf("unexpected backup schedule: %+v", sched.Spec.Backup)
}
vols := sched.Spec.Backup.Volumes
if vols == nil || len(*vols) != 1 || (*vols)[0].PersistentVolumeClaim == nil || (*vols)[0].PersistentVolumeClaim.ClaimName != "data" {
t.Errorf("expected schedule scoped to PVC data, got %+v", vols)
}
}