Files
cozystack/internal/backupcontroller/psmdbstrategy_controller_test.go
Andrey Kolkov 6555ae16b8 fix(mongodb): start a backup only once the operator has pushed the storage into pbm
The wait after a storage injection watched the cluster's spec, which is
not what pbm writes to. At the pinned operator version the backup
reconciler hands pbm only a storage name and records status.s3 from the
spec entry it read, while the storage pbm uses is whatever the cluster
reconciler last pushed into pbm's own config; that push flips PBMReady
False and back to True and the reconcile stamps observedGeneration when
it ends. A backup started between the apply and that push dumped to the
previous coordinates while its Backup recorded the new ones, so both its
restore and its prune pointed at the wrong bucket. The wait was also no
wait at all: the status write that named it re-queued the job at once,
and the live read always showed the apply. The mint now waits for
observedGeneration to reach the cluster's generation and for PBMReady
to be True, and fails at the backup deadline if the operator never gets
there.

The strategy, app and cluster reads still ran ahead of the operator CR
lookup and were bounded by the backup deadline, so a strategy absent for
one reconcile 40 minutes into a streaming dump failed the job and left
the finalized CR unreachable. The CR is resolved first now; with one in
hand the cluster is not read at all, and the reads the artifact needs
are bounded by the running ceiling, past which the job fails naming the
CR left in place.

The settle check treated a field the entry lacked as agreement, so a
strategy that newly set forcePathStyle minted in the same pass as the
apply that changed addressing. A missing field now counts as its zero
value, which keeps an entry the operator rewrote without an empty region
settled while catching a value the apply adds; the storage type is
compared outright.

The runbook still said the finalizer keeps a Plan from accumulating
objects, the RBAC comment still said the timeout cancel runs on either
flow, and four wait messages read a duration as a delay. All corrected;
the runbook also describes the pbm wait.

Assisted-by: LLM
Signed-off-by: Andrey Kolkov <androndo@gmail.com>
2026-09-29 11:02:57 +04:00

3968 lines
186 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: Apache-2.0
package backupcontroller
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
dynamicfake "k8s.io/client-go/dynamic/fake"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/tools/record"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
strategyv1alpha1 "github.com/cozystack/cozystack/api/backups/strategy/v1alpha1"
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
"github.com/cozystack/cozystack/internal/backupcontroller/mongodbapp"
"github.com/cozystack/cozystack/internal/backupcontroller/psmdbtypes"
)
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
func TestValidateMongoDBApplicationRef(t *testing.T) {
apps := mongodbapp.GroupName
other := "other.example.com"
cases := []struct {
name string
ref corev1.TypedLocalObjectReference
wantErr bool
}{
{"happy path with apps group", corev1.TypedLocalObjectReference{Kind: "MongoDB", Name: "x", APIGroup: &apps}, false},
{"empty apiGroup is accepted", corev1.TypedLocalObjectReference{Kind: "MongoDB", Name: "x"}, false},
{"foreign apiGroup rejected", corev1.TypedLocalObjectReference{Kind: "MongoDB", Name: "x", APIGroup: &other}, true},
{"wrong kind rejected", corev1.TypedLocalObjectReference{Kind: "MariaDB", Name: "x", APIGroup: &apps}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateMongoDBApplicationRef(tc.ref)
if tc.wantErr && err == nil {
t.Fatalf("expected validation error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
})
}
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
func TestPsmdbStorageAndTypeDefaults(t *testing.T) {
if got := psmdbStorageNameOrDefault(""); got != psmdbDefaultStorageName {
t.Errorf("psmdbStorageNameOrDefault(\"\"): got %q want %q", got, psmdbDefaultStorageName)
}
if got := psmdbStorageNameOrDefault("custom"); got != "custom" {
t.Errorf("psmdbStorageNameOrDefault(custom): got %q", got)
}
if got := psmdbBackupTypeOrDefault(""); got != psmdbtypes.BackupTypeLogical {
t.Errorf("psmdbBackupTypeOrDefault(\"\"): got %q want logical", got)
}
if got := psmdbBackupTypeOrDefault("logical"); got != "logical" {
t.Errorf("psmdbBackupTypeOrDefault(logical): got %q", got)
}
}
// ---------------------------------------------------------------------------
// Precondition gate
// ---------------------------------------------------------------------------
func TestPsmdbBackupPrecondition(t *testing.T) {
t.Run("backups disabled", func(t *testing.T) {
cluster := &psmdbtypes.PerconaServerMongoDB{
Spec: psmdbtypes.PerconaServerMongoDBSpec{
Backup: psmdbtypes.PerconaServerMongoDBBackupConfig{Enabled: false},
},
}
if msg := psmdbBackupPrecondition(cluster, "s3-storage"); msg == "" {
t.Fatal("expected a precondition message when backups are disabled")
}
})
t.Run("storage not declared", func(t *testing.T) {
cluster := &psmdbtypes.PerconaServerMongoDB{
Spec: psmdbtypes.PerconaServerMongoDBSpec{
Backup: psmdbtypes.PerconaServerMongoDBBackupConfig{
Enabled: true,
Storages: map[string]runtime.RawExtension{"other": {}},
},
},
}
if msg := psmdbBackupPrecondition(cluster, "s3-storage"); msg == "" {
t.Fatal("expected a precondition message when the named storage is missing")
}
})
t.Run("ready", func(t *testing.T) {
cluster := &psmdbtypes.PerconaServerMongoDB{
Spec: psmdbtypes.PerconaServerMongoDBSpec{
Backup: psmdbtypes.PerconaServerMongoDBBackupConfig{
Enabled: true,
Storages: map[string]runtime.RawExtension{"s3-storage": {}},
},
},
}
if msg := psmdbBackupPrecondition(cluster, "s3-storage"); msg != "" {
t.Fatalf("expected no precondition message, got %q", msg)
}
})
}
// ---------------------------------------------------------------------------
// Restore credentials re-pointed at the target cluster (DR after source delete)
// ---------------------------------------------------------------------------
// TestPsmdbTargetCredentialsSecret pins the fix for the disaster-recovery gap:
// a restore into a differently-named target must authenticate with the TARGET
// cluster's own S3 credentials, because the source release's -s3-creds Secret is
// deleted together with the source app. The selector prefers the source's
// storage name, then the chart default, then a sole storage.
func TestPsmdbTargetCredentialsSecret(t *testing.T) {
s3Storage := func(bucket, secret string) runtime.RawExtension {
return runtime.RawExtension{Raw: []byte(fmt.Sprintf(`{"type":"s3","s3":{"bucket":%q,"credentialsSecret":%q}}`, bucket, secret))}
}
clusterWith := func(storages map[string]runtime.RawExtension) *psmdbtypes.PerconaServerMongoDB {
return &psmdbtypes.PerconaServerMongoDB{
Spec: psmdbtypes.PerconaServerMongoDBSpec{
Backup: psmdbtypes.PerconaServerMongoDBBackupConfig{Storages: storages},
},
}
}
cases := []struct {
name string
storages map[string]runtime.RawExtension
preferred string
wantBucket string
want string
}{
{
name: "preferred storage on the source bucket wins",
storages: map[string]runtime.RawExtension{"s3-storage": s3Storage("shared", "target-creds"), "other": s3Storage("shared", "other-creds")},
preferred: "s3-storage",
wantBucket: "shared",
want: "target-creds",
},
{
name: "falls back to chart default when preferred absent",
storages: map[string]runtime.RawExtension{"s3-storage": s3Storage("shared", "target-creds")},
preferred: "nonexistent",
wantBucket: "shared",
want: "target-creds",
},
{
name: "sole non-default storage on the source bucket is used",
storages: map[string]runtime.RawExtension{"custom": s3Storage("shared", "custom-creds")},
wantBucket: "shared",
want: "custom-creds",
},
{
name: "different bucket is NOT adopted (cross-flow guard)",
storages: map[string]runtime.RawExtension{"s3-storage": s3Storage("platform-bucket", "cozy-backups-creds")},
preferred: "s3-storage",
wantBucket: "tenant-bucket",
want: "",
},
{
name: "empty wantBucket yields empty (unknown source bucket, no swap)",
storages: map[string]runtime.RawExtension{"s3-storage": s3Storage("shared", "target-creds")},
preferred: "s3-storage",
wantBucket: "",
want: "",
},
{
name: "ambiguous (multiple, none default, no preferred) yields empty",
storages: map[string]runtime.RawExtension{"a": s3Storage("shared", "a-creds"), "b": s3Storage("shared", "b-creds")},
wantBucket: "shared",
want: "",
},
{
name: "no storages yields empty",
storages: nil,
wantBucket: "shared",
want: "",
},
{
name: "non-s3 storage yields empty",
storages: map[string]runtime.RawExtension{"s3-storage": {Raw: []byte(`{"type":"azure","azure":{"container":"c"}}`)}},
wantBucket: "shared",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := psmdbTargetCredentialsSecret(clusterWith(tc.storages), tc.preferred, tc.wantBucket); got != tc.want {
t.Errorf("psmdbTargetCredentialsSecret: got %q want %q", got, tc.want)
}
})
}
}
// TestMongoDBRestoreCredentialsSecret pins the restore credential-swap decision
// across the four {legacy,system}×{legacy,system} flow combinations, so neither
// cross-flow guard can be dropped unnoticed: the projected-secret skip (a system
// source keeps cozy-backups-creds rather than adopting a legacy target's own
// Secret) and the same-bucket match (a legacy source is not handed a credential
// for a bucket its archive does not live in).
func TestMongoDBRestoreCredentialsSecret(t *testing.T) {
s3Storage := func(bucket, secret string) runtime.RawExtension {
return runtime.RawExtension{Raw: []byte(fmt.Sprintf(`{"type":"s3","s3":{"bucket":%q,"credentialsSecret":%q}}`, bucket, secret))}
}
target := func(storages map[string]runtime.RawExtension) *psmdbtypes.PerconaServerMongoDB {
return &psmdbtypes.PerconaServerMongoDB{
Spec: psmdbtypes.PerconaServerMongoDBSpec{
Backup: psmdbtypes.PerconaServerMongoDBBackupConfig{Storages: storages},
},
}
}
src := func(bucket, cred string) *psmdbtypes.BackupSource {
return &psmdbtypes.BackupSource{StorageName: "s3-storage", S3: &psmdbtypes.BackupStorageS3{Bucket: bucket, CredentialsSecret: cred}}
}
cases := []struct {
name string
source *psmdbtypes.BackupSource
target *psmdbtypes.PerconaServerMongoDB
want string
}{
{
name: "legacy source -> legacy target on the same bucket: swap to target creds (DR after source delete)",
source: src("tenant-bucket", "src-s3-creds"),
target: target(map[string]runtime.RawExtension{"s3-storage": s3Storage("tenant-bucket", "target-s3-creds")}),
want: "target-s3-creds",
},
{
name: "legacy source -> legacy target on a different bucket: keep source creds (no cross-bucket swap)",
source: src("tenant-bucket", "src-s3-creds"),
target: target(map[string]runtime.RawExtension{"s3-storage": s3Storage("other-bucket", "target-s3-creds")}),
want: "src-s3-creds",
},
{
name: "system source -> legacy target: keep projected cozy-backups-creds (outlives source, reads the platform bucket)",
source: src("cozy-backups-PLATFORM", "cozy-backups-creds"),
target: target(map[string]runtime.RawExtension{"s3-storage": s3Storage("tenant-bucket", "target-s3-creds")}),
want: "cozy-backups-creds",
},
{
name: "legacy source -> system-bucket target: keep source creds (target storage is on the platform bucket, not the archive's)",
source: src("tenant-bucket", "src-s3-creds"),
target: target(map[string]runtime.RawExtension{"s3-storage": s3Storage("cozy-backups-PLATFORM", "cozy-backups-creds")}),
want: "src-s3-creds",
},
{
// Reaches the projected-secret early return specifically: the target
// declares a storage on the SAME bucket as the archive under a
// different credentialsSecret, so without the "already cozy-backups-
// creds" short-circuit the same-bucket swap would re-point the restore
// at the operator-managed secret. Keeping the projected secret is
// correct — it outlives the source app and reads the platform bucket.
name: "system source -> target with same-bucket storage under other creds: keep projected cozy-backups-creds",
source: src("cozy-backups-PLATFORM", "cozy-backups-creds"),
target: target(map[string]runtime.RawExtension{"s3-storage": s3Storage("cozy-backups-PLATFORM", "operator-managed-creds")}),
want: "cozy-backups-creds",
},
{
name: "no s3 source: empty (caller keeps the source reference)",
source: &psmdbtypes.BackupSource{StorageName: "s3-storage"},
target: target(map[string]runtime.RawExtension{"s3-storage": s3Storage("tenant-bucket", "target-s3-creds")}),
want: "",
},
{
// Source archive with no bucket recorded (wantBucket==""): the swap
// must be refused even against a lone target storage that also has an
// empty bucket, keeping the source reference. Pins the wantBucket==""
// short-circuit — without it that sole storage would be adopted.
name: "no-bucket source: keep source creds against an empty-bucket target storage",
source: src("", "src-s3-creds"),
target: target(map[string]runtime.RawExtension{"only": s3Storage("", "empty-bucket-creds")}),
want: "src-s3-creds",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := mongodbRestoreCredentialsSecret(tc.source, tc.target); got != tc.want {
t.Errorf("mongodbRestoreCredentialsSecret: got %q want %q", got, tc.want)
}
})
}
}
// ---------------------------------------------------------------------------
// Backup-side ensure idempotency + CR shape
// ---------------------------------------------------------------------------
func TestEnsureMongoDBBackup_IdempotentByLabel(t *testing.T) {
c := newMongoDBStrategyTestClient(t)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
job := newMongoDBBackupJob("bj-1", "tenant")
if err := c.Create(context.Background(), job); err != nil {
t.Fatalf("seed BackupJob: %v", err)
}
rendered := newRenderedMongoDBTemplate()
cluster := mongodbNameForApp("mongodb-src")
first, err := r.ensureMongoDBBackup(context.Background(), job, cluster, "s3-storage", rendered, false)
if err != nil {
t.Fatalf("first ensureMongoDBBackup: %v", err)
}
second, err := r.ensureMongoDBBackup(context.Background(), job, cluster, "s3-storage", rendered, false)
if err != nil {
t.Fatalf("second ensureMongoDBBackup: %v", err)
}
if first.Name != second.Name {
t.Errorf("expected idempotent reuse: first=%q second=%q", first.Name, second.Name)
}
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list backups: %v", err)
}
if len(list.Items) != 1 {
t.Fatalf("expected exactly one operator Backup CR, got %d", len(list.Items))
}
got := &list.Items[0]
// The cozystack mongodb ApplicationDefinition prefixes the release name with
// "mongodb-", so a BackupJob targeting applicationRef.name=mongodb-src is
// reconciled against psmdb.percona.com/PerconaServerMongoDB named
// mongodb-mongodb-src.
if got.Spec.ClusterName != "mongodb-mongodb-src" {
t.Errorf("ClusterName: got %q want mongodb-mongodb-src", got.Spec.ClusterName)
}
if got.Spec.StorageName != "s3-storage" {
t.Errorf("StorageName: got %q want s3-storage", got.Spec.StorageName)
}
if got.Spec.Type != psmdbtypes.BackupTypeLogical {
t.Errorf("Type: got %q want logical", got.Spec.Type)
}
if got.Spec.CompressionType != "gzip" {
t.Errorf("CompressionType: got %q want gzip", got.Spec.CompressionType)
}
if got.Labels[backupsv1alpha1.OwningJobNameLabel] != "bj-1" {
t.Errorf("OwningJobName label missing or wrong: %v", got.Labels)
}
// Legacy flow: the archive is the tenant's, so the driver must NOT stamp the
// delete-backup finalizer that would prune it on CR deletion.
for _, f := range got.Finalizers {
if f == psmdbDeleteBackupFinalizer {
t.Errorf("legacy backup must not carry %q finalizer; got %v", psmdbDeleteBackupFinalizer, got.Finalizers)
}
}
}
func TestEnsureMongoDBBackup_SystemBucketStampsDeleteFinalizer(t *testing.T) {
c := newMongoDBStrategyTestClient(t)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
job := newMongoDBBackupJob("bj-sb", "tenant")
if err := c.Create(context.Background(), job); err != nil {
t.Fatalf("seed BackupJob: %v", err)
}
got, err := r.ensureMongoDBBackup(context.Background(), job, "mongodb-app", "s3-storage", newRenderedMongoDBTemplate(), true)
if err != nil {
t.Fatalf("ensureMongoDBBackup: %v", err)
}
found := false
for _, f := range got.Finalizers {
if f == psmdbDeleteBackupFinalizer {
found = true
}
}
if !found {
// Without it, deleting the Backup would orphan the object in the shared
// cozy-backups bucket forever.
t.Errorf("system-bucket backup must carry %q finalizer so its archive is pruned on delete; got %v", psmdbDeleteBackupFinalizer, got.Finalizers)
}
}
// ---------------------------------------------------------------------------
// Restore-side ensure idempotency + backupSource wiring
// ---------------------------------------------------------------------------
func TestEnsureMongoDBRestore_IdempotentByLabel(t *testing.T) {
c := newMongoDBStrategyTestClient(t)
r := &RestoreJobReconciler{Client: c, Scheme: c.Scheme()}
rj := newMongoDBRestoreJob("rj-1", "tenant")
if err := c.Create(context.Background(), rj); err != nil {
t.Fatalf("seed RestoreJob: %v", err)
}
fps := true
source := &psmdbtypes.BackupSource{
Type: psmdbtypes.BackupTypeLogical,
Destination: "s3://bkt/mongodb-src/2026-08-05T00:00:00Z",
S3: &psmdbtypes.BackupStorageS3{
Bucket: "bkt",
CredentialsSecret: "mongodb-mongodb-src-s3-creds",
EndpointURL: "https://seaweedfs-s3.tenant-root:8333",
ForcePathStyle: &fps,
},
}
first, err := r.ensureMongoDBRestore(context.Background(), rj, "mongodb-mongodb-target", source, nil)
if err != nil {
t.Fatalf("first ensureMongoDBRestore: %v", err)
}
second, err := r.ensureMongoDBRestore(context.Background(), rj, "mongodb-mongodb-target", source, nil)
if err != nil {
t.Fatalf("second ensureMongoDBRestore: %v", err)
}
if first.Name != second.Name {
t.Errorf("expected idempotent reuse: first=%q second=%q", first.Name, second.Name)
}
list := &psmdbtypes.PerconaServerMongoDBRestoreList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list restores: %v", err)
}
if len(list.Items) != 1 {
t.Fatalf("expected exactly one operator Restore CR, got %d", len(list.Items))
}
got := &list.Items[0]
if got.Spec.ClusterName != "mongodb-mongodb-target" {
t.Errorf("ClusterName: got %q want mongodb-mongodb-target", got.Spec.ClusterName)
}
if got.Spec.BackupSource == nil || got.Spec.BackupSource.Destination != source.Destination {
t.Errorf("BackupSource mismatch: %#v", got.Spec.BackupSource)
}
if got.Spec.BackupSource.S3 == nil || got.Spec.BackupSource.S3.Bucket != "bkt" {
t.Errorf("BackupSource.S3 mismatch: %#v", got.Spec.BackupSource.S3)
}
}
// ---------------------------------------------------------------------------
// resolveMongoDBRestoreTarget (in-place vs to-copy)
// ---------------------------------------------------------------------------
func TestResolveMongoDBRestoreTarget(t *testing.T) {
apps := mongodbapp.GroupName
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "src-bk"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{
Kind: "MongoDB", Name: "mongodb-src", APIGroup: &apps,
},
},
}
t.Run("in-place: missing targetApplicationRef inherits source", func(t *testing.T) {
rj := &backupsv1alpha1.RestoreJob{Spec: backupsv1alpha1.RestoreJobSpec{}}
r := &RestoreJobReconciler{}
got := r.resolveMongoDBRestoreTarget(rj, backup)
if got.AppName != "mongodb-src" || got.Kind != "MongoDB" {
t.Errorf("in-place target: got %+v want AppName=mongodb-src Kind=MongoDB", got)
}
})
t.Run("to-copy: targetApplicationRef wins over source", func(t *testing.T) {
rj := &backupsv1alpha1.RestoreJob{
Spec: backupsv1alpha1.RestoreJobSpec{
TargetApplicationRef: &corev1.TypedLocalObjectReference{
Kind: "MongoDB", Name: "mongodb-target", APIGroup: &apps,
},
},
}
r := &RestoreJobReconciler{}
got := r.resolveMongoDBRestoreTarget(rj, backup)
if got.AppName != "mongodb-target" {
t.Errorf("to-copy AppName: got %q want mongodb-target", got.AppName)
}
})
}
// ---------------------------------------------------------------------------
// Restore options + PITR
// ---------------------------------------------------------------------------
func TestParseMongoDBRestoreOptionsAndPITR(t *testing.T) {
t.Run("empty options are permissive", func(t *testing.T) {
o, unknown, err := parseMongoDBRestoreOptions(nil)
if err != nil {
t.Fatalf("parse nil options: %v", err)
}
if len(unknown) != 0 {
t.Errorf("no unknown keys expected, got %v", unknown)
}
if o.effectiveRestoreDeadline() != psmdbDefaultRestoreDeadline {
t.Errorf("default deadline mismatch: %v", o.effectiveRestoreDeadline())
}
pitr, err := o.pitrSpec()
if err != nil || pitr != nil {
t.Errorf("no pitr expected: pitr=%v err=%v", pitr, err)
}
})
t.Run("malformed json errors", func(t *testing.T) {
if _, _, err := parseMongoDBRestoreOptions(&runtime.RawExtension{Raw: []byte("{not-json")}); err == nil {
t.Fatal("expected decode error")
}
})
t.Run("known keys parse with no unknowns", func(t *testing.T) {
o, unknown, err := parseMongoDBRestoreOptions(&runtime.RawExtension{
Raw: []byte(`{"recoveryTime":"2026-08-05T12:34:56Z","restoreTimeoutSeconds":600}`),
})
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(unknown) != 0 {
t.Errorf("expected no unknown keys, got %v", unknown)
}
if o.RecoveryTime != "2026-08-05T12:34:56Z" || o.RestoreTimeoutSeconds != 600 {
t.Errorf("options mismatch: %#v", o)
}
})
t.Run("unknown keys are reported (typo guard)", func(t *testing.T) {
_, unknown, err := parseMongoDBRestoreOptions(&runtime.RawExtension{
Raw: []byte(`{"recoverytime":"oops","bogus":1}`),
})
if err != nil {
t.Fatalf("parse: %v", err)
}
// sorted: bogus, recoverytime
if len(unknown) != 2 || unknown[0] != "bogus" || unknown[1] != "recoverytime" {
t.Errorf("unknown keys: got %v want [bogus recoverytime]", unknown)
}
})
t.Run("recoveryTime RFC3339 maps to psmdb date in UTC", func(t *testing.T) {
// A non-UTC offset must be normalised to UTC in the psmdb date format.
o := MongoDBRestoreOptions{RecoveryTime: "2026-08-05T14:34:56+02:00"}
pitr, err := o.pitrSpec()
if err != nil {
t.Fatalf("pitrSpec: %v", err)
}
if pitr == nil || pitr.Type != "date" || pitr.Date != "2026-08-05 12:34:56" {
t.Errorf("pitr mismatch: %#v (want date=2026-08-05 12:34:56)", pitr)
}
})
t.Run("empty recoveryTime yields no pitr (snapshot restore)", func(t *testing.T) {
pitr, err := MongoDBRestoreOptions{}.pitrSpec()
if err != nil || pitr != nil {
t.Errorf("expected nil pitr: pitr=%#v err=%v", pitr, err)
}
})
t.Run("malformed recoveryTime errors terminally", func(t *testing.T) {
if _, err := (MongoDBRestoreOptions{RecoveryTime: "2026-08-05 12:34:56"}).pitrSpec(); err == nil {
t.Fatal("expected error for non-RFC3339 recoveryTime")
}
})
t.Run("custom deadline honoured", func(t *testing.T) {
o := MongoDBRestoreOptions{RestoreTimeoutSeconds: 120}
if o.effectiveRestoreDeadline().Seconds() != 120 {
t.Errorf("deadline: got %v want 120s", o.effectiveRestoreDeadline())
}
})
// A non-string recoveryTime (spec.options is free-form runtime.RawExtension,
// so {"recoveryTime": 20260805} passes admission) must be treated as a
// load-bearing decode failure: the caller fails the RestoreJob terminally
// rather than silently degrading the PITR request to a snapshot restore. The
// two conditions the caller ANDs are (a) the typed decode errors and (b) the
// recoveryTime key is present.
t.Run("non-string recoveryTime is a load-bearing decode failure (major-2 guard)", func(t *testing.T) {
opts := &runtime.RawExtension{Raw: []byte(`{"recoveryTime":20260805}`)}
if _, _, err := parseMongoDBRestoreOptions(opts); err == nil {
t.Fatal("expected a typed-decode error for a numeric recoveryTime")
}
if !restoreOptionsCarryRecoveryTimeKey(opts) {
t.Fatal("expected the recoveryTime key to be detected so the caller fails terminally")
}
})
t.Run("recoveryTime-key detection is scoped to object blobs carrying the key", func(t *testing.T) {
if restoreOptionsCarryRecoveryTimeKey(nil) {
t.Error("nil options must report no recoveryTime key")
}
if restoreOptionsCarryRecoveryTimeKey(&runtime.RawExtension{Raw: []byte(`{"restoreTimeoutSeconds":30}`)}) {
t.Error("a blob without recoveryTime must report no key (benign fall-back applies)")
}
if restoreOptionsCarryRecoveryTimeKey(&runtime.RawExtension{Raw: []byte(`"garbage`)}) {
t.Error("a non-object blob must report no key (no recoveryTime to honour)")
}
})
}
// ---------------------------------------------------------------------------
// Snapshot round-trip
// ---------------------------------------------------------------------------
func TestMarshalUnmarshalMongoDBBackupSnapshot_RoundTrip(t *testing.T) {
fps := true
mdbBackup := &psmdbtypes.PerconaServerMongoDBBackup{
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
Destination: "s3://bkt/mongodb-src/2026-08-05T00:00:00Z",
S3: &psmdbtypes.BackupStorageS3{
Bucket: "bkt",
CredentialsSecret: "mongodb-mongodb-src-s3-creds",
EndpointURL: "https://seaweedfs-s3.tenant-root:8333",
ForcePathStyle: &fps,
},
},
}
rendered := &strategyv1alpha1.MongoDBTemplate{Type: "logical"}
raw, err := marshalMongoDBBackupSnapshot(mdbBackup, rendered, "s3-storage", map[string]string{"k": "v"}, false)
if err != nil {
t.Fatalf("marshal: %v", err)
}
snap, err := unmarshalMongoDBBackupSnapshot(raw)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if snap == nil || snap.Destination != mdbBackup.Status.Destination {
t.Fatalf("destination round-trip: %#v", snap)
}
if snap.Kind != psmdbBackupSnapshotKind || snap.StorageName != "s3-storage" {
t.Errorf("snapshot metadata mismatch: %#v", snap)
}
if snap.S3 == nil || snap.S3.CredentialsSecret != "mongodb-mongodb-src-s3-creds" {
t.Errorf("snapshot S3 mismatch: %#v", snap.S3)
}
// Secret-handling contract: the snapshot must carry only a Secret NAME,
// never a raw credential. There is no field on BackupStorageS3 for a key,
// so this is structurally guaranteed; assert the reference is preserved.
if snap.S3.Bucket != "bkt" {
t.Errorf("snapshot bucket mismatch: %q", snap.S3.Bucket)
}
}
// TestMarshalMongoDBBackupSnapshot_SystemBucketFallback guards the
// useSystemBucket restore path: when the operator Backup status carries no S3
// echo, the snapshot must fall back to the strategy's injected coordinates
// (rendered.S3) so restore can rebuild backupSource - with credentialsSecret
// defaulting to cozy-backups-creds.
func TestMarshalMongoDBBackupSnapshot_SystemBucketFallback(t *testing.T) {
mdbBackup := &psmdbtypes.PerconaServerMongoDBBackup{
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
Destination: "s3://cozybkt/tenant-root/mongodb-src/2026-08-26T00:00:00Z",
// S3 intentionally nil: operator did not echo storage config.
},
}
rendered := &strategyv1alpha1.MongoDBTemplate{
Type: "logical",
S3: &strategyv1alpha1.MongoDBStorageS3{
Bucket: "cozybkt",
EndpointURL: "https://s3.example.org",
Prefix: "tenant-root/mongodb-src",
Region: "us-east-1",
// CredentialsSecret intentionally empty -> defaults below.
},
}
raw, err := marshalMongoDBBackupSnapshot(mdbBackup, rendered, "s3-storage", nil, true)
if err != nil {
t.Fatalf("marshal: %v", err)
}
snap, err := unmarshalMongoDBBackupSnapshot(raw)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if snap.S3 == nil {
t.Fatal("snapshot S3 must be populated from rendered.S3 when the operator echo is empty")
}
if snap.S3.CredentialsSecret != "cozy-backups-creds" {
t.Errorf("credentialsSecret must default to cozy-backups-creds; got %q", snap.S3.CredentialsSecret)
}
if snap.S3.EndpointURL != "https://s3.example.org" || snap.S3.Bucket != "cozybkt" {
t.Errorf("snapshot S3 coords mismatch: %#v", snap.S3)
}
}
// TestMarshalMongoDBBackupSnapshot_LegacyNoFallback pins the other half of the
// gate: a legacy app (useSystemBucket=false) whose operator echoed no S3 must
// leave the snapshot S3 nil even though the shared cozy-default strategy now
// carries platform coordinates. Recording the platform bucket for an archive
// the tenant wrote to its own bucket would send restore to the wrong place.
func TestMarshalMongoDBBackupSnapshot_LegacyNoFallback(t *testing.T) {
mdbBackup := &psmdbtypes.PerconaServerMongoDBBackup{
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
Destination: "s3://tenant-own-bucket/mongodb-src/2026-08-26T00:00:00Z",
// S3 nil: operator echoed nothing, as on a legacy app whose storage
// lives on the app CR.
},
}
rendered := &strategyv1alpha1.MongoDBTemplate{
Type: "logical",
S3: &strategyv1alpha1.MongoDBStorageS3{
Bucket: "cozy-backups-PLATFORM",
EndpointURL: "https://platform-s3.example",
},
}
raw, err := marshalMongoDBBackupSnapshot(mdbBackup, rendered, "s3-storage", nil, false)
if err != nil {
t.Fatalf("marshal: %v", err)
}
snap, err := unmarshalMongoDBBackupSnapshot(raw)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if snap.S3 != nil {
t.Fatalf("legacy snapshot must not adopt the platform coordinates; got %#v", snap.S3)
}
if snap.Destination != "s3://tenant-own-bucket/mongodb-src/2026-08-26T00:00:00Z" {
t.Errorf("legacy destination must be preserved; got %q", snap.Destination)
}
}
func TestUnmarshalMongoDBBackupSnapshot_Empty(t *testing.T) {
snap, err := unmarshalMongoDBBackupSnapshot(nil)
if err != nil || snap != nil {
t.Fatalf("nil snapshot should decode to (nil,nil): snap=%v err=%v", snap, err)
}
}
// ---------------------------------------------------------------------------
// resolveMongoDBBackupSource: live status preferred, snapshot fallback
// ---------------------------------------------------------------------------
func TestResolveMongoDBBackupSource_LivePreferred(t *testing.T) {
liveBackup := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "op-backup"},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
State: psmdbtypes.StateReady,
Type: "logical",
Destination: "s3://bkt/live/2026",
S3: &psmdbtypes.BackupStorageS3{Bucket: "bkt"},
},
}
cozyBackup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "cozy-bk"},
Spec: backupsv1alpha1.BackupSpec{
DriverMetadata: map[string]string{
psmdbBackupNameKey: "op-backup",
psmdbDestinationKey: "s3://bkt/stale/2020",
},
},
}
c := newMongoDBStrategyTestClient(t, liveBackup)
r := &RestoreJobReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
src, err := r.resolveMongoDBBackupSource(context.Background(), cozyBackup)
if err != nil {
t.Fatalf("resolveMongoDBBackupSource: %v", err)
}
if src.Destination != "s3://bkt/live/2026" {
t.Errorf("expected live destination to win, got %q", src.Destination)
}
}
func TestResolveMongoDBBackupSource_SnapshotFallback(t *testing.T) {
// No live operator Backup CR seeded; a snapshot on the Cozystack Backup is
// the only source of the destination.
fps := true
snapBackup := &psmdbtypes.PerconaServerMongoDBBackup{
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
Destination: "s3://bkt/snap/2026",
S3: &psmdbtypes.BackupStorageS3{Bucket: "bkt", ForcePathStyle: &fps},
},
}
raw, err := marshalMongoDBBackupSnapshot(snapBackup, &strategyv1alpha1.MongoDBTemplate{Type: "logical"}, "s3-storage", nil, false)
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
cozyBackup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "cozy-bk"},
Spec: backupsv1alpha1.BackupSpec{
DriverMetadata: map[string]string{psmdbBackupNameKey: "reaped-backup"},
},
Status: backupsv1alpha1.BackupStatus{UnderlyingResources: raw},
}
c := newMongoDBStrategyTestClient(t)
r := &RestoreJobReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
src, err := r.resolveMongoDBBackupSource(context.Background(), cozyBackup)
if err != nil {
t.Fatalf("resolveMongoDBBackupSource: %v", err)
}
if src.Destination != "s3://bkt/snap/2026" {
t.Errorf("expected snapshot destination, got %q", src.Destination)
}
if src.S3 == nil || src.S3.Bucket != "bkt" {
t.Errorf("expected snapshot S3 reused, got %#v", src.S3)
}
}
func TestResolveMongoDBBackupSource_NoDestinationFails(t *testing.T) {
cozyBackup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "cozy-bk"},
Spec: backupsv1alpha1.BackupSpec{DriverMetadata: map[string]string{}},
}
c := newMongoDBStrategyTestClient(t)
r := &RestoreJobReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
if _, err := r.resolveMongoDBBackupSource(context.Background(), cozyBackup); err == nil {
t.Fatal("expected an error when no destination is resolvable")
}
}
// A non-NotFound read error on the live operator Backup CR is transient (cache
// not yet synced, apiserver hiccup): resolveMongoDBBackupSource must wrap it in
// errTransientRestoreSource so the reconciler requeues instead of failing the
// RestoreJob — matching the backup path, which requeues the symmetric read
// error. A terminal snapshot fallback (no destination) must NOT carry the
// sentinel; TestResolveMongoDBBackupSource_NoDestinationFails pins that side.
func TestResolveMongoDBBackupSource_TransientLiveReadRequeues(t *testing.T) {
cozyBackup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "cozy-bk"},
Spec: backupsv1alpha1.BackupSpec{
DriverMetadata: map[string]string{psmdbBackupNameKey: "op-backup"},
},
}
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
_ = backupsv1alpha1.AddToScheme(s)
_ = strategyv1alpha1.AddToScheme(s)
_ = psmdbtypes.AddToScheme(s)
_ = mongodbapp.AddToScheme(s)
c := clientfake.NewClientBuilder().
WithScheme(s).
WithInterceptorFuncs(interceptor.Funcs{
Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if _, ok := obj.(*psmdbtypes.PerconaServerMongoDBBackup); ok {
return apierrors.NewServiceUnavailable("apiserver hiccup")
}
return cl.Get(ctx, key, obj, opts...)
},
}).
Build()
r := &RestoreJobReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
_, err := r.resolveMongoDBBackupSource(context.Background(), cozyBackup)
if err == nil {
t.Fatal("expected a transient error from the live-CR read, got nil")
}
if !errors.Is(err, errTransientRestoreSource) {
t.Errorf("expected errTransientRestoreSource so the caller requeues, got %v", err)
}
}
// ---------------------------------------------------------------------------
// createMongoDBBackupArtifact: driver-metadata + snapshot wiring
// ---------------------------------------------------------------------------
// TestCreateMongoDBBackupArtifact_ArtifactShape pins the producer side of the
// restore contract: the driver-metadata keys and the persisted snapshot that
// resolveMongoDBBackupSource reads back. Without this the producer/consumer
// seam is only exercised end-to-end in e2e — a key-name drift between writer
// and reader would pass every other unit test. Mirrors the MariaDB sibling
// TestCreateMariaDBBackupArtifact_ArtifactShape.
func TestCreateMongoDBBackupArtifact_ArtifactShape(t *testing.T) {
apps := mongodbapp.GroupName
resolved := &ResolvedBackupConfig{
StrategyRef: corev1.TypedLocalObjectReference{Kind: "MongoDB", Name: "cozy-default-mongodb"},
Parameters: map[string]string{},
}
rendered := newRenderedMongoDBTemplate()
t.Run("ready backup with destination populates metadata + artifact + snapshot", func(t *testing.T) {
job := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj-ok"},
Spec: backupsv1alpha1.BackupJobSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "MongoDB", Name: "mongodb-src", APIGroup: &apps},
},
}
c := newMongoDBStrategyTestClient(t, job)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
mdbBackup := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "op-bk"},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
State: psmdbtypes.StateReady,
Type: "logical",
Destination: "s3://bkt/mongodb-src/2026-08-05T00:00:00Z",
S3: &psmdbtypes.BackupStorageS3{Bucket: "bkt", CredentialsSecret: "mongodb-mongodb-src-s3-creds"},
},
}
artefact, err := r.createMongoDBBackupArtifact(context.Background(), job, resolved, mdbBackup, rendered, "s3-storage", false)
if err != nil {
t.Fatalf("createMongoDBBackupArtifact: %v", err)
}
if got := artefact.Spec.DriverMetadata[psmdbBackupNameKey]; got != "op-bk" {
t.Errorf("%s: got %q want op-bk", psmdbBackupNameKey, got)
}
if got := artefact.Spec.DriverMetadata[psmdbBackupNamespaceKey]; got != "tenant" {
t.Errorf("%s: got %q want tenant", psmdbBackupNamespaceKey, got)
}
if got := artefact.Spec.DriverMetadata[psmdbDestinationKey]; got != mdbBackup.Status.Destination {
t.Errorf("%s: got %q want %q", psmdbDestinationKey, got, mdbBackup.Status.Destination)
}
if artefact.Status.Phase != backupsv1alpha1.BackupPhaseReady {
t.Errorf("Status.Phase: got %q want Ready", artefact.Status.Phase)
}
if artefact.Status.Artifact == nil || artefact.Status.Artifact.URI != mdbBackup.Status.Destination {
t.Errorf("Status.Artifact.URI: got %#v want %q", artefact.Status.Artifact, mdbBackup.Status.Destination)
}
// The persisted snapshot must decode back to a usable backupSource —
// this is exactly what resolveMongoDBBackupSource consumes on the
// restore path when the operator Backup CR has been reaped.
snap, err := unmarshalMongoDBBackupSnapshot(artefact.Status.UnderlyingResources)
if err != nil {
t.Fatalf("decode snapshot: %v", err)
}
if snap == nil || snap.Destination != mdbBackup.Status.Destination {
t.Fatalf("snapshot destination round-trip: %#v", snap)
}
if snap.S3 == nil || snap.S3.CredentialsSecret != "mongodb-mongodb-src-s3-creds" {
t.Errorf("snapshot S3 by-reference mismatch: %#v", snap.S3)
}
})
t.Run("no destination leaves Artifact nil and omits destination key", func(t *testing.T) {
job := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj-nodest"},
Spec: backupsv1alpha1.BackupJobSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "MongoDB", Name: "mongodb-src", APIGroup: &apps},
},
}
c := newMongoDBStrategyTestClient(t, job)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
mdbBackup := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "op-bk2"},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{State: psmdbtypes.StateReady},
}
artefact, err := r.createMongoDBBackupArtifact(context.Background(), job, resolved, mdbBackup, rendered, "s3-storage", false)
if err != nil {
t.Fatalf("createMongoDBBackupArtifact: %v", err)
}
if artefact.Status.Artifact != nil {
t.Errorf("no destination must leave Status.Artifact nil; got %#v", artefact.Status.Artifact)
}
if _, ok := artefact.Spec.DriverMetadata[psmdbDestinationKey]; ok {
t.Errorf("no destination must omit %s from driverMetadata", psmdbDestinationKey)
}
})
}
// ---------------------------------------------------------------------------
// System-bucket storage injection
// ---------------------------------------------------------------------------
// TestShouldInjectMongoDBSystemStorage pins the injection gate: inject only when
// the app opted into the system bucket AND the strategy carries S3 coordinates.
// The flag-false branch is the guarantee that a legacy app is never touched,
// regardless of what storage happens to be on the live cluster.
func TestShouldInjectMongoDBSystemStorage(t *testing.T) {
s3 := &strategyv1alpha1.MongoDBStorageS3{Bucket: "b", EndpointURL: "https://s3"}
cases := []struct {
name string
useSystemBucket bool
rendered *strategyv1alpha1.MongoDBTemplate
want bool
}{
{
name: "useSystemBucket flow with strategy coordinates: inject",
useSystemBucket: true,
rendered: &strategyv1alpha1.MongoDBTemplate{StorageName: "s3-storage", S3: s3},
want: true,
},
{
name: "legacy app (flag off): skip even when the shared strategy carries coordinates",
useSystemBucket: false,
rendered: &strategyv1alpha1.MongoDBTemplate{StorageName: "s3-storage", S3: s3},
want: false,
},
{
name: "useSystemBucket flag but strategy carries no coordinates: skip (nothing to inject)",
useSystemBucket: true,
rendered: &strategyv1alpha1.MongoDBTemplate{StorageName: "s3-storage"},
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := shouldInjectMongoDBSystemStorage(tc.useSystemBucket, tc.rendered); got != tc.want {
t.Fatalf("shouldInjectMongoDBSystemStorage = %v, want %v", got, tc.want)
}
})
}
}
// TestBuildMongoDBSystemStorageEntry pins the injected storage-entry shape: the
// {"type":"s3","s3":{…}} envelope, the empty-credentialsSecret default, and the
// forcePathStyle omit-when-nil (a nil pointer must not surface as
// forcePathStyle:false the app never chose).
func TestBuildMongoDBSystemStorageEntry(t *testing.T) {
t.Run("defaults credentialsSecret and omits forcePathStyle when nil", func(t *testing.T) {
entry := buildMongoDBSystemStorageEntry(&strategyv1alpha1.MongoDBStorageS3{
Bucket: "sys-bucket",
EndpointURL: "https://s3.example",
Region: "us-east-1",
Prefix: "mongodb-src",
})
if entry["type"] != "s3" {
t.Fatalf("type = %v, want s3", entry["type"])
}
s3, ok := entry["s3"].(map[string]interface{})
if !ok {
t.Fatalf("s3 block is not a map: %#v", entry["s3"])
}
if s3["credentialsSecret"] != "cozy-backups-creds" {
t.Errorf("credentialsSecret = %v, want cozy-backups-creds", s3["credentialsSecret"])
}
if _, present := s3["forcePathStyle"]; present {
t.Errorf("forcePathStyle must be omitted when the pointer is nil; got %v", s3["forcePathStyle"])
}
for k, want := range map[string]interface{}{
"bucket": "sys-bucket",
"endpointUrl": "https://s3.example",
"region": "us-east-1",
"prefix": "mongodb-src",
"insecureSkipTLSVerify": false,
} {
if s3[k] != want {
t.Errorf("s3[%q] = %v, want %v", k, s3[k], want)
}
}
})
t.Run("honours explicit credentialsSecret and forcePathStyle", func(t *testing.T) {
fps := true
entry := buildMongoDBSystemStorageEntry(&strategyv1alpha1.MongoDBStorageS3{
Bucket: "b",
CredentialsSecret: "custom-creds",
ForcePathStyle: &fps,
InsecureSkipTLSVerify: true,
})
s3 := entry["s3"].(map[string]interface{})
if s3["credentialsSecret"] != "custom-creds" {
t.Errorf("credentialsSecret = %v, want custom-creds", s3["credentialsSecret"])
}
if s3["forcePathStyle"] != true {
t.Errorf("forcePathStyle = %v, want true", s3["forcePathStyle"])
}
if s3["insecureSkipTLSVerify"] != true {
t.Errorf("insecureSkipTLSVerify = %v, want true", s3["insecureSkipTLSVerify"])
}
})
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
func newMongoDBStrategyTestClient(t *testing.T, objs ...client.Object) client.Client {
t.Helper()
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
_ = backupsv1alpha1.AddToScheme(s)
_ = strategyv1alpha1.AddToScheme(s)
_ = psmdbtypes.AddToScheme(s)
_ = mongodbapp.AddToScheme(s)
return clientfake.NewClientBuilder().
WithScheme(s).
WithObjects(objs...).
WithStatusSubresource(&backupsv1alpha1.BackupJob{}, &backupsv1alpha1.RestoreJob{}, &backupsv1alpha1.Backup{}).
Build()
}
func newMongoDBBackupJob(name, namespace string) *backupsv1alpha1.BackupJob {
apps := mongodbapp.GroupName
return &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Spec: backupsv1alpha1.BackupJobSpec{
ApplicationRef: corev1.TypedLocalObjectReference{
Kind: "MongoDB", Name: "mongodb-src", APIGroup: &apps,
},
},
}
}
func newMongoDBRestoreJob(name, namespace string) *backupsv1alpha1.RestoreJob {
return &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Spec: backupsv1alpha1.RestoreJobSpec{
BackupRef: corev1.LocalObjectReference{Name: "src-backup"},
},
}
}
func newRenderedMongoDBTemplate() *strategyv1alpha1.MongoDBTemplate {
return &strategyv1alpha1.MongoDBTemplate{
StorageName: "s3-storage",
Type: "logical",
CompressionType: "gzip",
}
}
// ---------------------------------------------------------------------------
// Strategy rendering
// ---------------------------------------------------------------------------
// The strategy prefix reaches the CR as a literal Go template
// ("<ns>/<app>") and renderMongoDBTemplate is what turns it into a per-tenant
// path at BackupJob time. That resolution is the only thing keeping two tenants'
// dumps apart inside one shared bucket, so pin it: an unrendered prefix would
// collide every tenant under the same path. Mirrors the CNPG/FoundationDB
// sibling render tests.
func TestRenderMongoDBTemplate_TemplatingApplicationName(t *testing.T) {
tmpl := strategyv1alpha1.MongoDBTemplate{
StorageName: "s3-storage",
Type: "logical",
S3: &strategyv1alpha1.MongoDBStorageS3{
Bucket: "cozy-backups",
EndpointURL: "https://s3.example",
Prefix: "{{ .Application.metadata.namespace }}/{{ .Application.metadata.name }}",
CredentialsSecret: "cozy-backups-creds",
},
}
app := &mongodbapp.MongoDB{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-x", Name: "mongo-y"}}
got, err := renderMongoDBTemplate(tmpl, app, nil)
if err != nil {
t.Fatalf("renderMongoDBTemplate: %v", err)
}
if got.S3 == nil {
t.Fatalf("rendered S3 is nil")
}
if got.S3.Prefix != "tenant-x/mongo-y" {
t.Errorf("prefix not templated per application: got %q want tenant-x/mongo-y", got.S3.Prefix)
}
// The non-templated coordinates must pass through untouched.
if got.S3.Bucket != "cozy-backups" || got.S3.CredentialsSecret != "cozy-backups-creds" {
t.Errorf("static coordinates altered: %#v", got.S3)
}
}
// ---------------------------------------------------------------------------
// System-bucket storage injection (BackupJob path)
// ---------------------------------------------------------------------------
// The injection is what makes the useSystemBucket flow work: the app chart omits
// the storage, so reconcileMongoDB SSA-injects it and must read the precondition
// off the post-apply cluster (cluster = injected), not the pre-apply one it
// Got. This pins that within a single reconcile: with a live cluster that has
// backups enabled but no storage declared, the reconcile must inject, pass the
// precondition, and create the operator Backup CR. Drop the reassignment and the
// precondition reads the pre-apply cluster, fails, and no Backup is created.
func TestReconcileMongoDB_InjectsSystemStorageBeforePrecondition(t *testing.T) {
apps := mongodbapp.GroupName
strategyGroup := strategyv1alpha1.GroupVersion.Group
now := metav1.Now()
job := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj-inj"},
Spec: backupsv1alpha1.BackupJobSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "MongoDB", Name: "app1", APIGroup: &apps},
},
Status: backupsv1alpha1.BackupJobStatus{StartedAt: &now},
}
strategy := &strategyv1alpha1.MongoDB{
ObjectMeta: metav1.ObjectMeta{Name: "cozy-default-mongodb"},
Spec: strategyv1alpha1.MongoDBSpec{Template: strategyv1alpha1.MongoDBTemplate{
StorageName: "s3-storage",
Type: "logical",
S3: &strategyv1alpha1.MongoDBStorageS3{
Bucket: "cozy-backups",
EndpointURL: "https://s3.example",
Prefix: "{{ .Application.metadata.namespace }}/{{ .Application.metadata.name }}",
CredentialsSecret: "cozy-backups-creds",
},
}},
}
app := &mongodbapp.MongoDB{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "app1"},
Spec: mongodbapp.MongoDBSpec{Backup: mongodbapp.MongoDBBackupSpec{UseSystemBucket: true}},
}
// Live cluster: backups enabled (agents run) but storage NOT declared — the
// exact state the chart leaves on the useSystemBucket flow.
cluster := &psmdbtypes.PerconaServerMongoDB{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "mongodb-app1"},
Spec: psmdbtypes.PerconaServerMongoDBSpec{Backup: psmdbtypes.PerconaServerMongoDBBackupConfig{Enabled: true}},
Status: psmdbtypes.PerconaServerMongoDBStatus{Conditions: []psmdbtypes.PerconaServerMongoDBCondition{{Type: psmdbtypes.ConditionPBMReady, Status: "True"}}},
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
resolved := &ResolvedBackupConfig{
StrategyRef: corev1.TypedLocalObjectReference{APIGroup: &strategyGroup, Kind: strategyv1alpha1.MongoDBStrategyKind, Name: "cozy-default-mongodb"},
Parameters: map[string]string{},
}
// First pass: the storage is freshly injected, so the driver requeues rather
// than minting the Backup CR in the same reconcile (see the injection-race
// note in reconcileMongoDB). The apply is persisted onto the live cluster.
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB pass 1: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("expected a requeue after the first injection, got %+v", res)
}
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list operator backups: %v", err)
}
if len(list.Items) != 0 {
t.Fatalf("no operator Backup should be minted before the requeue, got %d", len(list.Items))
}
got := &psmdbtypes.PerconaServerMongoDB{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "mongodb-app1"}, got); err != nil {
t.Fatalf("get cluster: %v", err)
}
entry, ok := got.Spec.Backup.Storages["s3-storage"]
if !ok {
t.Fatalf("expected injected s3-storage on the cluster, got %#v", got.Spec.Backup.Storages)
}
// The whole entry must reach the cluster, the prefix above all: it is the
// only thing separating two tenants' archives inside the one shared bucket.
var persistedEntry struct {
Type string `json:"type"`
S3 struct {
Bucket string `json:"bucket"`
EndpointURL string `json:"endpointUrl"`
Prefix string `json:"prefix"`
CredentialsSecret string `json:"credentialsSecret"`
} `json:"s3"`
}
if err := json.Unmarshal(entry.Raw, &persistedEntry); err != nil {
t.Fatalf("decode injected entry %s: %v", entry.Raw, err)
}
if persistedEntry.Type != "s3" || persistedEntry.S3.Bucket != "cozy-backups" || persistedEntry.S3.EndpointURL != "https://s3.example" ||
persistedEntry.S3.CredentialsSecret != "cozy-backups-creds" || persistedEntry.S3.Prefix != "tenant/app1" {
t.Errorf("injected entry must carry the strategy coordinates with the per-application prefix resolved, got %s", entry.Raw)
}
// Second pass: the cluster already declares the storage (the operator cache
// has had a poll to observe it), so the precondition passes off the injected
// storage and the operator Backup CR is minted. Re-fetch the BackupJob first —
// pass 1 wrote its Ready=False condition, so a stale copy would conflict.
fresh := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "bj-inj"}, fresh); err != nil {
t.Fatalf("re-get job: %v", err)
}
res, err = r.reconcileMongoDB(context.Background(), fresh, resolved)
if err != nil {
t.Fatalf("reconcileMongoDB pass 2: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("expected a poll requeue while the operator Backup runs, got %+v", res)
}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list operator backups: %v", err)
}
if len(list.Items) != 1 {
t.Fatalf("expected the injected storage to let the precondition pass and create 1 operator Backup, got %d", len(list.Items))
}
}
// mongodbInjectFixture builds the four objects reconcileMongoDB needs to reach
// the injection point on the useSystemBucket flow, parameterised on the live
// cluster's backup.enabled.
func mongodbInjectFixture(enabled bool) (*backupsv1alpha1.BackupJob, *strategyv1alpha1.MongoDB, *mongodbapp.MongoDB, *psmdbtypes.PerconaServerMongoDB, *ResolvedBackupConfig) {
apps := mongodbapp.GroupName
strategyGroup := strategyv1alpha1.GroupVersion.Group
now := metav1.Now()
job := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj-inj"},
Spec: backupsv1alpha1.BackupJobSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "MongoDB", Name: "app1", APIGroup: &apps},
},
Status: backupsv1alpha1.BackupJobStatus{StartedAt: &now},
}
strategy := &strategyv1alpha1.MongoDB{
ObjectMeta: metav1.ObjectMeta{Name: "cozy-default-mongodb"},
Spec: strategyv1alpha1.MongoDBSpec{Template: strategyv1alpha1.MongoDBTemplate{
StorageName: "s3-storage",
Type: "logical",
S3: &strategyv1alpha1.MongoDBStorageS3{
Bucket: "cozy-backups", EndpointURL: "https://s3.example",
Prefix: "{{ .Application.metadata.namespace }}/{{ .Application.metadata.name }}", CredentialsSecret: "cozy-backups-creds",
},
}},
}
app := &mongodbapp.MongoDB{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "app1"},
Spec: mongodbapp.MongoDBSpec{Backup: mongodbapp.MongoDBBackupSpec{UseSystemBucket: true}},
}
cluster := &psmdbtypes.PerconaServerMongoDB{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "mongodb-app1"},
Spec: psmdbtypes.PerconaServerMongoDBSpec{Backup: psmdbtypes.PerconaServerMongoDBBackupConfig{Enabled: enabled}},
Status: psmdbtypes.PerconaServerMongoDBStatus{Conditions: []psmdbtypes.PerconaServerMongoDBCondition{{Type: psmdbtypes.ConditionPBMReady, Status: "True"}}},
}
resolved := &ResolvedBackupConfig{
StrategyRef: corev1.TypedLocalObjectReference{APIGroup: &strategyGroup, Kind: strategyv1alpha1.MongoDBStrategyKind, Name: "cozy-default-mongodb"},
Parameters: map[string]string{},
}
return job, strategy, app, cluster, resolved
}
// The first BackupJob of a useSystemBucket app injects the storage; the psmdb
// operator resolves it from a cached cluster read when it services the Backup
// CR, so minting the CR in the same pass races that cache and can latch it at
// State=error. The driver must requeue after a fresh injection instead of
// minting, giving the cache a poll to observe the storage.
func TestReconcileMongoDB_FirstInjectionRequeuesBeforeMinting(t *testing.T) {
// Fixture seeds a cluster with backups enabled and NO storage declared.
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("expected a requeue after the first injection, got %+v", res)
}
// No operator Backup CR minted yet — that waits for the next pass.
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list: %v", err)
}
if len(list.Items) != 0 {
t.Errorf("no operator Backup should be created in the same pass as a fresh injection, got %d", len(list.Items))
}
// The wait is named on the BackupJob, not silent: a Ready=False condition with
// the injection reason so the pause is visible in kubectl describe.
persisted := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, persisted); err != nil {
t.Fatalf("get job: %v", err)
}
cond := apimeta.FindStatusCondition(persisted.Status.Conditions, "Ready")
if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != "PerconaServerMongoDBStorageInjected" {
t.Errorf("expected Ready=False with reason PerconaServerMongoDBStorageInjected, got %+v", cond)
}
}
// A cluster that can never service a backup (backup.enabled=false) must not be
// mutated: injection is gated on Enabled so the disabled cluster fails the
// precondition on the enabled check without the driver having written storage
// onto it.
func TestReconcileMongoDB_DisabledClusterNotMutated(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(false)
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("expected a waiting requeue for a disabled cluster, got %+v", res)
}
got := &psmdbtypes.PerconaServerMongoDB{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "mongodb-app1"}, got); err != nil {
t.Fatalf("get cluster: %v", err)
}
if len(got.Spec.Backup.Storages) != 0 {
t.Errorf("disabled cluster must not be injected, got storages %#v", got.Spec.Backup.Storages)
}
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list: %v", err)
}
if len(list.Items) != 0 {
t.Errorf("no operator Backup should be created for a disabled cluster, got %d", len(list.Items))
}
}
// A server-side apply that fails transiently (apiserver hiccup, operator
// conflict) must requeue with backoff, not fail the BackupJob terminally. Before
// the fix the injection error went straight to markBackupJobFailed, so one
// transient apply killed the backup.
func TestReconcileMongoDB_InjectApplyErrorRequeues(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
_ = backupsv1alpha1.AddToScheme(s)
_ = strategyv1alpha1.AddToScheme(s)
_ = psmdbtypes.AddToScheme(s)
_ = mongodbapp.AddToScheme(s)
c := clientfake.NewClientBuilder().
WithScheme(s).
WithObjects(job, strategy, app, cluster).
WithStatusSubresource(&backupsv1alpha1.BackupJob{}).
WithInterceptorFuncs(interceptor.Funcs{
Patch: func(ctx context.Context, cl client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
if _, ok := obj.(*psmdbtypes.PerconaServerMongoDB); ok {
return apierrors.NewConflict(schema.GroupResource{Group: "psmdb.percona.com", Resource: "perconaservermongodbs"}, obj.GetName(), errors.New("racing the operator"))
}
return cl.Patch(ctx, obj, patch, opts...)
},
}).
Build()
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
_, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err == nil {
t.Fatal("expected a transient error so the caller requeues, got nil")
}
// The BackupJob must NOT be marked Failed by a transient apply error.
persisted := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "bj-inj"}, persisted); err != nil {
t.Fatalf("get job: %v", err)
}
if persisted.Status.Phase == backupsv1alpha1.BackupJobPhaseFailed {
t.Errorf("a transient inject error must not fail the BackupJob terminally")
}
}
// resolveMongoDBBackupSource must fill an S3 block the live operator status
// omits from the snapshot: on the useSystemBucket flow the operator can report
// status.destination with status.s3 empty (the storage was injected, not
// declared), and without the coordinates the restore has nothing to authenticate
// or address the archive with. Neither prior test covers a live CR whose
// destination is set but whose S3 is nil.
func TestResolveMongoDBBackupSource_LiveMissingS3BackfillsFromSnapshot(t *testing.T) {
liveBackup := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "op-backup"},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
State: psmdbtypes.StateReady,
Type: "logical",
Destination: "s3://cozy-backups/tenant/app1/2026",
// S3 intentionally nil: the operator echoed a destination only.
},
}
snapBackup := &psmdbtypes.PerconaServerMongoDBBackup{
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
Destination: "s3://cozy-backups/tenant/app1/2026",
S3: &psmdbtypes.BackupStorageS3{Bucket: "cozy-backups", CredentialsSecret: "cozy-backups-creds", EndpointURL: "https://s3.example"},
},
}
raw, err := marshalMongoDBBackupSnapshot(snapBackup, &strategyv1alpha1.MongoDBTemplate{Type: "logical"}, "s3-storage", nil, false)
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
cozyBackup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "cozy-bk"},
Spec: backupsv1alpha1.BackupSpec{
DriverMetadata: map[string]string{psmdbBackupNameKey: "op-backup"},
},
Status: backupsv1alpha1.BackupStatus{UnderlyingResources: raw},
}
c := newMongoDBStrategyTestClient(t, liveBackup)
r := &RestoreJobReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
src, err := r.resolveMongoDBBackupSource(context.Background(), cozyBackup)
if err != nil {
t.Fatalf("resolveMongoDBBackupSource: %v", err)
}
if src.Destination != "s3://cozy-backups/tenant/app1/2026" {
t.Errorf("expected live destination, got %q", src.Destination)
}
if src.S3 == nil {
t.Fatalf("expected S3 backfilled from snapshot, got nil (restore would have no coordinates)")
}
if src.S3.CredentialsSecret != "cozy-backups-creds" || src.S3.Bucket != "cozy-backups" {
t.Errorf("expected snapshot S3 coordinates, got %#v", src.S3)
}
}
// ---------------------------------------------------------------------------
// System-bucket archive cleanup (retention)
// ---------------------------------------------------------------------------
func TestCleanupMongoDBBackup(t *testing.T) {
md := map[string]string{psmdbBackupNameKey: "op-bk"}
newBackup := func(ann map[string]string) *backupsv1alpha1.Backup {
return &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "cozy-bk", Annotations: ann},
Spec: backupsv1alpha1.BackupSpec{DriverMetadata: md},
}
}
// The delete-backup finalizer is the ownership marker: present = the driver
// owns the shared-bucket archive; absent = legacy (tenant bucket).
opBackup := func(finalized bool) *psmdbtypes.PerconaServerMongoDBBackup {
b := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "op-bk"},
}
if finalized {
b.Finalizers = []string{psmdbDeleteBackupFinalizer}
}
return b
}
liveNamespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant"}}
getOp := func(t *testing.T, c client.Client) *psmdbtypes.PerconaServerMongoDBBackup {
got := &psmdbtypes.PerconaServerMongoDBBackup{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "op-bk"}, got); err != nil {
t.Fatalf("get operator CR: %v", err)
}
return got
}
t.Run("legacy backup (no finalizer) is a no-op", func(t *testing.T) {
c := newMongoDBStrategyTestClient(t, opBackup(false), liveNamespace)
r := &BackupReconciler{Client: c}
res, err := r.cleanupMongoDBBackup(context.Background(), newBackup(nil))
if err != nil || res.RequeueAfter != 0 {
t.Fatalf("legacy cleanup must be a no-op, got res=%+v err=%v", res, err)
}
if !getOp(t, c).DeletionTimestamp.IsZero() {
t.Errorf("legacy cleanup must not delete the operator CR")
}
})
t.Run("owned archive deletes the operator CR and waits", func(t *testing.T) {
c := newMongoDBStrategyTestClient(t, opBackup(true), liveNamespace)
r := &BackupReconciler{Client: c}
res, err := r.cleanupMongoDBBackup(context.Background(), newBackup(nil))
if err != nil {
t.Fatalf("cleanupMongoDBBackup: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("expected a requeue while the operator prunes the archive, got %+v", res)
}
// The finalizer holds the CR Terminating until the operator prunes; the
// driver keeps its finalizer and waits, it does not strip it here.
got := getOp(t, c)
if got.DeletionTimestamp.IsZero() {
t.Errorf("expected the operator CR to be marked for deletion")
}
if !controllerutil.ContainsFinalizer(got, psmdbDeleteBackupFinalizer) {
t.Errorf("driver must keep the finalizer while waiting, so the operator can prune")
}
})
t.Run("operator CR already gone releases", func(t *testing.T) {
c := newMongoDBStrategyTestClient(t, liveNamespace)
r := &BackupReconciler{Client: c}
res, err := r.cleanupMongoDBBackup(context.Background(), newBackup(nil))
if err != nil || res.RequeueAfter != 0 {
t.Fatalf("a reaped operator CR must release, got res=%+v err=%v", res, err)
}
})
t.Run("skip annotation strips the finalizer and releases", func(t *testing.T) {
c := newMongoDBStrategyTestClient(t, opBackup(true), liveNamespace)
r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
res, err := r.cleanupMongoDBBackup(context.Background(), newBackup(map[string]string{psmdbSkipArtifactCleanupAnnotation: "true"}))
if err != nil || res.RequeueAfter != 0 {
t.Fatalf("skip annotation must release, got res=%+v err=%v", res, err)
}
// Releasing strips our finalizer so the CR can be reaped (in the fake
// client, with no finalizer left, the best-effort delete removes it): the
// escape hatch actually unwedges. Tolerate either "gone" or "present
// without our finalizer".
got := &psmdbtypes.PerconaServerMongoDBBackup{}
err = c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "op-bk"}, got)
if err == nil && controllerutil.ContainsFinalizer(got, psmdbDeleteBackupFinalizer) {
t.Errorf("release must strip the delete-backup finalizer; still present: %v", got.Finalizers)
}
})
t.Run("terminating namespace strips the finalizer and releases (no wedge)", func(t *testing.T) {
// No namespace object seeded → namespaceTerminating reports true, the
// teardown case. The driver must not requeue forever waiting on an
// operator that can no longer prune; it strips its finalizer and releases.
c := newMongoDBStrategyTestClient(t, opBackup(true))
r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
res, err := r.cleanupMongoDBBackup(context.Background(), newBackup(nil))
if err != nil || res.RequeueAfter != 0 {
t.Fatalf("teardown must release, not wedge; got res=%+v err=%v", res, err)
}
got := &psmdbtypes.PerconaServerMongoDBBackup{}
err = c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "op-bk"}, got)
if err == nil && controllerutil.ContainsFinalizer(got, psmdbDeleteBackupFinalizer) {
t.Errorf("teardown must strip the finalizer so the namespace can finish; still present: %v", got.Finalizers)
}
})
t.Run("terminating release uses the pre-read CR, not a second Get", func(t *testing.T) {
// The terminating path hands the CR it already read and confirmed owned to
// releaseMongoDBCleanup, so the strip is not gated on a second Get. Fail
// every operator-CR Get after the first: cleanup's initial read succeeds
// and confirms ownership, and the release must strip from the passed object
// without re-reading. If the call site instead passed nil, the release
// would re-Get (fail here) and skip the strip — no ArtifactNotDeleted Event.
getN := 0
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
_ = backupsv1alpha1.AddToScheme(s)
_ = psmdbtypes.AddToScheme(s)
c := clientfake.NewClientBuilder().
WithScheme(s).
WithObjects(opBackup(true)).
WithInterceptorFuncs(interceptor.Funcs{
Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if _, ok := obj.(*psmdbtypes.PerconaServerMongoDBBackup); ok {
getN++
if getN > 1 {
return apierrors.NewServiceUnavailable("read throttled after first")
}
}
return cl.Get(ctx, key, obj, opts...)
},
}).
Build()
rec := record.NewFakeRecorder(10)
r := &BackupReconciler{Client: c, Recorder: rec}
// No Namespace object → namespaceTerminating reports true (teardown path).
res, err := r.cleanupMongoDBBackup(context.Background(), newBackup(nil))
if err != nil || res.RequeueAfter != 0 {
t.Fatalf("teardown must release, got res=%+v err=%v", res, err)
}
select {
case ev := <-rec.Events:
if !strings.Contains(ev, "ArtifactNotDeleted") {
t.Errorf("expected the owned-release event proving the pre-read CR was used, got %q", ev)
}
default:
t.Errorf("expected an ArtifactNotDeleted event (pre-read CR used, strip ran), got none")
}
})
// A client whose Get on the operator CR hard-fails the way a missing CRD or a
// revoked verb does — NoKindMatchError / an arbitrary error — models the case
// the escape hatch exists for. The annotation must free the Backup without
// depending on that read, and a bare NoMatchError must be treated as "gone".
failingGetClient := func(getErr error) client.Client {
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
_ = backupsv1alpha1.AddToScheme(s)
_ = psmdbtypes.AddToScheme(s)
return clientfake.NewClientBuilder().
WithScheme(s).
WithInterceptorFuncs(interceptor.Funcs{
Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if _, ok := obj.(*psmdbtypes.PerconaServerMongoDBBackup); ok {
return getErr
}
return cl.Get(ctx, key, obj, opts...)
},
}).
Build()
}
noMatch := &apimeta.NoKindMatchError{GroupKind: schema.GroupKind{Group: "psmdb.percona.com", Kind: "PerconaServerMongoDBBackup"}}
t.Run("skip annotation releases even when the operator CR is unreadable", func(t *testing.T) {
// The verb is revoked (Forbidden): the escape hatch runs before the Get, so
// it must still release rather than propagate the read error and wedge.
c := failingGetClient(apierrors.NewForbidden(schema.GroupResource{Group: "psmdb.percona.com", Resource: "perconaservermongodbbackups"}, "op-bk", errors.New("rbac revoked")))
rec := record.NewFakeRecorder(10)
r := &BackupReconciler{Client: c, Recorder: rec}
res, err := r.cleanupMongoDBBackup(context.Background(), newBackup(map[string]string{psmdbSkipArtifactCleanupAnnotation: "true"}))
if err != nil || res.RequeueAfter != 0 {
t.Fatalf("annotation must release despite an unreadable operator CR, got res=%+v err=%v", res, err)
}
// A CR that could not be read may still carry the finalizer, so the release
// surfaces which object may be left stuck rather than a Debug line nobody sees.
select {
case ev := <-rec.Events:
if !strings.Contains(ev, "FinalizerNotStripped") || !strings.Contains(ev, "op-bk") {
t.Errorf("expected a FinalizerNotStripped event naming the unreadable CR, got %q", ev)
}
default:
t.Errorf("expected a FinalizerNotStripped Warning for the unreadable CR, got none")
}
})
t.Run("a missing CRD (NoMatchError) releases instead of wedging", func(t *testing.T) {
c := failingGetClient(noMatch)
r := &BackupReconciler{Client: c}
res, err := r.cleanupMongoDBBackup(context.Background(), newBackup(nil))
if err != nil || res.RequeueAfter != 0 {
t.Fatalf("NoMatchError must be treated as gone and release, got res=%+v err=%v", res, err)
}
})
// The annotation reaches releaseMongoDBCleanup before the ownership check, so
// the release must itself refuse to touch a CR the driver does not own — the
// legacy operator-side backup record is what a psmdb restore-by-name resolves
// against, and the RBAC comment and docs both promise it is never deleted.
t.Run("legacy backup with the skip annotation keeps its operator CR", func(t *testing.T) {
c := newMongoDBStrategyTestClient(t, opBackup(false), liveNamespace)
r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
res, err := r.cleanupMongoDBBackup(context.Background(), newBackup(map[string]string{psmdbSkipArtifactCleanupAnnotation: "true"}))
if err != nil || res.RequeueAfter != 0 {
t.Fatalf("legacy + annotation must release, got res=%+v err=%v", res, err)
}
got := getOp(t, c)
if !got.DeletionTimestamp.IsZero() {
t.Errorf("the escape hatch must not delete a legacy (unowned) operator CR")
}
})
// When the finalizer strip fails, the CR keeps percona.com/delete-backup and
// can pin its namespace in Terminating; the release still proceeds, but it
// must say which object is stuck rather than emit the success-case Event.
t.Run("a failed finalizer strip surfaces the stuck object", func(t *testing.T) {
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
_ = backupsv1alpha1.AddToScheme(s)
_ = psmdbtypes.AddToScheme(s)
c := clientfake.NewClientBuilder().
WithScheme(s).
WithObjects(opBackup(true)).
WithInterceptorFuncs(interceptor.Funcs{
Patch: func(ctx context.Context, cl client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
if _, ok := obj.(*psmdbtypes.PerconaServerMongoDBBackup); ok {
return apierrors.NewServiceUnavailable("apiserver down")
}
return cl.Patch(ctx, obj, patch, opts...)
},
}).
Build()
rec := record.NewFakeRecorder(10)
r := &BackupReconciler{Client: c, Recorder: rec}
res, err := r.cleanupMongoDBBackup(context.Background(), newBackup(map[string]string{psmdbSkipArtifactCleanupAnnotation: "true"}))
if err != nil || res.RequeueAfter != 0 {
t.Fatalf("release must proceed even when the strip fails, got res=%+v err=%v", res, err)
}
select {
case ev := <-rec.Events:
if !strings.Contains(ev, "FinalizerNotStripped") || !strings.Contains(ev, "op-bk") {
t.Errorf("expected a FinalizerNotStripped event naming the object, got %q", ev)
}
default:
t.Errorf("expected an event surfacing the stuck finalizer, got none")
}
})
}
// On the useSystemBucket flow a dump the operator is actively writing
// (state=running) is never failed on the wall-clock deadline: pbm keeps
// streaming into the shared bucket after the driver gives up, so failing it
// strands an archive no Backup object represents. On the legacy flow the
// archive lands in the tenant's own bucket under psmdb task retention, so the
// pre-existing deadline still applies to running. Not-started states trip the
// deadline on both flows.
func TestPsmdbBackupTimedOut(t *testing.T) {
past := &metav1.Time{Time: time.Now().Add(-2 * psmdbDefaultBackupDeadline)}
recent := &metav1.Time{Time: time.Now()}
cases := []struct {
name string
state string
started *metav1.Time
usb bool
want bool
}{
{"system-bucket running past deadline: never strand the shared archive", psmdbtypes.StateRunning, past, true, false},
// The ceiling is 24h, pinned as a boundary pair in absolute terms so a
// change to the constant is a deliberate change here too.
{"system-bucket running just under the 24h ceiling: left to finish", psmdbtypes.StateRunning, &metav1.Time{Time: time.Now().Add(-(24*time.Hour - time.Minute))}, true, false},
{"system-bucket running just past the 24h ceiling: a wedged agent cannot pin the job forever", psmdbtypes.StateRunning, &metav1.Time{Time: time.Now().Add(-(24*time.Hour + time.Minute))}, true, true},
{"legacy running past deadline: the pre-existing deadline applies", psmdbtypes.StateRunning, past, false, true},
{"waiting past deadline: nothing written, safe to fail", psmdbtypes.StateWaiting, past, true, true},
{"requested past deadline", psmdbtypes.StateRequested, past, false, true},
{"never observed past deadline", "", past, true, true},
{"legacy running within deadline", psmdbtypes.StateRunning, recent, false, false},
{"waiting within deadline", psmdbtypes.StateWaiting, recent, true, false},
{"legacy running with no StartedAt", psmdbtypes.StateRunning, nil, false, false},
}
for _, tc := range cases {
if got := psmdbBackupTimedOut(tc.state, tc.started, tc.usb); got != tc.want {
t.Errorf("%s: psmdbBackupTimedOut(state=%q, useSystemBucket=%v): got %v want %v", tc.name, tc.state, tc.usb, got, tc.want)
}
}
}
// The SSA-patch call site is the one place that could force-overwrite a running
// legacy tenant's own s3-storage entry. On the legacy flow (useSystemBucket
// false) the driver must never touch the cluster, even when the cluster is
// otherwise ready to back up — the injection is gated on the app flag, not on
// the live storage set.
func TestReconcileMongoDB_LegacyClusterNotPatched(t *testing.T) {
apps := mongodbapp.GroupName
strategyGroup := strategyv1alpha1.GroupVersion.Group
now := metav1.Now()
job := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj-legacy"},
Spec: backupsv1alpha1.BackupJobSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "MongoDB", Name: "app1", APIGroup: &apps},
},
Status: backupsv1alpha1.BackupJobStatus{StartedAt: &now},
}
// Strategy still carries S3 coordinates (they live on the shared default), so
// the guard cannot lean on rendered.S3 being nil — only the app flag.
strategy := &strategyv1alpha1.MongoDB{
ObjectMeta: metav1.ObjectMeta{Name: "cozy-default-mongodb"},
Spec: strategyv1alpha1.MongoDBSpec{Template: strategyv1alpha1.MongoDBTemplate{
StorageName: "s3-storage",
Type: "logical",
S3: &strategyv1alpha1.MongoDBStorageS3{Bucket: "cozy-backups", EndpointURL: "https://s3.example", CredentialsSecret: "cozy-backups-creds"},
}},
}
app := &mongodbapp.MongoDB{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "app1"},
Spec: mongodbapp.MongoDBSpec{Backup: mongodbapp.MongoDBBackupSpec{UseSystemBucket: false}},
}
// Legacy cluster with backups enabled AND its own tenant-bucket storage.
ownStorage := runtime.RawExtension{Raw: []byte(`{"type":"s3","s3":{"bucket":"tenant-own","credentialsSecret":"mongodb-app1-s3-creds"}}`)}
cluster := &psmdbtypes.PerconaServerMongoDB{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "mongodb-app1"},
Spec: psmdbtypes.PerconaServerMongoDBSpec{Backup: psmdbtypes.PerconaServerMongoDBBackupConfig{
Enabled: true,
Storages: map[string]runtime.RawExtension{"s3-storage": ownStorage},
}},
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
resolved := &ResolvedBackupConfig{
StrategyRef: corev1.TypedLocalObjectReference{APIGroup: &strategyGroup, Kind: strategyv1alpha1.MongoDBStrategyKind, Name: "cozy-default-mongodb"},
Parameters: map[string]string{},
}
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
got := &psmdbtypes.PerconaServerMongoDB{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "mongodb-app1"}, got); err != nil {
t.Fatalf("get cluster: %v", err)
}
bucket, _ := psmdbStorageS3(got.Spec.Backup.Storages["s3-storage"])
if bucket != "tenant-own" {
t.Errorf("legacy tenant's own s3-storage was overwritten: bucket=%q, want tenant-own", bucket)
}
}
// releaseMongoDBCleanup must strip the finalizer off the object the caller
// already read and confirmed owned, without a second Get whose transient
// failure would silently drop the strip and wedge a terminating namespace.
func TestReleaseMongoDBCleanup_UsesPassedObjectNotReRead(t *testing.T) {
owned := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "op-bk", Finalizers: []string{psmdbDeleteBackupFinalizer}},
}
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
_ = backupsv1alpha1.AddToScheme(s)
_ = psmdbtypes.AddToScheme(s)
c := clientfake.NewClientBuilder().
WithScheme(s).
WithObjects(owned.DeepCopy()).
WithInterceptorFuncs(interceptor.Funcs{
Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if _, ok := obj.(*psmdbtypes.PerconaServerMongoDBBackup); ok {
return apierrors.NewServiceUnavailable("read throttled")
}
return cl.Get(ctx, key, obj, opts...)
},
}).
Build()
rec := record.NewFakeRecorder(10)
r := &BackupReconciler{Client: c, Recorder: rec}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "cozy-bk"},
Spec: backupsv1alpha1.BackupSpec{DriverMetadata: map[string]string{psmdbBackupNameKey: "op-bk"}},
}
res, err := r.releaseMongoDBCleanup(context.Background(), backup, "op-bk", owned.DeepCopy(), "namespace is terminating")
if err != nil || res.RequeueAfter != 0 {
t.Fatalf("release must proceed, got res=%+v err=%v", res, err)
}
// The owned-strip path fires ArtifactNotDeleted; if the code had re-read the
// CR (Get throttled), it would have taken the quiet "nothing of ours" path
// with no event — so the event proves the passed object was used.
select {
case ev := <-rec.Events:
if !strings.Contains(ev, "ArtifactNotDeleted") {
t.Errorf("expected the owned-release event proving the passed object was used, got %q", ev)
}
default:
t.Errorf("expected an ArtifactNotDeleted event (passed object used, strip ran), got none")
}
}
// A streaming dump on the useSystemBucket flow is neither cancelled nor failed
// on the deadline: the pinned operator persists status only on a state/error
// change, so status.lastTransition freezes at the instant the dump entered
// running, and a real dataset routinely outruns any window. The wait is named
// on the BackupJob (Ready=False with the start time) rather than silent. On the
// legacy flow the archive lands in the tenant's own bucket under psmdb task
// retention, so the pre-existing deadline still fails the job — and the operator
// CR is left alone either way.
func TestReconcileMongoDB_RunningPastDeadline(t *testing.T) {
setup := func(t *testing.T, useSystemBucket bool) (*BackupJobReconciler, client.Client, *ResolvedBackupConfig, *backupsv1alpha1.BackupJob) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
app.Spec.Backup.UseSystemBucket = useSystemBucket
job.Status.StartedAt = &metav1.Time{Time: time.Now().Add(-4 * psmdbDefaultBackupDeadline)}
bucket := "cozy-backups"
if !useSystemBucket {
// Legacy: the strategy carries no coordinates to inject and the cluster
// declares the tenant's own bucket.
strategy.Spec.Template.S3 = nil
bucket = "tenant-own"
}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(fmt.Sprintf(`{"type":"s3","s3":{"bucket":%q}}`, bucket))},
}
running := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant",
Name: "op-running",
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
// lastTransition frozen at the start, as the pinned operator leaves it.
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
State: psmdbtypes.StateRunning,
LastTransition: &metav1.Time{Time: time.Now().Add(-4 * psmdbDefaultBackupDeadline)},
},
}
if useSystemBucket {
running.Finalizers = []string{psmdbDeleteBackupFinalizer}
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster, running)
return &BackupJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}, c, resolved, job
}
getCR := func(t *testing.T, c client.Client) *psmdbtypes.PerconaServerMongoDBBackup {
got := &psmdbtypes.PerconaServerMongoDBBackup{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "op-running"}, got); err != nil {
t.Fatalf("get running backup: %v", err)
}
return got
}
getJob := func(t *testing.T, c client.Client, name string) *backupsv1alpha1.BackupJob {
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
return p
}
t.Run("system bucket: left to finish, wait named on the job", func(t *testing.T) {
r, c, resolved, job := setup(t, true)
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("expected a poll requeue for a running system-bucket backup, got %+v", res)
}
if !getCR(t, c).DeletionTimestamp.IsZero() {
t.Errorf("a running system-bucket backup must never be cancelled; it was deleted")
}
p := getJob(t, c, job.Name)
if p.Status.Phase == backupsv1alpha1.BackupJobPhaseFailed {
t.Errorf("a running system-bucket backup must not fail the BackupJob on the deadline")
}
cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready")
if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != "PerconaServerMongoDBBackupRunning" || !strings.Contains(cond.Message, "streaming since") {
t.Errorf("expected Ready=False PerconaServerMongoDBBackupRunning naming the start time, got %+v", cond)
}
})
// Past the running ceiling the job fails, but the CR is still not cancelled:
// with its prune finalizer it is the only handle to whatever pbm still
// writes, and the failure names it.
t.Run("system bucket: past the ceiling → fail, CR left in place", func(t *testing.T) {
r, c, resolved, job := setup(t, true)
job.Status.StartedAt = &metav1.Time{Time: time.Now().Add(-2 * psmdbRunningCeiling)}
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if !getCR(t, c).DeletionTimestamp.IsZero() {
t.Errorf("a running system-bucket backup must not be cancelled even past the ceiling")
}
p := getJob(t, c, job.Name)
if p.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed {
t.Fatalf("a system-bucket dump running past the ceiling must fail the BackupJob, got phase=%q", p.Status.Phase)
}
if !strings.Contains(p.Status.Message, "op-running") || !strings.Contains(p.Status.Message, "left in place") {
t.Errorf("the failure must name the operator CR left in place, got %q", p.Status.Message)
}
})
t.Run("legacy: the deadline still fails the job, CR left alone", func(t *testing.T) {
r, c, resolved, job := setup(t, false)
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
p := getJob(t, c, job.Name)
if p.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed {
t.Fatalf("a legacy backup running past the deadline must fail the BackupJob, got phase=%q", p.Status.Phase)
}
cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready")
if cond == nil || !strings.Contains(cond.Message, "did not complete within") {
t.Errorf("expected a Ready condition naming the deadline, got %+v", cond)
}
if !getCR(t, c).DeletionTimestamp.IsZero() {
t.Errorf("the legacy operator CR must be left alone (hands-off); it was deleted")
}
})
}
// The storage-race retry hinges on recognising the operator's own
// unable-to-get-storage error and only that: a different error, or the right
// error naming a different storage, must not be swallowed as retryable.
func TestPsmdbErrorIsUnresolvedStorage(t *testing.T) {
cases := []struct {
msg, storage string
want bool
}{
{`unable to get storage "s3-storage": not found`, "s3-storage", true},
{`unable to get storage "other": not found`, "s3-storage", false},
{"connection refused", "s3-storage", false},
{"", "s3-storage", false},
}
for _, tc := range cases {
if got := psmdbErrorIsUnresolvedStorage(tc.msg, tc.storage); got != tc.want {
t.Errorf("psmdbErrorIsUnresolvedStorage(%q, %q): got %v want %v", tc.msg, tc.storage, got, tc.want)
}
}
}
// The first-injection requeue waits for the cluster cache to reflect the applied
// storage, but that wait is deadline-bounded: a cluster whose cache never
// reflects it must fail terminally, not requeue forever. Pin the bound — a fresh
// injection past the deadline fails the BackupJob without minting a Backup CR.
func TestReconcileMongoDB_FirstInjectionDeadlineFails(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
// No storage declared (fresh injection) and started well past the deadline.
job.Status.StartedAt = &metav1.Time{Time: time.Now().Add(-2 * psmdbDefaultBackupDeadline)}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
persisted := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, persisted); err != nil {
t.Fatalf("get job: %v", err)
}
if persisted.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed {
t.Fatalf("expected the BackupJob to be Failed past the injection deadline, got phase=%q", persisted.Status.Phase)
}
cond := apimeta.FindStatusCondition(persisted.Status.Conditions, "Ready")
if cond == nil || !strings.Contains(cond.Message, "did not reflect the injected storage") {
t.Errorf("expected a Ready condition naming the unreflected storage, got %+v", cond)
}
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list: %v", err)
}
if len(list.Items) != 0 {
t.Errorf("no operator Backup should be minted on the deadline failure, got %d", len(list.Items))
}
}
// The residual injection race: the operator can latch the Backup CR at
// state=error because it resolved storage from a cache that had not observed the
// apply. Within the deadline the driver must delete that CR and requeue (a fresh
// one resolves against a caught-up cache), not fail the BackupJob — that error
// is the driver's own race, not the tenant's failure.
func TestReconcileMongoDB_StorageRaceErrorRetried(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
job.Status.StartedAt = &metav1.Time{Time: time.Now()} // within deadline
// Storage already declared so the reconcile skips the first-injection requeue
// and reaches the state switch.
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`)},
}
errored := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant",
Name: "op-errored",
Finalizers: []string{psmdbDeleteBackupFinalizer},
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
State: psmdbtypes.StateError,
Error: `unable to get storage "s3-storage": not found`,
},
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster, errored)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("expected a retry requeue on the storage-race error, got %+v", res)
}
// The errored CR is deleted (Terminating with its finalizer) so a fresh one
// resolves against a caught-up cache.
got := &psmdbtypes.PerconaServerMongoDBBackup{}
gerr := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "op-errored"}, got)
if gerr == nil && got.DeletionTimestamp.IsZero() {
t.Errorf("the storage-race errored CR must be deleted before retrying; it was left untouched")
}
// The BackupJob is NOT failed.
persisted := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, persisted); err != nil {
t.Fatalf("get job: %v", err)
}
if persisted.Status.Phase == backupsv1alpha1.BackupJobPhaseFailed {
t.Errorf("the storage-race error must be retried, not fail the BackupJob")
}
if got := psmdbStorageRaceRetries(persisted); got != 1 {
t.Errorf("the retry must be counted on the BackupJob (the deleted CR is reaped), got %d", got)
}
}
// The storage-race retry is a system-bucket repair: it deletes the errored CR
// and re-mints. On the legacy flow the same operator error is the tenant's own
// storage problem and the CR is the tenant's — it must be left alone and the
// job failed terminally, never deleted and retried.
func TestReconcileMongoDB_StorageRaceErrorIsTerminalOnLegacy(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
app.Spec.Backup.UseSystemBucket = false
strategy.Spec.Template.S3 = nil
job.Status.StartedAt = &metav1.Time{Time: time.Now()}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"tenant-own","credentialsSecret":"mongodb-app1-s3-creds"}}`)},
}
errored := &psmdbtypes.PerconaServerMongoDBBackup{ // no delete-backup finalizer: legacy
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant", Name: "op-legacy-errored",
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{State: psmdbtypes.StateError, Error: `unable to get storage "s3-storage": not found`},
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster, errored)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
got := &psmdbtypes.PerconaServerMongoDBBackup{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "op-legacy-errored"}, got); err != nil || !got.DeletionTimestamp.IsZero() {
t.Errorf("a legacy operator CR must never be deleted by the storage-race retry, err=%v", err)
}
persisted := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, persisted); err != nil {
t.Fatalf("get job: %v", err)
}
if persisted.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed {
t.Errorf("a legacy storage error is terminal, got phase=%q", persisted.Status.Phase)
}
}
// The retry deletes the errored CR, but its release-lock finalizer keeps it
// briefly Terminating. The lookup must skip a Terminating CR so a fresh one is
// minted (against a caught-up cache) rather than re-observing the same error on
// the lingering object and requeuing forever.
func TestReconcileMongoDB_StorageRaceReMintsPastLingeringCR(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
job.Status.StartedAt = &metav1.Time{Time: time.Now()} // within deadline
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`)},
}
// An errored CR from a previous retry, still carrying the finalizer.
lingering := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant",
Name: "op-lingering",
Finalizers: []string{psmdbDeleteBackupFinalizer},
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{State: psmdbtypes.StateError, Error: `unable to get storage "s3-storage": not found`},
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster, lingering)
// Delete it so the finalizer holds it Terminating (DeletionTimestamp set).
if err := c.Delete(context.Background(), lingering); err != nil {
t.Fatalf("delete to make it Terminating: %v", err)
}
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
// A fresh, non-Terminating CR must exist — the retry re-minted past the
// lingering one instead of re-observing it.
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list: %v", err)
}
fresh := 0
for i := range list.Items {
if list.Items[i].DeletionTimestamp.IsZero() {
fresh++
}
}
if fresh == 0 {
t.Errorf("expected a fresh operator Backup minted past the Terminating CR, got none (lookup did not skip the lingering CR)")
}
}
// psmdbBackupUnstructured builds a live PerconaServerMongoDBBackup for the fake
// dynamic client, carrying status.state (omitted when empty).
func psmdbBackupUnstructured(namespace, name, state string) *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetGroupVersionKind(schema.GroupVersionKind{
Group: psmdbBackupGVR.Group,
Version: psmdbBackupGVR.Version,
Kind: "PerconaServerMongoDBBackup",
})
u.SetNamespace(namespace)
u.SetName(name)
// The typed fake client stamps every seeded object with this
// resourceVersion; the cancel pins its delete to the version the live read
// answered for, so the two must agree for the delete to go through.
u.SetResourceVersion("999")
if state != "" {
_ = unstructured.SetNestedField(u.Object, state, "status", "state")
}
return u
}
// newPsmdbBackupDynamicClient is the dynamic (uncached) client the cancel path
// re-reads live state through. Modelled on altinitystrategy_controller_test.go.
func newPsmdbBackupDynamicClient(objs ...*unstructured.Unstructured) *dynamicfake.FakeDynamicClient {
rtObjs := make([]runtime.Object, 0, len(objs))
for _, o := range objs {
rtObjs = append(rtObjs, o)
}
return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(
runtime.NewScheme(),
map[schema.GroupVersionResource]string{psmdbBackupGVR: "PerconaServerMongoDBBackupList", psmdbClusterGVR: "PerconaServerMongoDBList"},
rtObjs...,
)
}
// The deadline cancel re-reads the CR live and must delete it only on a positive
// not-started answer: a live state that has moved to running or ready, or a read
// that cannot be answered at all, must defer instead of deleting a CR whose
// delete-backup finalizer would take a running partial or a completed archive
// with it. The cached mdbBackup reads not-started (waiting) past the deadline in
// every case; only the live answer differs.
func TestReconcileMongoDB_TimedOutCancelUsesLiveState(t *testing.T) {
setup := func(t *testing.T, live *dynamicfake.FakeDynamicClient) (*BackupJobReconciler, client.Client, *ResolvedBackupConfig, *backupsv1alpha1.BackupJob) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
// Past the deadline but inside the live-read grace.
job.Status.StartedAt = &metav1.Time{Time: time.Now().Add(-psmdbDefaultBackupDeadline - psmdbLiveReadGrace/2)}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`)},
}
// Cached CR reads a not-started state (waiting) past the deadline.
cached := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant",
Name: "op-waiting",
Finalizers: []string{psmdbDeleteBackupFinalizer},
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{State: psmdbtypes.StateWaiting},
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster, cached)
return &BackupJobReconciler{Client: c, Interface: live, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}, c, resolved, job
}
deleted := func(t *testing.T, c client.Client) bool {
got := &psmdbtypes.PerconaServerMongoDBBackup{}
err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "op-waiting"}, got)
return err == nil && !got.DeletionTimestamp.IsZero()
}
failed := func(t *testing.T, c client.Client, name string) bool {
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
return p.Status.Phase == backupsv1alpha1.BackupJobPhaseFailed
}
t.Run("live still not-started → cancel and fail", func(t *testing.T) {
live := newPsmdbBackupDynamicClient(psmdbBackupUnstructured("tenant", "op-waiting", psmdbtypes.StateWaiting))
r, c, resolved, job := setup(t, live)
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if !deleted(t, c) {
t.Errorf("a positively not-started backup past the deadline must be cancelled")
}
if !failed(t, c, job.Name) {
t.Errorf("the BackupJob must be Failed after cancelling a timed-out not-started backup")
}
})
// The delete is pinned to the resourceVersion the live read answered for: a
// CR written between that read and the delete is exactly what the live read
// exists to rule out, so it must not be deleted on the stale answer.
t.Run("live not-started but moved since the read → not deleted", func(t *testing.T) {
stale := psmdbBackupUnstructured("tenant", "op-waiting", psmdbtypes.StateWaiting)
stale.SetResourceVersion("998")
live := newPsmdbBackupDynamicClient(stale)
r, c, resolved, job := setup(t, live)
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("a CR that moved since the live read must be re-read on the next poll, got %+v", res)
}
if deleted(t, c) {
t.Errorf("a CR written after the live read must not be deleted on that read's answer")
}
if failed(t, c, job.Name) {
t.Errorf("the BackupJob must not fail while the CR's live state is unsettled")
}
})
// No dynamic client at all is an unanswerable read, not a not-started
// answer: the cancel must defer exactly as it does for a failed read.
t.Run("no live client → defer, never cancel", func(t *testing.T) {
r, c, resolved, job := setup(t, nil)
r.Interface = nil
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("an unanswerable live read must requeue, got %+v", res)
}
if deleted(t, c) {
t.Errorf("with no live client the CR must not be deleted blind")
}
if failed(t, c, job.Name) {
t.Errorf("with no live client the BackupJob must not fail inside the grace")
}
// An unanswerable read is a named, grace-bounded wait, not a silent poll:
// treating it as a not-started answer would leave the job requeueing
// with no condition and no way to ever fail.
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
if cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready"); cond == nil || cond.Reason != "PerconaServerMongoDBLiveStateUnknown" {
t.Errorf("the missing live client must be named on the job (Ready=False PerconaServerMongoDBLiveStateUnknown), got %+v", cond)
}
})
t.Run("live turned running → defer, never cancel", func(t *testing.T) {
live := newPsmdbBackupDynamicClient(psmdbBackupUnstructured("tenant", "op-waiting", psmdbtypes.StateRunning))
r, c, resolved, job := setup(t, live)
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("a live running state must requeue, got %+v", res)
}
if deleted(t, c) {
t.Errorf("a CR whose live state turned running must not be cancelled (partial archive)")
}
if failed(t, c, job.Name) {
t.Errorf("the BackupJob must not fail while the live backup is running")
}
})
t.Run("live completed ready → defer, archive protected", func(t *testing.T) {
live := newPsmdbBackupDynamicClient(psmdbBackupUnstructured("tenant", "op-waiting", psmdbtypes.StateReady))
r, c, resolved, job := setup(t, live)
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if deleted(t, c) {
t.Errorf("a just-completed (ready) CR must not be deleted — its delete-backup finalizer would prune the archive")
}
})
t.Run("live read cannot be answered → defer, never cancel", func(t *testing.T) {
// Empty dynamic store: the live Get returns NotFound (an error), which must
// not be read as a not-started answer.
live := newPsmdbBackupDynamicClient()
r, c, resolved, job := setup(t, live)
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("an unanswerable live read must requeue, got %+v", res)
}
if deleted(t, c) {
t.Errorf("an unanswerable live read must defer, not delete blind")
}
if failed(t, c, job.Name) {
t.Errorf("an unanswerable live read must not fail the BackupJob inside the grace")
}
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
if cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready"); cond == nil || cond.Reason != "PerconaServerMongoDBLiveStateUnknown" {
t.Errorf("the deferral must be named on the job (Ready=False PerconaServerMongoDBLiveStateUnknown), got %+v", cond)
}
})
// Deferring cannot be forever: once the grace past the deadline is spent the
// job fails, and the CR is still not deleted blind.
t.Run("live read still unanswerable past the grace → fail, CR left alone", func(t *testing.T) {
live := newPsmdbBackupDynamicClient()
r, c, resolved, job := setup(t, live)
job.Status.StartedAt = &metav1.Time{Time: time.Now().Add(-2*psmdbDefaultBackupDeadline - psmdbLiveReadGrace)}
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if deleted(t, c) {
t.Errorf("an unconfirmed CR must never be deleted blind, even when the job finally fails")
}
if !failed(t, c, job.Name) {
t.Errorf("an unreadable CR must not pin the BackupJob Running forever; expected Failed past the grace")
}
})
t.Run("live requested → fail but leave the CR (already dispatched to pbm)", func(t *testing.T) {
live := newPsmdbBackupDynamicClient(psmdbBackupUnstructured("tenant", "op-waiting", psmdbtypes.StateRequested))
r, c, resolved, job := setup(t, live)
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if deleted(t, c) {
t.Errorf("a requested CR was already handed to pbm; deleting it cannot recall the command and would orphan the dump")
}
if !failed(t, c, job.Name) {
t.Errorf("the BackupJob must still fail on the deadline for a requested-but-never-run backup")
}
})
// The legacy flow keeps its hands-off contract: the deadline fails the job
// and the operator CR — the tenant's — is left in place, exactly as before
// this driver existed. No live read is consulted.
t.Run("legacy waiting past deadline → fail, CR left alone", func(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
app.Spec.Backup.UseSystemBucket = false
strategy.Spec.Template.S3 = nil
job.Status.StartedAt = &metav1.Time{Time: time.Now().Add(-2 * psmdbDefaultBackupDeadline)}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"tenant-own","credentialsSecret":"mongodb-app1-s3-creds"}}`)},
}
cached := &psmdbtypes.PerconaServerMongoDBBackup{ // no delete-backup finalizer: legacy
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant", Name: "op-waiting",
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{State: psmdbtypes.StateWaiting},
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster, cached)
// No dynamic client at all: the legacy path must not need a live read.
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if !failed(t, c, job.Name) {
t.Errorf("a legacy backup that never started must fail on the deadline")
}
got := &psmdbtypes.PerconaServerMongoDBBackup{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "op-waiting"}, got); err != nil || !got.DeletionTimestamp.IsZero() {
t.Errorf("the legacy operator CR must be left alone (hands-off), err=%v", err)
}
})
}
// PITR needs an oplog stream the useSystemBucket flow never captures, so a
// recoveryTime restore of a backup taken on that flow (recorded by the snapshot
// flag, not inferred from a Secret name) must fail with a named error rather
// than hand the operator a target it cannot serve. The refusal fires before the
// target cluster read, so no target need exist.
func TestReconcileMongoDBRestore_RefusesPITROnSystemBucketBackup(t *testing.T) {
apps := mongodbapp.GroupName
snap := &psmdbtypes.PerconaServerMongoDBBackup{
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
Destination: "s3://cozy-backups/tenant/app1/2026",
S3: &psmdbtypes.BackupStorageS3{Bucket: "cozy-backups", CredentialsSecret: psmdbDefaultCredentialsSecret},
},
}
raw, err := marshalMongoDBBackupSnapshot(snap, &strategyv1alpha1.MongoDBTemplate{Type: "logical"}, "s3-storage", nil, true)
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "src-backup"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "MongoDB", Name: "app1", APIGroup: &apps},
},
Status: backupsv1alpha1.BackupStatus{UnderlyingResources: raw},
}
restoreJob := newMongoDBRestoreJob("rj-pitr", "tenant")
restoreJob.Status.StartedAt = &metav1.Time{Time: time.Now()}
restoreJob.Status.Phase = backupsv1alpha1.RestoreJobPhaseRunning
restoreJob.Spec.Options = &runtime.RawExtension{Raw: []byte(`{"recoveryTime":"2026-08-05T12:34:56Z"}`)}
c := newMongoDBStrategyTestClient(t, backup, restoreJob)
r := &RestoreJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileMongoDBRestore(context.Background(), restoreJob.DeepCopy(), backup); err != nil {
t.Fatalf("reconcileMongoDBRestore: %v", err)
}
persisted := &backupsv1alpha1.RestoreJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "rj-pitr"}, persisted); err != nil {
t.Fatalf("get restore job: %v", err)
}
if persisted.Status.Phase != backupsv1alpha1.RestoreJobPhaseFailed {
t.Fatalf("expected the RestoreJob to fail on a PITR request over a system-bucket backup, got phase=%q", persisted.Status.Phase)
}
cond := apimeta.FindStatusCondition(persisted.Status.Conditions, "Ready")
if cond == nil || !strings.Contains(cond.Message, "point-in-time recovery is not available") {
t.Errorf("expected a Ready condition explaining PITR is unavailable, got %+v", cond)
}
}
// The refusal keys on the snapshot flow flag, not on the credentialsSecret name:
// a legacy (non-system-bucket) backup that happens to resolve to cozy-backups-creds
// must NOT have its recoveryTime restore refused. Keeps the check honest — the
// Secret name is a proxy a BackupClass can defeat either way.
func TestReconcileMongoDBRestore_PITRAllowedForNonSystemBucketBackup(t *testing.T) {
apps := mongodbapp.GroupName
snap := &psmdbtypes.PerconaServerMongoDBBackup{
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
Destination: "s3://tenant-own/tenant/app1/2026",
S3: &psmdbtypes.BackupStorageS3{Bucket: "tenant-own", CredentialsSecret: psmdbDefaultCredentialsSecret},
},
}
// useSystemBucket=false recorded on the snapshot despite the cozy-backups-creds name.
raw, err := marshalMongoDBBackupSnapshot(snap, &strategyv1alpha1.MongoDBTemplate{Type: "logical"}, "s3-storage", nil, false)
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "src-backup"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "MongoDB", Name: "app1", APIGroup: &apps},
},
Status: backupsv1alpha1.BackupStatus{UnderlyingResources: raw},
}
restoreJob := newMongoDBRestoreJob("rj-pitr-legacy", "tenant")
restoreJob.Status.StartedAt = &metav1.Time{Time: time.Now()}
restoreJob.Status.Phase = backupsv1alpha1.RestoreJobPhaseRunning
restoreJob.Spec.Options = &runtime.RawExtension{Raw: []byte(`{"recoveryTime":"2026-08-05T12:34:56Z"}`)}
c := newMongoDBStrategyTestClient(t, backup, restoreJob)
r := &RestoreJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
// No target cluster exists, so the reconcile requeues on the target read — it
// must get past the PITR refusal to reach it. A PITR-refusal failure here would
// mean the check still keyed on the Secret name.
if _, err := r.reconcileMongoDBRestore(context.Background(), restoreJob.DeepCopy(), backup); err != nil {
t.Fatalf("reconcileMongoDBRestore: %v", err)
}
persisted := &backupsv1alpha1.RestoreJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "rj-pitr-legacy"}, persisted); err != nil {
t.Fatalf("get restore job: %v", err)
}
if persisted.Status.Phase == backupsv1alpha1.RestoreJobPhaseFailed {
cond := apimeta.FindStatusCondition(persisted.Status.Conditions, "Ready")
if cond != nil && strings.Contains(cond.Message, "point-in-time recovery is not available") {
t.Errorf("PITR must not be refused for a non-system-bucket backup (keyed on Secret name, not the flow): %+v", cond)
}
}
}
// A recoveryTime restore must know which flow the backup was taken on; a
// snapshot that fails to decode leaves that unknown, and the refusal must fail
// closed rather than hand the operator a point-in-time target it may not serve.
// The live operator CR is present so source resolution succeeds on the live path
// (which never consults the snapshot) — exactly the case that reaches this guard.
func TestReconcileMongoDBRestore_PITRRefusedWhenSnapshotUndecodable(t *testing.T) {
apps := mongodbapp.GroupName
live := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "op-backup"},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
State: psmdbtypes.StateReady,
Destination: "s3://cozy-backups/tenant/app1/2026",
S3: &psmdbtypes.BackupStorageS3{Bucket: "cozy-backups", CredentialsSecret: psmdbDefaultCredentialsSecret},
},
}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "src-backup"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "MongoDB", Name: "app1", APIGroup: &apps},
DriverMetadata: map[string]string{psmdbBackupNameKey: "op-backup"},
},
// Valid JSON, wrong shape: the flag field is not a bool, so the typed decode fails.
Status: backupsv1alpha1.BackupStatus{UnderlyingResources: &runtime.RawExtension{Raw: []byte(`{"kind":"MongoDBBackupSnapshot","useSystemBucket":"not-a-bool"}`)}},
}
restoreJob := newMongoDBRestoreJob("rj-pitr-bad", "tenant")
restoreJob.Status.StartedAt = &metav1.Time{Time: time.Now()}
restoreJob.Status.Phase = backupsv1alpha1.RestoreJobPhaseRunning
restoreJob.Spec.Options = &runtime.RawExtension{Raw: []byte(`{"recoveryTime":"2026-08-05T12:34:56Z"}`)}
c := newMongoDBStrategyTestClient(t, backup, restoreJob, live)
r := &RestoreJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileMongoDBRestore(context.Background(), restoreJob.DeepCopy(), backup); err != nil {
t.Fatalf("reconcileMongoDBRestore: %v", err)
}
persisted := &backupsv1alpha1.RestoreJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "rj-pitr-bad"}, persisted); err != nil {
t.Fatalf("get restore job: %v", err)
}
if persisted.Status.Phase != backupsv1alpha1.RestoreJobPhaseFailed {
t.Fatalf("a PITR request over an undecodable snapshot must fail closed, got phase=%q", persisted.Status.Phase)
}
cond := apimeta.FindStatusCondition(persisted.Status.Conditions, "Ready")
if cond == nil || !strings.Contains(cond.Message, "cannot be decoded") {
t.Errorf("expected a Ready condition naming the undecodable snapshot, got %+v", cond)
}
}
// After an app opts into useSystemBucket the chart prunes its legacy
// <release>-s3-creds, and its live storage moves to the platform bucket, so a
// restore of an older legacy backup finds no same-bucket credential to adopt
// and keeps the source's — now deleted — Secret. It must fail with a legible,
// named reason before the operator sees a reference to a Secret that is gone.
func TestReconcileMongoDBRestore_FailsNamedWhenCredentialsSecretMissing(t *testing.T) {
apps := mongodbapp.GroupName
snap := &psmdbtypes.PerconaServerMongoDBBackup{
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
Destination: "s3://tenant-own/tenant/app1/2026",
S3: &psmdbtypes.BackupStorageS3{Bucket: "tenant-own", CredentialsSecret: "mongodb-app1-s3-creds"},
},
}
raw, err := marshalMongoDBBackupSnapshot(snap, &strategyv1alpha1.MongoDBTemplate{Type: "logical"}, "s3-storage", nil, false)
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
newBackup := func() *backupsv1alpha1.Backup {
return &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "src-backup"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "MongoDB", Name: "app1", APIGroup: &apps},
},
Status: backupsv1alpha1.BackupStatus{UnderlyingResources: raw},
}
}
// The same app, now on the system bucket: its only storage is on cozy-backups,
// so nothing on tenant-own is adoptable.
newTarget := func() *psmdbtypes.PerconaServerMongoDB {
return &psmdbtypes.PerconaServerMongoDB{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: mongodbNameForApp("app1")},
Spec: psmdbtypes.PerconaServerMongoDBSpec{Backup: psmdbtypes.PerconaServerMongoDBBackupConfig{
Enabled: true,
Storages: map[string]runtime.RawExtension{"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`)}},
}},
}
}
newRJ := func(name string) *backupsv1alpha1.RestoreJob {
rj := newMongoDBRestoreJob(name, "tenant")
rj.Status.StartedAt = &metav1.Time{Time: time.Now()}
rj.Status.Phase = backupsv1alpha1.RestoreJobPhaseRunning
return rj
}
getRJ := func(t *testing.T, c client.Client, name string) *backupsv1alpha1.RestoreJob {
p := &backupsv1alpha1.RestoreJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: name}, p); err != nil {
t.Fatalf("get restore job: %v", err)
}
return p
}
// Like the sibling preconditions (target absent, backups disabled) the check
// waits until the restore deadline — the Secret can be re-created — naming
// the reason on the job, and only then fails.
t.Run("missing Secret waits with a named reason", func(t *testing.T) {
backup, rj := newBackup(), newRJ("rj-nocred")
c := newMongoDBStrategyTestClient(t, backup, rj, newTarget())
r := &RestoreJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
res, err := r.reconcileMongoDBRestore(context.Background(), rj.DeepCopy(), backup)
if err != nil {
t.Fatalf("reconcileMongoDBRestore: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("a missing Secret within the deadline must requeue, got %+v", res)
}
p := getRJ(t, c, "rj-nocred")
if p.Status.Phase == backupsv1alpha1.RestoreJobPhaseFailed {
t.Errorf("must not fail before the restore deadline")
}
cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready")
if cond == nil || cond.Reason != "RestoreCredentialsMissing" || !strings.Contains(cond.Message, "mongodb-app1-s3-creds") {
t.Errorf("expected Ready=False RestoreCredentialsMissing naming the Secret, got %+v", cond)
}
})
t.Run("missing Secret past the deadline fails with the named reason", func(t *testing.T) {
backup, rj := newBackup(), newRJ("rj-nocred-late")
rj.Status.StartedAt = &metav1.Time{Time: time.Now().Add(-2 * psmdbDefaultRestoreDeadline)}
c := newMongoDBStrategyTestClient(t, backup, rj, newTarget())
r := &RestoreJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileMongoDBRestore(context.Background(), rj.DeepCopy(), backup); err != nil {
t.Fatalf("reconcileMongoDBRestore: %v", err)
}
p := getRJ(t, c, "rj-nocred-late")
if p.Status.Phase != backupsv1alpha1.RestoreJobPhaseFailed {
t.Fatalf("expected Failed past the deadline, got phase=%q", p.Status.Phase)
}
if cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready"); cond == nil || cond.Reason != "RestoreCredentialsMissing" {
t.Errorf("expected RestoreCredentialsMissing, got %+v", cond)
}
})
// Once the operator restore exists the reference has been handed over; a
// Secret pruned mid-replay must not mark a restore still in progress Failed.
t.Run("an existing operator restore skips the check", func(t *testing.T) {
backup, rj := newBackup(), newRJ("rj-replaying")
inflight := &psmdbtypes.PerconaServerMongoDBRestore{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant", Name: "rj-replaying-abc",
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: rj.Name,
backupsv1alpha1.OwningJobNamespaceLabel: rj.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBRestoreStatus{State: psmdbtypes.StateRunning},
}
c := newMongoDBStrategyTestClient(t, backup, rj, newTarget(), inflight)
r := &RestoreJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileMongoDBRestore(context.Background(), rj.DeepCopy(), backup); err != nil {
t.Fatalf("reconcileMongoDBRestore: %v", err)
}
p := getRJ(t, c, "rj-replaying")
if cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready"); cond != nil && cond.Reason == "RestoreCredentialsMissing" {
t.Errorf("a restore already replaying must not be reported as missing credentials: %+v", cond)
}
if p.Status.Phase == backupsv1alpha1.RestoreJobPhaseFailed {
t.Errorf("a restore already replaying must not be failed by the pre-check")
}
})
t.Run("present Secret lets the restore proceed", func(t *testing.T) {
backup, rj := newBackup(), newRJ("rj-cred-ok")
secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "mongodb-app1-s3-creds"}}
c := newMongoDBStrategyTestClient(t, backup, rj, newTarget(), secret)
r := &RestoreJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileMongoDBRestore(context.Background(), rj.DeepCopy(), backup); err != nil {
t.Fatalf("reconcileMongoDBRestore: %v", err)
}
persisted := &backupsv1alpha1.RestoreJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "rj-cred-ok"}, persisted); err != nil {
t.Fatalf("get restore job: %v", err)
}
if cond := apimeta.FindStatusCondition(persisted.Status.Conditions, "Ready"); cond != nil && cond.Reason == "RestoreCredentialsMissing" {
t.Errorf("an existing credentialsSecret must not be reported missing: %+v", cond)
}
})
}
// Opting out (useSystemBucket true→false) stops the injection but leaves the
// driver's storage on the live cluster until the chart re-renders. A BackupJob
// in that window must not mint a CR — it would carry no delete-backup finalizer
// and write an object nothing owns into the platform bucket. The wait is named
// and deadline-bounded like the others.
func TestReconcileMongoDB_OptOutRefusesStalePlatformStorage(t *testing.T) {
setup := func(t *testing.T, startedAt time.Time) (*BackupJobReconciler, client.Client, *ResolvedBackupConfig, *backupsv1alpha1.BackupJob) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
app.Spec.Backup.UseSystemBucket = false // opted back out; the strategy still names cozy-backups
job.Status.StartedAt = &metav1.Time{Time: startedAt}
// Live cluster still carries the entry the driver injected.
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`)},
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster)
return &BackupJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}, c, resolved, job
}
countCRs := func(t *testing.T, c client.Client) int {
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list: %v", err)
}
return len(list.Items)
}
t.Run("within the deadline: no mint, named wait", func(t *testing.T) {
r, c, resolved, job := setup(t, time.Now())
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("expected a waiting requeue while the platform storage lingers, got %+v", res)
}
if n := countCRs(t, c); n != 0 {
t.Errorf("no operator Backup must be minted onto the platform bucket with useSystemBucket=false, got %d", n)
}
persisted := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, persisted); err != nil {
t.Fatalf("get job: %v", err)
}
cond := apimeta.FindStatusCondition(persisted.Status.Conditions, "Ready")
if cond == nil || cond.Reason != "PerconaServerMongoDBStorageStale" {
t.Errorf("expected Ready=False PerconaServerMongoDBStorageStale, got %+v", cond)
}
})
t.Run("past the deadline: fails, still no mint", func(t *testing.T) {
r, c, resolved, job := setup(t, time.Now().Add(-2*psmdbDefaultBackupDeadline))
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if n := countCRs(t, c); n != 0 {
t.Errorf("no operator Backup must be minted on the deadline failure either, got %d", n)
}
persisted := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, persisted); err != nil {
t.Fatalf("get job: %v", err)
}
if persisted.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed {
t.Errorf("expected the BackupJob to fail once the stale storage outlives the deadline, got phase=%q", persisted.Status.Phase)
}
})
// A CR minted while the flag was true carries the delete-backup finalizer and
// is still streaming into the platform bucket; flipping the flag must not
// route later polls into the refusal and fail it at the deadline.
t.Run("a dump already in flight is not pre-empted by the refusal", func(t *testing.T) {
r, c, resolved, job := setup(t, time.Now().Add(-2*psmdbDefaultBackupDeadline))
running := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant", Name: "op-inflight",
Finalizers: []string{psmdbDeleteBackupFinalizer},
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{State: psmdbtypes.StateRunning},
}
if err := c.Create(context.Background(), running); err != nil {
t.Fatalf("seed in-flight CR: %v", err)
}
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("an in-flight system-bucket dump must keep polling, got %+v", res)
}
persisted := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, persisted); err != nil {
t.Fatalf("get job: %v", err)
}
if persisted.Status.Phase == backupsv1alpha1.BackupJobPhaseFailed {
t.Errorf("the opt-out refusal must not fail a dump already streaming (flow comes from the CR's finalizer)")
}
cond := apimeta.FindStatusCondition(persisted.Status.Conditions, "Ready")
if cond == nil || cond.Reason != "PerconaServerMongoDBBackupRunning" {
t.Errorf("expected the running wait, not the stale-storage refusal, got %+v", cond)
}
got := &psmdbtypes.PerconaServerMongoDBBackup{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "op-inflight"}, got); err != nil || !got.DeletionTimestamp.IsZero() {
t.Errorf("the in-flight CR must be left alone, err=%v deleted=%v", err, err == nil && !got.DeletionTimestamp.IsZero())
}
})
// The injected entry is identified by its credentialsSecret, not by bucket
// name: a legacy tenant whose own bucket happens to carry the platform's name
// (external S3, admin-chosen name) must not be refused.
t.Run("a legacy storage on a same-named bucket with its own Secret is not refused", func(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
app.Spec.Backup.UseSystemBucket = false
job.Status.StartedAt = &metav1.Time{Time: time.Now()}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"cozy-backups","credentialsSecret":"mongodb-app1-s3-creds"}}`)},
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if n := countCRs(t, c); n != 1 {
t.Errorf("a legacy storage with the tenant's own Secret must mint normally, got %d CRs", n)
}
})
}
// A running dump polls through requeueMongoDBBackupWaiting for hours; the
// condition it writes is stable, so only the first reconcile may write status.
// Counts status updates on the BackupJob across two reconciles of the same
// fixture and requires exactly one.
func TestRequeueMongoDBBackupWaiting_WritesStatusOnlyOnChange(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
job.Status.StartedAt = &metav1.Time{Time: time.Now().Add(-4 * psmdbDefaultBackupDeadline)}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`)},
}
running := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant", Name: "op-running",
Finalizers: []string{psmdbDeleteBackupFinalizer},
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{State: psmdbtypes.StateRunning},
}
sch := runtime.NewScheme()
_ = scheme.AddToScheme(sch)
_ = backupsv1alpha1.AddToScheme(sch)
_ = strategyv1alpha1.AddToScheme(sch)
_ = psmdbtypes.AddToScheme(sch)
_ = mongodbapp.AddToScheme(sch)
statusWrites := 0
c := clientfake.NewClientBuilder().
WithScheme(sch).
WithObjects(job, strategy, app, cluster, running).
WithStatusSubresource(&backupsv1alpha1.BackupJob{}).
WithInterceptorFuncs(interceptor.Funcs{
SubResourceUpdate: func(ctx context.Context, cl client.Client, sub string, obj client.Object, opts ...client.SubResourceUpdateOption) error {
if _, ok := obj.(*backupsv1alpha1.BackupJob); ok && sub == "status" {
statusWrites++
}
return cl.SubResource(sub).Update(ctx, obj, opts...)
},
}).
Build()
r := &BackupJobReconciler{Client: c, Scheme: sch, Recorder: record.NewFakeRecorder(10)}
for pass := 1; pass <= 2; pass++ {
fresh := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, fresh); err != nil {
t.Fatalf("get job: %v", err)
}
if _, err := r.reconcileMongoDB(context.Background(), fresh, resolved); err != nil {
t.Fatalf("reconcileMongoDB pass %d: %v", pass, err)
}
}
// Pass 1 also flips Phase to Running (one write) and writes the condition;
// pass 2 must add nothing.
if statusWrites < 1 {
t.Fatalf("expected the first reconcile to write status, got %d writes", statusWrites)
}
first := statusWrites
fresh := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, fresh); err != nil {
t.Fatalf("get job: %v", err)
}
if _, err := r.reconcileMongoDB(context.Background(), fresh, resolved); err != nil {
t.Fatalf("reconcileMongoDB pass 3: %v", err)
}
if statusWrites != first {
t.Errorf("an unchanged running wait must not write status again: %d writes before, %d after", first, statusWrites)
}
}
// The flow a backup ran on is fixed at mint time by its CR's finalizer, and the
// artifact must record that — not the app flag re-read on the completing
// reconcile. Flip the flag to false while the system-bucket dump completes: the
// snapshot must still say useSystemBucket, or the PITR refusal and the S3
// backfill both switch off for an archive that lives in cozy-backups.
func TestReconcileMongoDB_ArtifactRecordsTheCRsFlowNotTheFlag(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
app.Spec.Backup.UseSystemBucket = false // flipped after the mint
job.Status.StartedAt = &metav1.Time{Time: time.Now()}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`)},
}
ready := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant", Name: "op-ready",
Finalizers: []string{psmdbDeleteBackupFinalizer}, // minted on the system-bucket flow
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
State: psmdbtypes.StateReady,
Destination: "s3://cozy-backups/tenant/app1/2026-09-20",
S3: &psmdbtypes.BackupStorageS3{Bucket: "cozy-backups", CredentialsSecret: psmdbDefaultCredentialsSecret},
},
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster, ready)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
persisted := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, persisted); err != nil {
t.Fatalf("get job: %v", err)
}
if persisted.Status.BackupRef == nil {
t.Fatalf("expected a Backup artifact, got phase=%q conditions=%+v", persisted.Status.Phase, persisted.Status.Conditions)
}
artifact := &backupsv1alpha1.Backup{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: persisted.Status.BackupRef.Name}, artifact); err != nil {
t.Fatalf("get artifact: %v", err)
}
snap, err := unmarshalMongoDBBackupSnapshot(artifact.Status.UnderlyingResources)
if err != nil || snap == nil {
t.Fatalf("snapshot: %v (%+v)", err, snap)
}
if !snap.UseSystemBucket {
t.Errorf("the snapshot must record the flow the CR ran on (useSystemBucket), not the app flag re-read at completion")
}
}
// On useSystemBucket the chart refuses backup.enabled=false, so a missing storage
// means the strategy carried nothing to inject; the wait must say that rather
// than send the tenant to a value they already have set.
func TestReconcileMongoDB_StrategyWithoutS3NamesTheCause(t *testing.T) {
cases := []struct {
name string
storages map[string]runtime.RawExtension
}{
{"no storage on the cluster", nil},
// The cluster still holds a legacy entry pointing at the tenant's own
// bucket. Nothing was injected, so a CR minted here would carry the prune
// finalizer against that bucket, and deleting the Backup would delete the
// tenant's archive. The refusal must not depend on the storage being absent.
{"legacy tenant-bucket storage still on the cluster", map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"tenant-private-bucket","credentialsSecret":"mongodb-app1-s3-creds"}}`)},
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
strategy.Spec.Template.S3 = nil
job.Status.StartedAt = &metav1.Time{Time: time.Now()}
cluster.Spec.Backup.Storages = tc.storages
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list: %v", err)
}
if len(list.Items) != 0 {
t.Errorf("no operator Backup may be minted without s3 coordinates to own, got %d (finalizers=%v)", len(list.Items), list.Items[0].Finalizers)
}
persisted := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, persisted); err != nil {
t.Fatalf("get job: %v", err)
}
cond := apimeta.FindStatusCondition(persisted.Status.Conditions, "Ready")
if cond == nil || cond.Reason != "MongoDBStrategyHasNoS3" || !strings.Contains(cond.Message, "no s3 coordinates") {
t.Errorf("expected Ready=False MongoDBStrategyHasNoS3 naming the missing coordinates, got %+v", cond)
}
})
}
}
// The app flag is a desired value: it reads true as soon as the tenant writes
// it, before helm-controller renders the revision. A cluster still carrying the
// chart's scheduled tasks and PITR stream was rendered without the flag, and
// they write to the storage key the driver would rewrite, so injecting would
// redirect the tenant's own nightly dump and oplog into the shared bucket. The
// driver must leave that cluster alone and hold the job, bounded by the deadline.
func TestReconcileMongoDB_LegacyRenderIsNotInjectedOver(t *testing.T) {
setup := func(t *testing.T, startedAt time.Time, pitrOnly bool) (*BackupJobReconciler, client.Client, *ResolvedBackupConfig, *backupsv1alpha1.BackupJob) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
job.Status.StartedAt = &metav1.Time{Time: startedAt}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"tenant-own","credentialsSecret":"mongodb-app1-s3-creds"}}`)},
}
if pitrOnly {
cluster.Spec.Backup.PITR = &psmdbtypes.PerconaServerMongoDBPITR{Enabled: true}
} else {
cluster.Spec.Backup.Tasks = []runtime.RawExtension{{Raw: []byte(`{"name":"daily-backup","enabled":true,"schedule":"0 2 * * *","keep":30,"storageName":"s3-storage","type":"logical"}`)}}
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster)
return &BackupJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}, c, resolved, job
}
assertUntouched := func(t *testing.T, c client.Client) {
got := &psmdbtypes.PerconaServerMongoDB{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "mongodb-app1"}, got); err != nil {
t.Fatalf("get cluster: %v", err)
}
if bucket, _ := psmdbStorageS3(got.Spec.Backup.Storages["s3-storage"]); bucket != "tenant-own" {
t.Errorf("a legacy-rendered cluster must not be injected over; storage now points at %q", bucket)
}
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list: %v", err)
}
if len(list.Items) != 0 {
t.Errorf("no operator Backup may be minted against a legacy render, got %d", len(list.Items))
}
}
getJob := func(t *testing.T, c client.Client, name string) *backupsv1alpha1.BackupJob {
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
return p
}
for _, pitrOnly := range []bool{false, true} {
name := "tasks still rendered"
if pitrOnly {
name = "pitr still rendered"
}
t.Run(name+": held, cluster untouched", func(t *testing.T) {
r, c, resolved, job := setup(t, time.Now(), pitrOnly)
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("expected a named wait for the release to render, got %+v", res)
}
assertUntouched(t, c)
p := getJob(t, c, job.Name)
if cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready"); cond == nil || cond.Reason != "PerconaServerMongoDBLegacyRender" {
t.Errorf("expected Ready=False PerconaServerMongoDBLegacyRender, got %+v", cond)
}
})
}
t.Run("past the deadline: fails, cluster still untouched", func(t *testing.T) {
r, c, resolved, job := setup(t, time.Now().Add(-2*psmdbDefaultBackupDeadline), false)
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
assertUntouched(t, c)
if p := getJob(t, c, job.Name); p.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed {
t.Errorf("a release that never renders the flag must fail the job on the deadline, got phase=%q", p.Status.Phase)
}
})
}
// The prune finalizer means "the platform owns this archive", so it must follow
// where the storage actually points, not the flag: if the apply's merged view
// carries anything but the strategy's coordinates on the entry (a mutating
// webhook, a concurrent writer), no CR may be minted against it. Each term is
// varied on its own: the credentialsSecret is what tells a tenant's entry from
// the platform's when a bucket name collides, so a foreign credential on the
// platform bucket must be refused just as a foreign bucket is.
func TestReconcileMongoDB_ForeignStorageIsNotOwned(t *testing.T) {
cases := []struct {
name string
foreign string
named string
}{
{"foreign bucket, platform credential", `{"type":"s3","s3":{"bucket":"somebody-elses","credentialsSecret":"cozy-backups-creds"}}`, "somebody-elses"},
{"platform bucket, foreign credential", `{"type":"s3","s3":{"bucket":"cozy-backups","credentialsSecret":"mongodb-app1-s3-creds"}}`, "mongodb-app1-s3-creds"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
job.Status.StartedAt = &metav1.Time{Time: time.Now()}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`)},
}
sch := runtime.NewScheme()
_ = scheme.AddToScheme(sch)
_ = backupsv1alpha1.AddToScheme(sch)
_ = strategyv1alpha1.AddToScheme(sch)
_ = psmdbtypes.AddToScheme(sch)
_ = mongodbapp.AddToScheme(sch)
foreign := runtime.RawExtension{Raw: []byte(tc.foreign)}
c := clientfake.NewClientBuilder().WithScheme(sch).WithObjects(job, strategy, app, cluster).
WithStatusSubresource(&backupsv1alpha1.BackupJob{}).
WithInterceptorFuncs(interceptor.Funcs{
Patch: func(ctx context.Context, cl client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
err := cl.Patch(ctx, obj, patch, opts...)
if psmdb, ok := obj.(*psmdbtypes.PerconaServerMongoDB); ok && err == nil {
psmdb.Spec.Backup.Storages["s3-storage"] = foreign
}
return err
},
}).Build()
r := &BackupJobReconciler{Client: c, Scheme: sch, Recorder: record.NewFakeRecorder(10)}
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("expected a named wait on the foreign storage, got %+v", res)
}
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list: %v", err)
}
if len(list.Items) != 0 {
t.Errorf("no operator Backup may be minted against a storage that does not carry the platform coordinates, got %d (finalizers=%v)", len(list.Items), list.Items[0].Finalizers)
}
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
if cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready"); cond == nil || cond.Reason != "PerconaServerMongoDBStorageForeign" || !strings.Contains(cond.Message, tc.named) {
t.Errorf("expected Ready=False PerconaServerMongoDBStorageForeign naming %q, got %+v", tc.named, cond)
}
})
}
}
// Once a job has its operator CR the storage that CR names is fixed for the
// dump, so the polls that follow must not touch the cluster or fail the job on
// anything decided at mint time: not the legacy-render hold (a cluster that
// re-acquired the chart's tasks and pitr after the mint), not the precondition
// (backups disabled on the cluster mid-dump), and not the no-coordinates hold
// (the strategy's s3 block dropped mid-dump). Each hold fails a job at the
// 30-minute deadline, so the job is aged past it: a streaming system-bucket
// dump must still be left to the state switch and its 24h ceiling, with the
// finalized CR reachable through the job that owns it.
func TestReconcileMongoDB_ExistingCRIsNotInjectedOver(t *testing.T) {
cases := []struct {
name string
shape func(cluster *psmdbtypes.PerconaServerMongoDB, strategy *strategyv1alpha1.MongoDB)
}{
{"tasks re-acquired after the mint", func(cluster *psmdbtypes.PerconaServerMongoDB, _ *strategyv1alpha1.MongoDB) {
cluster.Spec.Backup.Tasks = []runtime.RawExtension{{Raw: []byte(`{"name":"daily-backup","enabled":true,"schedule":"0 2 * * *","storageName":"s3-storage"}`)}}
}},
{"backups disabled after the mint", func(cluster *psmdbtypes.PerconaServerMongoDB, _ *strategyv1alpha1.MongoDB) {
cluster.Spec.Backup.Enabled = false
}},
{"strategy lost its s3 block after the mint", func(_ *psmdbtypes.PerconaServerMongoDB, strategy *strategyv1alpha1.MongoDB) {
strategy.Spec.Template.S3 = nil
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
job.Status.StartedAt = &metav1.Time{Time: time.Now().Add(-2 * psmdbDefaultBackupDeadline)}
// The release rendered back to the legacy flow after the CR was minted:
// the tenant's own storage is on the cluster again.
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"tenant-own","credentialsSecret":"mongodb-app1-s3-creds"}}`)},
}
tc.shape(cluster, strategy)
minted := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant", Name: "op-minted", Finalizers: []string{psmdbDeleteBackupFinalizer},
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{State: psmdbtypes.StateRunning},
}
sch := runtime.NewScheme()
_ = scheme.AddToScheme(sch)
_ = backupsv1alpha1.AddToScheme(sch)
_ = strategyv1alpha1.AddToScheme(sch)
_ = psmdbtypes.AddToScheme(sch)
_ = mongodbapp.AddToScheme(sch)
applies := 0
c := clientfake.NewClientBuilder().WithScheme(sch).WithObjects(job, strategy, app, cluster, minted).
WithStatusSubresource(&backupsv1alpha1.BackupJob{}).
WithInterceptorFuncs(interceptor.Funcs{
Patch: func(ctx context.Context, cl client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
if _, ok := obj.(*psmdbtypes.PerconaServerMongoDB); ok {
applies++
}
return cl.Patch(ctx, obj, patch, opts...)
},
}).Build()
r := &BackupJobReconciler{Client: c, Scheme: sch, Recorder: record.NewFakeRecorder(10)}
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("a job with a running CR must keep polling it, got %+v", res)
}
if applies != 0 {
t.Errorf("no storage may be applied once the job has its CR, got %d apply patch(es) on the cluster", applies)
}
got := &psmdbtypes.PerconaServerMongoDB{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "mongodb-app1"}, got); err != nil {
t.Fatalf("get cluster: %v", err)
}
if bucket, _ := psmdbStorageS3(got.Spec.Backup.Storages["s3-storage"]); bucket != "tenant-own" {
t.Errorf("the tenant's storage was rewritten to %q", bucket)
}
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
if p.Status.Phase == backupsv1alpha1.BackupJobPhaseFailed {
t.Errorf("a streaming CR must not be failed by a mint-time hold past the deadline, got %q", p.Status.Message)
}
cr := &psmdbtypes.PerconaServerMongoDBBackup{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "op-minted"}, cr); err != nil || !cr.DeletionTimestamp.IsZero() {
t.Errorf("the running CR must be left alone, err=%v", err)
}
})
}
}
// A change to the strategy coordinates rewrites an entry the operator has
// already cached, so the next mint must wait a poll for the cache to observe
// the new entry exactly as the first injection does; otherwise the operator
// may resolve the previous coordinates and write the dump there. Only a real
// change waits: an entry already as applied mints in the same pass.
func TestReconcileMongoDB_CoordinateChangeRequeuesBeforeMinting(t *testing.T) {
mint := func(t *testing.T, seeded string) (ctrl.Result, int, string) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{"s3-storage": {Raw: []byte(seeded)}}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list: %v", err)
}
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
reason := ""
if cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready"); cond != nil {
reason = cond.Reason
}
return res, len(list.Items), reason
}
asApplied := `{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`
movedBucket := `{"type":"s3","s3":{"bucket":"cozy-backups-old","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`
t.Run("entry already as applied: mints in the same pass", func(t *testing.T) {
_, minted, reason := mint(t, asApplied)
if minted != 1 {
t.Errorf("an entry already carrying the current coordinates must mint without waiting, got %d CRs (reason=%q)", minted, reason)
}
})
// forcePathStyle is omitted from the apply when the strategy leaves it
// unset, so an entry still carrying it is about to change too.
t.Run("strategy dropped forcePathStyle since the last injection: waits a poll first", func(t *testing.T) {
withPathStyle := `{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false,"forcePathStyle":true}}`
res, minted, reason := mint(t, withPathStyle)
if res.RequeueAfter == 0 || minted != 0 || reason != "PerconaServerMongoDBStorageInjected" {
t.Errorf("an entry about to lose forcePathStyle must wait a poll, got res=%+v minted=%d reason=%q", res, minted, reason)
}
})
t.Run("bucket moved since the last injection: waits a poll first", func(t *testing.T) {
res, minted, reason := mint(t, movedBucket)
if res.RequeueAfter == 0 {
t.Fatalf("expected a requeue after the coordinates changed, got %+v", res)
}
if minted != 0 {
t.Errorf("no CR may be minted in the pass that rewrote the coordinates, got %d", minted)
}
if reason != "PerconaServerMongoDBStorageInjected" {
t.Errorf("the wait must be named PerconaServerMongoDBStorageInjected, got %q", reason)
}
})
}
// The mint-time reads decide from the apiserver, not the manager's cache: a
// cache lagging a re-render back to the legacy flow shows the flag true with
// the tasks absent, and the apply would force-own the entry the tenant's
// restored tasks name. With a dynamic client the live cluster wins.
func TestReconcileMongoDB_MintTimeReadsTheClusterLive(t *testing.T) {
job, strategy, app, cached, resolved := mongodbInjectFixture(true)
// The cache still shows the useSystemBucket render: enabled, no storage.
live := cached.DeepCopy()
live.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"tenant-own","credentialsSecret":"mongodb-app1-s3-creds"}}`)},
}
live.Spec.Backup.Tasks = []runtime.RawExtension{{Raw: []byte(`{"name":"daily-backup","enabled":true,"storageName":"s3-storage"}`)}}
liveObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(live)
if err != nil {
t.Fatalf("to unstructured: %v", err)
}
u := &unstructured.Unstructured{Object: liveObj}
u.SetGroupVersionKind(psmdbtypes.GroupVersion.WithKind("PerconaServerMongoDB"))
dyn := newPsmdbBackupDynamicClient(u)
sch := runtime.NewScheme()
_ = scheme.AddToScheme(sch)
_ = backupsv1alpha1.AddToScheme(sch)
_ = strategyv1alpha1.AddToScheme(sch)
_ = psmdbtypes.AddToScheme(sch)
_ = mongodbapp.AddToScheme(sch)
applies := 0
c := clientfake.NewClientBuilder().WithScheme(sch).WithObjects(job, strategy, app, cached).
WithStatusSubresource(&backupsv1alpha1.BackupJob{}).
WithInterceptorFuncs(interceptor.Funcs{
Patch: func(ctx context.Context, cl client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
if _, ok := obj.(*psmdbtypes.PerconaServerMongoDB); ok {
applies++
}
return cl.Patch(ctx, obj, patch, opts...)
},
}).Build()
r := &BackupJobReconciler{Client: c, Interface: dyn, Scheme: sch, Recorder: record.NewFakeRecorder(10)}
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("expected the legacy-render hold off the live read, got %+v", res)
}
if applies != 0 {
t.Errorf("the live cluster carries tasks; nothing may be applied on the cached view, got %d patch(es)", applies)
}
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
if cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready"); cond == nil || cond.Reason != "PerconaServerMongoDBLegacyRender" {
t.Errorf("expected Ready=False PerconaServerMongoDBLegacyRender from the live read, got %+v", cond)
}
}
// The storage-race retry recognises one operator error and only that. Any other
// failure of a system-bucket CR is the tenant's to see: terminal, with the
// operator's own text, the CR left in place and no retry spent.
func TestReconcileMongoDB_NonStorageErrorIsTerminal(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
job.Status.StartedAt = &metav1.Time{Time: time.Now()}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`)},
}
errored := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant", Name: "op-pbm-failed", Finalizers: []string{psmdbDeleteBackupFinalizer},
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{State: psmdbtypes.StateError, Error: "pbm-agent: connection refused"},
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster, errored)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
if p.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed || !strings.Contains(p.Status.Message, "connection refused") {
t.Errorf("an operator error other than the storage race must fail the job with the operator's text, got phase=%q msg=%q", p.Status.Phase, p.Status.Message)
}
if got := psmdbStorageRaceRetries(p); got != 0 {
t.Errorf("no storage-race retry may be spent on an unrelated error, got %d", got)
}
got := &psmdbtypes.PerconaServerMongoDBBackup{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "op-pbm-failed"}, got); err != nil || !got.DeletionTimestamp.IsZero() {
t.Errorf("the errored CR must be left in place for the tenant to inspect, err=%v", err)
}
}
// The storage-race retry deletes the errored CR and mints a fresh one; at the
// pinned operator version the deleted CR is reaped at once (delete-backup is
// dropped without pruning on a non-ready CR), so nothing about the surviving
// CRs records how many retries were spent. The budget must survive that reap:
// this drives the loop with the operator simulated between polls, reaping the
// deleted CR and erroring the fresh one, and expects the job to stop at the
// budget with the operator's error rather than churn a CR per poll.
func TestReconcileMongoDB_StorageRaceRetryBudgetExhausted(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
job.Status.StartedAt = &metav1.Time{Time: time.Now()}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`)},
}
first := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant", Name: "op-try-1", Finalizers: []string{psmdbDeleteBackupFinalizer},
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{State: psmdbtypes.StateError, Error: `unable to get storage "s3-storage": not found`},
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster, first)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
listCRs := func(t *testing.T) []psmdbtypes.PerconaServerMongoDBBackup {
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list: %v", err)
}
return list.Items
}
// The operator between polls: a deleted CR loses its finalizer and goes
// away, and the fresh CR resolves the storage from a stale cache and errors.
operator := func(t *testing.T) {
for _, cr := range listCRs(t) {
cr := cr
if !cr.DeletionTimestamp.IsZero() {
base := cr.DeepCopy()
cr.Finalizers = nil
if err := c.Patch(context.Background(), &cr, client.MergeFrom(base)); err != nil && !apierrors.IsNotFound(err) {
t.Fatalf("reap %s: %v", cr.Name, err)
}
continue
}
if cr.Status.State == "" {
cr.Status.State = psmdbtypes.StateError
cr.Status.Error = `unable to get storage "s3-storage": not found`
if err := c.Update(context.Background(), &cr); err != nil {
t.Fatalf("error %s: %v", cr.Name, err)
}
}
}
}
getJob := func(t *testing.T) *backupsv1alpha1.BackupJob {
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
return p
}
// Each retry: one reconcile deletes the errored CR, the operator reaps it,
// the next reconcile mints a fresh one, and the operator errors that too.
for retry := 1; retry <= psmdbStorageRaceRetryBudget; retry++ {
res, err := r.reconcileMongoDB(context.Background(), getJob(t), resolved)
if err != nil {
t.Fatalf("retry %d: reconcileMongoDB: %v", retry, err)
}
if res.RequeueAfter == 0 {
t.Fatalf("retry %d: expected a retry requeue, got %+v", retry, res)
}
if p := getJob(t); p.Status.Phase == backupsv1alpha1.BackupJobPhaseFailed {
t.Fatalf("retry %d: the job must not fail inside the budget", retry)
} else if got := psmdbStorageRaceRetries(p); got != retry {
t.Fatalf("retry %d: the BackupJob must carry the retry count, got %d", retry, got)
}
operator(t)
// Nothing survives the reap: the count lives on the job or nowhere.
if crs := listCRs(t); len(crs) != 0 {
t.Fatalf("retry %d: expected no CR after the operator reaped the deleted one, got %d", retry, len(crs))
}
if _, err := r.reconcileMongoDB(context.Background(), getJob(t), resolved); err != nil {
t.Fatalf("retry %d: re-mint reconcileMongoDB: %v", retry, err)
}
if crs := listCRs(t); len(crs) != 1 || crs[0].Status.State != "" {
t.Fatalf("retry %d: expected exactly one fresh CR minted, got %+v", retry, crs)
}
operator(t)
}
// Budget spent: the next error is terminal, with the operator's message, and
// the live errored CR is not deleted.
if _, err := r.reconcileMongoDB(context.Background(), getJob(t), resolved); err != nil {
t.Fatalf("reconcileMongoDB past the budget: %v", err)
}
p := getJob(t)
if p.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed {
t.Fatalf("with the retry budget spent the job must fail, got phase=%q", p.Status.Phase)
}
if !strings.Contains(p.Status.Message, "unable to get storage") || !strings.Contains(p.Status.Message, fmt.Sprintf("after %d retries", psmdbStorageRaceRetryBudget)) {
t.Errorf("the terminal message must carry the operator's error and the spent budget, got %q", p.Status.Message)
}
crs := listCRs(t)
if len(crs) != 1 || !crs[0].DeletionTimestamp.IsZero() {
t.Errorf("the terminal path must leave the live errored CR in place and mint no other, got %d CRs", len(crs))
}
}
// The archive exists once the operator CR is ready; the Backup object is the
// only thing that can ever reach it. A transient Create failure must retry (the
// job stays Running) rather than fail terminally with no artifact, and when the
// window is spent the failure names the CR that still holds the archive.
func TestReconcileMongoDB_ArtifactCreateFailureRetriesThenNamesTheCR(t *testing.T) {
build := func(t *testing.T, startedAgo time.Duration, completed *time.Time) (*BackupJobReconciler, client.Client, *record.FakeRecorder, *ResolvedBackupConfig, *backupsv1alpha1.BackupJob) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
job.Status.StartedAt = &metav1.Time{Time: time.Now().Add(-startedAgo)}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`)},
}
ready := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant", Name: "op-ready", Finalizers: []string{psmdbDeleteBackupFinalizer},
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
State: psmdbtypes.StateReady,
Destination: "s3://cozy-backups/tenant/app1/2026-09-20",
S3: &psmdbtypes.BackupStorageS3{Bucket: "cozy-backups", CredentialsSecret: psmdbDefaultCredentialsSecret},
},
}
if completed != nil {
ready.Status.Completed = &metav1.Time{Time: *completed}
}
sch := runtime.NewScheme()
_ = scheme.AddToScheme(sch)
_ = backupsv1alpha1.AddToScheme(sch)
_ = strategyv1alpha1.AddToScheme(sch)
_ = psmdbtypes.AddToScheme(sch)
_ = mongodbapp.AddToScheme(sch)
c := clientfake.NewClientBuilder().WithScheme(sch).WithObjects(job, strategy, app, cluster, ready).
WithStatusSubresource(&backupsv1alpha1.BackupJob{}, &backupsv1alpha1.Backup{}).
WithInterceptorFuncs(interceptor.Funcs{
Create: func(ctx context.Context, cl client.WithWatch, obj client.Object, opts ...client.CreateOption) error {
if _, ok := obj.(*backupsv1alpha1.Backup); ok {
return apierrors.NewServiceUnavailable("etcd timeout")
}
return cl.Create(ctx, obj, opts...)
},
}).Build()
rec := record.NewFakeRecorder(10)
return &BackupJobReconciler{Client: c, Scheme: sch, Recorder: rec}, c, rec, resolved, job
}
getJob := func(t *testing.T, c client.Client, name string) *backupsv1alpha1.BackupJob {
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
return p
}
t.Run("inside the window: retried, not failed", func(t *testing.T) {
now := time.Now()
r, c, _, resolved, job := build(t, 10*time.Minute, &now)
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err == nil {
t.Fatalf("a transient artifact Create failure must be returned for backoff, got nil")
}
if p := getJob(t, c, job.Name); p.Status.Phase == backupsv1alpha1.BackupJobPhaseFailed {
t.Errorf("a transient artifact Create failure must not fail the job while the window is open")
}
})
t.Run("window spent: fails and names the CR holding the archive", func(t *testing.T) {
completed := time.Now().Add(-2 * psmdbLiveReadGrace)
r, c, rec, resolved, job := build(t, 10*time.Minute, &completed)
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
p := getJob(t, c, job.Name)
if p.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed {
t.Fatalf("expected Failed once the artifact window is spent, got phase=%q", p.Status.Phase)
}
if cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready"); cond == nil || !strings.Contains(cond.Message, "op-ready") || !strings.Contains(cond.Message, psmdbLiveReadGrace.String()) {
t.Errorf("the terminal message must name the CR that still holds the archive and the window it retried for, got %+v", cond)
}
select {
case ev := <-rec.Events:
if !strings.Contains(ev, "BackupArtifactNotCreated") || !strings.Contains(ev, "op-ready") {
t.Errorf("expected a BackupArtifactNotCreated Warning naming the CR, got %q", ev)
}
default:
t.Errorf("expected a Warning naming the orphaned archive's CR, got none")
}
})
// With no completion time on the CR the window runs from the job's start,
// the deadline plus the grace, and the failure must say which window it was.
t.Run("no completion time: window is deadline plus grace, and named", func(t *testing.T) {
r, c, _, resolved, job := build(t, 2*(psmdbDefaultBackupDeadline+psmdbLiveReadGrace), nil)
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
p := getJob(t, c, job.Name)
if p.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed {
t.Fatalf("expected Failed once the start-based window is spent, got phase=%q", p.Status.Phase)
}
if want := (psmdbDefaultBackupDeadline + psmdbLiveReadGrace).String(); !strings.Contains(p.Status.Message, want) {
t.Errorf("the failure must name the window in force (%s), got %q", want, p.Status.Message)
}
})
}
// A legacy Backup owns nothing on the shared bucket; before this driver its
// cleanup was an unconditional no-op. With the flow recorded on the snapshot,
// cleanup must release a legacy Backup without any apiserver read, so a throttle
// or revoked verb on a CR the driver does not own cannot hold it Terminating.
func TestCleanupMongoDBBackup_LegacySnapshotSkipsTheRead(t *testing.T) {
legacySnap := &psmdbtypes.PerconaServerMongoDBBackup{Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
Destination: "s3://tenant-own/x", S3: &psmdbtypes.BackupStorageS3{Bucket: "tenant-own", CredentialsSecret: "mongodb-app1-s3-creds"},
}}
raw, err := marshalMongoDBBackupSnapshot(legacySnap, &strategyv1alpha1.MongoDBTemplate{Type: "logical"}, "s3-storage", nil, false)
if err != nil {
t.Fatalf("marshal: %v", err)
}
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
_ = backupsv1alpha1.AddToScheme(s)
_ = psmdbtypes.AddToScheme(s)
reads := 0
c := clientfake.NewClientBuilder().WithScheme(s).WithInterceptorFuncs(interceptor.Funcs{
Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if _, ok := obj.(*psmdbtypes.PerconaServerMongoDBBackup); ok {
reads++
return apierrors.NewTooManyRequests("apiserver throttled", 1)
}
return cl.Get(ctx, key, obj, opts...)
},
}).Build()
r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "legacy-bk"},
Spec: backupsv1alpha1.BackupSpec{DriverMetadata: map[string]string{psmdbBackupNameKey: "op-legacy"}},
Status: backupsv1alpha1.BackupStatus{UnderlyingResources: raw},
}
res, err := r.cleanupMongoDBBackup(context.Background(), backup)
if err != nil || res.RequeueAfter != 0 {
t.Fatalf("a legacy Backup must release without depending on the operator CR read, got res=%+v err=%v", res, err)
}
if reads != 0 {
t.Errorf("a legacy Backup must not read the operator CR at all, got %d reads", reads)
}
// The shortcut is for this driver's own snapshot shape only: a snapshot of
// another kind says nothing about the flow, so cleanup must fall through to
// the read rather than release on it.
t.Run("foreign snapshot kind falls through to the read", func(t *testing.T) {
foreign := &runtime.RawExtension{Raw: []byte(`{"kind":"SomethingElse","apiVersion":"v1","useSystemBucket":false}`)}
reads = 0
other := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "foreign-bk"},
Spec: backupsv1alpha1.BackupSpec{DriverMetadata: map[string]string{psmdbBackupNameKey: "op-foreign"}},
Status: backupsv1alpha1.BackupStatus{UnderlyingResources: foreign},
}
if _, err := r.cleanupMongoDBBackup(context.Background(), other); err == nil {
t.Fatalf("a snapshot of another kind must not release without reading the operator CR")
}
if reads != 1 {
t.Errorf("expected the operator CR to be read once for a foreign snapshot kind, got %d reads", reads)
}
})
}
// The other direction of the snapshot gate: a Backup whose snapshot records the
// system-bucket flow must go on to the operator CR and delete it, so the prune
// finalizer frees the archive. Releasing it the way a legacy one is released
// would leave the object in the shared bucket with nothing left to prune it.
func TestCleanupMongoDBBackup_SystemBucketSnapshotReachesThePrune(t *testing.T) {
sbSnap := &psmdbtypes.PerconaServerMongoDBBackup{Status: psmdbtypes.PerconaServerMongoDBBackupStatus{
Destination: "s3://cozy-backups/tenant/app1/x", S3: &psmdbtypes.BackupStorageS3{Bucket: "cozy-backups", CredentialsSecret: psmdbDefaultCredentialsSecret},
}}
raw, err := marshalMongoDBBackupSnapshot(sbSnap, &strategyv1alpha1.MongoDBTemplate{Type: "logical"}, "s3-storage", nil, true)
if err != nil {
t.Fatalf("marshal: %v", err)
}
owned := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "op-sb", Finalizers: []string{psmdbDeleteBackupFinalizer}},
}
c := newMongoDBStrategyTestClient(t, owned, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant"}})
r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "sb-bk"},
Spec: backupsv1alpha1.BackupSpec{DriverMetadata: map[string]string{psmdbBackupNameKey: "op-sb"}},
Status: backupsv1alpha1.BackupStatus{UnderlyingResources: raw},
}
res, err := r.cleanupMongoDBBackup(context.Background(), backup)
if err != nil {
t.Fatalf("cleanupMongoDBBackup: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("a system-bucket Backup must wait for the operator to prune, got %+v", res)
}
got := &psmdbtypes.PerconaServerMongoDBBackup{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "op-sb"}, got); err != nil {
t.Fatalf("get operator CR: %v", err)
}
if got.DeletionTimestamp.IsZero() {
t.Errorf("a system-bucket Backup must delete its operator CR so the delete-backup finalizer prunes the archive")
}
}
// After a tenant opts out, the platform may drop or rename the strategy's s3
// block; the lingering injected entry still carries the platform default Secret
// and must still be refused, or a legacy CR is minted onto the platform bucket.
func TestReconcileMongoDB_OptOutRefusalSurvivesStrategyWithoutS3(t *testing.T) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
app.Spec.Backup.UseSystemBucket = false
strategy.Spec.Template.S3 = nil // platform dropped the coordinates after the opt-out
job.Status.StartedAt = &metav1.Time{Time: time.Now()}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{
"s3-storage": {Raw: []byte(`{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`)},
}
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list: %v", err)
}
if len(list.Items) != 0 {
t.Errorf("the lingering injected entry must still be refused when the strategy carries no s3, got %d CRs minted", len(list.Items))
}
persisted := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, persisted); err != nil {
t.Fatalf("get job: %v", err)
}
if cond := apimeta.FindStatusCondition(persisted.Status.Conditions, "Ready"); cond == nil || cond.Reason != "PerconaServerMongoDBStorageStale" {
t.Errorf("expected Ready=False PerconaServerMongoDBStorageStale, got %+v", cond)
}
}
// settledPlatformEntry is the storage entry exactly as the driver applies it
// for mongodbInjectFixture's strategy, so a cluster seeded with it mints
// without the settle wait.
const settledPlatformEntry = `{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","region":"","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","insecureSkipTLSVerify":false}}`
// The entry on the spec is not the storage pbm writes to: the operator pushes
// spec.backup into pbm's own config in the cluster reconcile, stamping
// observedGeneration at its end and flipping PBMReady False then True around
// the push. A CR minted before both hold is resolved against the previous
// coordinates, so the mint must wait for both, bounded by the deadline.
func TestReconcileMongoDB_MintWaitsForPBMConfig(t *testing.T) {
run := func(t *testing.T, shape func(c *psmdbtypes.PerconaServerMongoDB), startedAgo time.Duration) (ctrl.Result, int, *backupsv1alpha1.BackupJob) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
job.Status.StartedAt = &metav1.Time{Time: time.Now().Add(-startedAgo)}
cluster.Spec.Backup.Storages = map[string]runtime.RawExtension{"s3-storage": {Raw: []byte(settledPlatformEntry)}}
shape(cluster)
c := newMongoDBStrategyTestClient(t, job, strategy, app, cluster)
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
if err := c.List(context.Background(), list, client.InNamespace("tenant")); err != nil {
t.Fatalf("list: %v", err)
}
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: job.Name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
return res, len(list.Items), p
}
held := func(t *testing.T, res ctrl.Result, minted int, p *backupsv1alpha1.BackupJob) {
if res.RequeueAfter == 0 || minted != 0 {
t.Fatalf("the mint must wait for the operator, got res=%+v minted=%d", res, minted)
}
if cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready"); cond == nil || cond.Reason != "PerconaServerMongoDBPBMConfigPending" {
t.Errorf("expected Ready=False PerconaServerMongoDBPBMConfigPending, got %+v", cond)
}
}
t.Run("operator has not seen this generation: held", func(t *testing.T) {
res, minted, p := run(t, func(c *psmdbtypes.PerconaServerMongoDB) {
c.Generation = 5
c.Status.ObservedGeneration = 4
}, 0)
held(t, res, minted, p)
})
t.Run("PBMReady False while the config is pushed: held", func(t *testing.T) {
res, minted, p := run(t, func(c *psmdbtypes.PerconaServerMongoDB) {
c.Status.Conditions = []psmdbtypes.PerconaServerMongoDBCondition{{Type: psmdbtypes.ConditionPBMReady, Status: "False"}}
}, 0)
held(t, res, minted, p)
})
t.Run("PBMReady never set (cluster never had a backup config): held", func(t *testing.T) {
res, minted, p := run(t, func(c *psmdbtypes.PerconaServerMongoDB) { c.Status.Conditions = nil }, 0)
held(t, res, minted, p)
})
t.Run("generation observed and PBMReady True: mints", func(t *testing.T) {
_, minted, _ := run(t, func(c *psmdbtypes.PerconaServerMongoDB) {
c.Generation = 5
c.Status.ObservedGeneration = 5
}, 0)
if minted != 1 {
t.Errorf("with the config applied the CR must be minted, got %d", minted)
}
})
t.Run("never applied past the deadline: fails, nothing minted", func(t *testing.T) {
_, minted, p := run(t, func(c *psmdbtypes.PerconaServerMongoDB) { c.Status.Conditions = nil }, 2*psmdbDefaultBackupDeadline)
if minted != 0 || p.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed || !strings.Contains(p.Status.Message, "backup configuration applied") {
t.Errorf("an operator that never applies the config must fail the job at the deadline with nothing minted, got minted=%d phase=%q msg=%q", minted, p.Status.Phase, p.Status.Message)
}
})
}
// The reads the state switch still needs once a CR exists (strategy, app) are
// bounded by the running ceiling, not the backup deadline: a strategy absent
// for one reconcile 40 minutes into a streaming dump must not fail the job and
// leave the finalized CR unreachable. Past the ceiling the failure names the CR.
func TestReconcileMongoDB_ReadsBehindExistingCRDoNotFailAtDeadline(t *testing.T) {
build := func(t *testing.T, startedAgo time.Duration, withStrategy, withApp bool) (*BackupJobReconciler, client.Client, *ResolvedBackupConfig, *backupsv1alpha1.BackupJob) {
job, strategy, app, cluster, resolved := mongodbInjectFixture(true)
job.Status.StartedAt = &metav1.Time{Time: time.Now().Add(-startedAgo)}
running := &psmdbtypes.PerconaServerMongoDBBackup{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant", Name: "op-running", Finalizers: []string{psmdbDeleteBackupFinalizer},
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: job.Name,
backupsv1alpha1.OwningJobNamespaceLabel: job.Namespace,
},
},
Status: psmdbtypes.PerconaServerMongoDBBackupStatus{State: psmdbtypes.StateRunning},
}
objs := []client.Object{job, cluster, running}
if withStrategy {
objs = append(objs, strategy)
}
if withApp {
objs = append(objs, app)
}
c := newMongoDBStrategyTestClient(t, objs...)
return &BackupJobReconciler{Client: c, Scheme: c.Scheme(), Recorder: record.NewFakeRecorder(10)}, c, resolved, job
}
getJob := func(t *testing.T, c client.Client, name string) *backupsv1alpha1.BackupJob {
p := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: name}, p); err != nil {
t.Fatalf("get job: %v", err)
}
return p
}
crIntact := func(t *testing.T, c client.Client) {
cr := &psmdbtypes.PerconaServerMongoDBBackup{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant", Name: "op-running"}, cr); err != nil || !cr.DeletionTimestamp.IsZero() {
t.Errorf("the running CR must be left alone, err=%v", err)
}
}
for _, tc := range []struct {
name string
withStrategy, withApp bool
reason string
}{
{"strategy absent", false, true, "MongoDBStrategyNotReady"},
{"app absent", true, false, "MongoDBApplicationNotReady"},
} {
t.Run(tc.name+" past the deadline: held, not failed", func(t *testing.T) {
r, c, resolved, job := build(t, 40*time.Minute, tc.withStrategy, tc.withApp)
res, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved)
if err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("expected a named wait behind the running CR, got %+v", res)
}
p := getJob(t, c, job.Name)
if p.Status.Phase == backupsv1alpha1.BackupJobPhaseFailed {
t.Fatalf("a job with a streaming CR must not fail on the backup deadline, got %q", p.Status.Message)
}
if cond := apimeta.FindStatusCondition(p.Status.Conditions, "Ready"); cond == nil || cond.Reason != tc.reason || !strings.Contains(cond.Message, "op-running") {
t.Errorf("expected Ready=False %s naming the CR, got %+v", tc.reason, cond)
}
crIntact(t, c)
})
}
t.Run("strategy absent past the ceiling: fails naming the CR left in place", func(t *testing.T) {
r, c, resolved, job := build(t, 2*psmdbRunningCeiling, false, true)
if _, err := r.reconcileMongoDB(context.Background(), job.DeepCopy(), resolved); err != nil {
t.Fatalf("reconcileMongoDB: %v", err)
}
p := getJob(t, c, job.Name)
if p.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed || !strings.Contains(p.Status.Message, "op-running") || !strings.Contains(p.Status.Message, "left in place") {
t.Errorf("past the ceiling the job must fail naming the CR left in place, got phase=%q msg=%q", p.Status.Phase, p.Status.Message)
}
crIntact(t, c)
})
}
// The settle check compares the entry with what the apply writes, field by
// field: a non-empty value the entry lacks is a change the apply adds, a
// different type is a change, and a zero value the entry lacks is not.
func TestPsmdbStorageEntrySettled(t *testing.T) {
s3 := &strategyv1alpha1.MongoDBStorageS3{Bucket: "cozy-backups", EndpointURL: "https://s3.example", Prefix: "tenant/app1", CredentialsSecret: "cozy-backups-creds"}
on := true
cases := []struct {
name string
live string
s3 *strategyv1alpha1.MongoDBStorageS3
want bool
}{
{"as applied", settledPlatformEntry, s3, true},
{"operator rewrote it without the empty region and the false flag", `{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds"}}`, s3, true},
{"entry lacks the endpoint the apply adds", `{"type":"s3","s3":{"bucket":"cozy-backups","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds"}}`, s3, false},
{"entry lacks forcePathStyle the strategy now sets", settledPlatformEntry, &strategyv1alpha1.MongoDBStorageS3{Bucket: "cozy-backups", EndpointURL: "https://s3.example", Prefix: "tenant/app1", CredentialsSecret: "cozy-backups-creds", ForcePathStyle: &on}, false},
{"entry carries forcePathStyle the strategy dropped", `{"type":"s3","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds","forcePathStyle":true}}`, s3, false},
{"bucket moved", `{"type":"s3","s3":{"bucket":"old","endpointUrl":"https://s3.example","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds"}}`, s3, false},
{"different storage type", `{"type":"azure","s3":{"bucket":"cozy-backups","endpointUrl":"https://s3.example","prefix":"tenant/app1","credentialsSecret":"cozy-backups-creds"}}`, s3, false},
{"absent", ``, s3, false},
}
for _, tc := range cases {
if got := psmdbStorageEntrySettled(runtime.RawExtension{Raw: []byte(tc.live)}, tc.s3); got != tc.want {
t.Errorf("%s: settled=%v, want %v", tc.name, got, tc.want)
}
}
}