mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-03 18:47:04 +09:00
resolveMongoDBBackupSource folded two failure classes into one error return:
a transient API read error on the live PerconaServerMongoDBBackup CR (cache
not yet synced, apiserver hiccup) and the terminal no-restorable-destination
case. The RestoreJob caller turned every error into markRestoreJobFailed, so a
retryable read error failed a restore that would have succeeded on the next
reconcile. The backup path already treats the symmetric live-CR read error as
transient (return ctrl.Result{}, err).
Wrap the transient read error in a new errTransientRestoreSource sentinel and
requeue on it at the caller; only a genuinely unresolvable source stays
terminal. Adds a non-vacuous interceptor-driven test.
Addresses the MINOR robustness note from PR review.
Signed-off-by: Andrey Kolkov <androndo@gmail.com>
758 lines
30 KiB
Go
758 lines
30 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
package backupcontroller
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/client-go/kubernetes/scheme"
|
|
"k8s.io/client-go/tools/record"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
|
|
|
|
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(secret string) runtime.RawExtension {
|
|
return runtime.RawExtension{Raw: []byte(fmt.Sprintf(`{"type":"s3","s3":{"credentialsSecret":%q}}`, 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
|
|
want string
|
|
}{
|
|
{
|
|
name: "preferred storage wins",
|
|
storages: map[string]runtime.RawExtension{"s3-storage": s3Storage("target-creds"), "other": s3Storage("other-creds")},
|
|
preferred: "s3-storage",
|
|
want: "target-creds",
|
|
},
|
|
{
|
|
name: "falls back to chart default when preferred absent",
|
|
storages: map[string]runtime.RawExtension{"s3-storage": s3Storage("target-creds")},
|
|
preferred: "nonexistent",
|
|
want: "target-creds",
|
|
},
|
|
{
|
|
name: "sole non-default storage is used",
|
|
storages: map[string]runtime.RawExtension{"custom": s3Storage("custom-creds")},
|
|
want: "custom-creds",
|
|
},
|
|
{
|
|
name: "ambiguous (multiple, none default, no preferred) yields empty",
|
|
storages: map[string]runtime.RawExtension{"a": s3Storage("a-creds"), "b": s3Storage("b-creds")},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "no storages yields empty",
|
|
storages: nil,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "non-s3 storage yields empty",
|
|
storages: map[string]runtime.RawExtension{"s3-storage": {Raw: []byte(`{"type":"azure","azure":{"container":"c"}}`)}},
|
|
want: "",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := psmdbTargetCredentialsSecret(clusterWith(tc.storages), tc.preferred); got != tc.want {
|
|
t.Errorf("psmdbTargetCredentialsSecret: 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)
|
|
if err != nil {
|
|
t.Fatalf("first ensureMongoDBBackup: %v", err)
|
|
}
|
|
second, err := r.ensureMongoDBBackup(context.Background(), job, cluster, "s3-storage", rendered)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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"})
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
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")
|
|
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")
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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",
|
|
}
|
|
}
|