Files
cozystack/internal/backupcontroller/backup_controller_test.go
Aleksei Sviridkin 9b56e70ca6 feat(mongodb): support the system-bucket backup flow (useSystemBucket) (#3967)
## What this PR does

MongoDB was the only `cozy-default` app with **no `useSystemBucket`
flow**: its backup storage was always static on the
`PerconaServerMongoDB` CR (bucket/endpoint/credentials from `backup.*`
values plus a chart-emitted `<release>-s3-creds` Secret), so a tenant
could not back up to the platform system bucket out of the box the way
Postgres/ClickHouse/MariaDB/etcd do. This closes that gap so the default
MongoDB backup-restore works OOTB.

PBM takes `bucket`/`endpointUrl`/`region`/`prefix` as **static CR
fields** (not secret refs), and the platform bucket name/endpoint are
only known at BackupJob time — so, unlike ClickHouse's runtime
`secretKeyRef` env, the chart cannot render them. The implementation
mirrors the CNPG driver's pattern:

- **Chart** (`packages/apps/mongodb`): a new `backup.useSystemBucket`
flag keeps `spec.backup.enabled` (so the pbm agents run) but omits the
static `storages`/`tasks`/`pitr` and the `<release>-s3-creds` Secret.
Helm never owns `spec.backup.storages`, so a Flux re-render can't fight
the driver. A `bootstrap.enabled + useSystemBucket` render guard mirrors
Postgres, and `useSystemBucket: true` with `backup.enabled: false` is
refused both at render time and by an `x-kubernetes-validations` rule on
`backup` in the schema (the same mechanism as the `storageClass`
immutability rule). Note on the schema rule: the aggregated apiserver
does not evaluate CEL on application-backed resources yet (#2657), so
today it is a UI/schema-level marker and the render-time refusal is what
stops a Flux release; it also trips the API review gate (“validation
rule added”), so this PR needs an API-owner approval on top of the code
review.
- **Strategy** (`cozy-default-mongodb`): now carries the S3 coordinates
(`endpointUrl` already scheme'd by the
`backupstrategy-controller.endpoint` helper, `credentialsSecret:
cozy-backups-creds`, `prefix: <namespace>/<application>`).
- **Driver** (`psmdbstrategy_controller.go`): SSA-injects
`spec.backup.storages[s3-storage]` onto the live `PerconaServerMongoDB`
at BackupJob time under a dedicated field manager
(`cozystack-psmdb-backup-driver`) with `ForceOwnership`. Injection is
**gated on the app's `backup.useSystemBucket` flag** (a legacy app
leaves it false and is never touched; a useSystemBucket app is
re-injected once per mint (once per BackupJob, plus once per
storage-race re-mint), before its operator CR is created, so a later
coordinate change catches up on the next job while a streaming dump is
never patched under). The backup CR is minted only once the operator
reports the storage pushed into pbm (`status.observedGeneration` caught
up and `PBMReady` True): pbm writes to what its own config says, not to
the spec, so a CR minted before that push would land at the previous
coordinates while its Backup recorded the new ones; the job waits at
`PerconaServerMongoDBPBMConfigPending` meanwhile, bounded by the backup
deadline. Once a job has its CR, none of the mint-time reads or holds
can fail it: the strategy and app reads the artifact still needs are
bounded by the 24h running ceiling instead, and the cluster is not read
at all **and on the release having rendered that flag**: the app CR is
projected from the HelmRelease's desired values, so the flag reads true
before helm-controller renders the revision, while the tenant's own
storage, scheduled tasks and PITR stream from the previous render still
sit on the cluster. As long as the cluster carries `spec.backup.tasks`
or `spec.backup.pitr`, the driver does not inject and holds the
BackupJob at `Ready=False PerconaServerMongoDBLegacyRender` (failing it
at the deadline), so a wedged legacy revision never has its nightly dump
and oplog redirected into the shared bucket. The backup snapshot falls
back to the strategy coordinates when the operator status echo is empty;
that fallback is gated on the CR's own `percona.com/delete-backup`
finalizer (the flow the CR was minted on), not on the app flag re-read
at completion, so a legacy backup never records the platform bucket and
a flag flipped mid-dump does not change the artifact. The restore path
rebuilds `backupSource` from it (the `cozy-backups-creds` reference is
projected into the restore namespace before dispatch).
- **Retention/cleanup**: on the `useSystemBucket` flow the driver owns
the shared-bucket archive (the chart's psmdb `tasks[].keep` retention is
gated out, and an on-demand backup is not covered by task retention). It
stamps the operator's `percona.com/delete-backup` finalizer on each
backup it mints **against a storage that carries the platform
coordinates** — the strategy's bucket and `credentialsSecret`, read back
from the live cluster after the injection — never on the flag alone: a
strategy without an `s3` block, or a storage that points anywhere else,
holds the job (`MongoDBStrategyHasNoS3` /
`PerconaServerMongoDBStorageForeign`) instead of minting a finalized
dump against a bucket the platform does not own. On Cozystack `Backup`
deletion (by hand today; by platform retention once it lands, since a
`Plan` only creates BackupJobs and never deletes a `Backup`) the driver
deletes the operator `PerconaServerMongoDBBackup` CR so that finalizer
frees the object — the finalizer's presence is the ownership marker
cleanup reads back. That reading is unambiguous because cleanup resolves
the CR by the name recorded in `driverMetadata` (the one the driver
minted), not by scanning: the psmdb operator does stamp
`percona.com/delete-backup` on its own `cron-*` task-minted backups, but
those are never in scope. A legacy backup keeps the pre-existing
hands-off contract, decided from the snapshot before any read of the
operator CR. A terminating namespace or the
`backups.cozystack.io/skip-artifact-cleanup: "true"` annotation strips
the driver's finalizer and releases; a `Get` of the operator CR that
fails with anything other than NotFound/NoMatchError still requeues, and
the operator's own `release-lock` finalizer can hold the CR while the
operator is down, so those two are what can keep a namespace in
`Terminating`. `schedule` and `retentionPolicy` are inert on this flow
(there is no engine-side retention and a `Plan` has no retention field
yet); platform-level retention is tracked in
[#4192](https://github.com/cozystack/cozystack/pull/4192) and teardown
GC of orphaned backups in
[#3966](https://github.com/cozystack/cozystack/issues/3966), so until
those land bound `cozy-backups` with an S3 lifecycle policy.
- **Bounds on the waits**: the injection race with the operator's
cluster cache (an `unable to get storage` error on a freshly injected
storage) is retried by deleting the errored CR and minting a fresh one,
with the retry count kept on the BackupJob
(`backups.cozystack.io/mongodb-storage-race-retries`) because the
deleted CR is reaped before the next poll; after three retries the
operator's error is terminal. A system-bucket dump that has started
streaming is not cancelled on the 30-minute deadline, but fails after 24
hours in `running` (`BackupAbandoned` warning naming the operator CR,
which is left in place as the handle to whatever pbm still writes). The
deadline cancel of a not-yet-started CR pins its delete to the
resourceVersion the live read answered for.
- **RBAC**: `patch` on `perconaservermongodbs` for the injection
(server-side apply), and `create`/`delete`/`patch` on
`perconaservermongodbbackups` — `delete` to prune the shared-bucket
archive on `Backup` deletion, `patch` to strip the driver's finalizer
when releasing a wedged cleanup.

Coverage: Go unit tests over `internal/backupcontroller` — the injection
gate keyed off the app flag (legacy cluster never patched) and off the
applied render (a cluster still carrying tasks or pitr is held,
untouched), the SSA-injection precondition with the whole entry
(per-application prefix included) asserted on the cluster, strategy
templating per application, the finalizer refused against a storage
without platform coordinates (strategy without `s3` over a legacy entry,
foreign coordinates on the merged entry), the retry budget driven with
the operator simulated reaping the deleted CR between polls, the restore
snapshot fallback and credential swap across all four legacy/system flow
combinations, the finalizer-owned cleanup with its escape hatches (skip
annotation before any read, `NoMatchError` treated as gone,
terminating-namespace release using the pre-read CR, a failed strip
surfaced as an Event, a legacy CR never deleted, a legacy snapshot
released without a read, a system-bucket snapshot reaching the prune),
the deadline cancelling a not-yet-running operator backup only on a
positive live read (including no live client at all, and a CR that moved
since the read), and the running ceiling. Plus helm-unittest on both
charts (chart skips the creds Secret and omits
`spec.backup.storages`/`tasks`/`pitr` on `useSystemBucket` and keeps
them on the legacy flow; the strategy renders the S3 coordinates and the
`insecureSkipTLSVerify` knob). A live `useSystemBucket` backup+restore
e2e against the platform bucket is a follow-up (needs the system bucket
wired in CI, same status as the Postgres/ClickHouse useSystemBucket
e2e). One item to confirm in e2e: the psmdb operator accepting
`spec.backup.enabled=true` with an empty `storages` between install and
the first BackupJob.

### Screenshots

Not applicable (no UI changes).

### Downstream repositories

The tenant-facing schema surface is one new field,
`spec.backup.useSystemBucket`, on the MongoDB `values.schema.json` / CRD
(regenerated), plus a validation rule on `spec.backup`.
`terraform-provider-cozystack` hand-models MongoDB and may want to
expose it, but it is a new optional boolean that defaults false (no
drift for existing users), and the chart `README.md` regenerates on
release. Leaving every box unticked per the trigger-map guidance so a
maintainer decides on the provider follow-up.

- [ ] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:

### Release note

```release-note
feat(mongodb): MongoDB backups now support the platform system bucket via backup.useSystemBucket, so the default cozy-default BackupClass works without per-app S3 credentials — the backup driver injects the system-bucket storage onto the cluster at backup time, once the release has rendered the flag. On this flow nothing prunes the shared bucket automatically yet (a Plan carries no retention field; tracked in #4192 and #3966), so an S3 lifecycle policy on cozy-backups is a prerequisite for enabling it, not a suggestion. useSystemBucket requires backup.enabled=true; the chart refuses the combination with backups disabled at render time, and the schema carries the same rule as a marker until the apiserver enforces CEL (#2657). A system-bucket dump still running after 24 hours fails its BackupJob with the operator backup left in place. A MongoDB restore now adopts the target cluster's S3 credentials only for a storage on the same bucket as the archive (it used to adopt them unconditionally); a legacy restore into a target on a different bucket that relied on one key pair covering both must keep or re-create the source release's -s3-creds Secret by name, and is held at Ready=False RestoreCredentialsMissing until it exists.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added opt-in system bucket mode for MongoDB backups.
* Automatically injects platform bucket storage without
application-managed S3 credentials.
* Supports endpoint, region, path prefix, addressing, and TLS settings.
* Manages shared-bucket backup artifacts when system bucket backups are
deleted.

* **Bug Fixes**
* Improved restore credential selection to avoid cross-bucket
mismatches.
  * Preserved backup metadata when storage details are unavailable.

* **Documentation**
* Documented configuration, scheduling, retention, PITR limitations, and
restore guidance.

* **Validation**
* Prevented incompatible bootstrap and system bucket restore
configurations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-29 18:10:56 +03:00

605 lines
25 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package backupcontroller
import (
"context"
"strings"
"testing"
batchv1 "k8s.io/api/batch/v1"
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"
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/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/cnpgtypes"
velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
)
// TestBackupCleanup_CNPG_DeletesUnderlyingCNPGBackup locks in the bug fix
// for review Blocker 2: deleting a CNPG-strategy Backup must also delete
// the postgresql.cnpg.io/Backup CR the driver created. Before the fix,
// cleanupVeleroBackup was the only cleanup path and it short-circuited on
// CNPG Backups (no velero metadata key), leaking the cnpg.io/Backup CR.
func TestBackupCleanup_CNPG_DeletesUnderlyingCNPGBackup(t *testing.T) {
apiGroup := strategyv1alpha1.GroupVersion.Group
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &apiGroup, Kind: strategyv1alpha1.CNPGStrategyKind, Name: "s",
},
DriverMetadata: map[string]string{
cnpgBackupNameKey: "bk-abc123",
},
},
}
cnpgBk := &cnpgtypes.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk-abc123"},
}
c := newBackupTestClient(t, backup, cnpgBk)
r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
if _, err := r.cleanupOnDelete(context.Background(), backup); err != nil {
t.Fatalf("cleanupOnDelete returned %v", err)
}
got := &cnpgtypes.Backup{}
err := c.Get(context.Background(), client.ObjectKey{Namespace: "tenant", Name: "bk-abc123"}, got)
if err == nil {
t.Fatalf("expected cnpg.io/Backup to be deleted, but it still exists")
}
if !apierrors.IsNotFound(err) {
t.Fatalf("expected NotFound, got %v", err)
}
}
// TestBackupCleanup_CNPG_NoMetadataIsNoOp asserts the dispatcher does not
// fail when a CNPG-strategy Backup carries no driver metadata - e.g. the
// BackupJob marked itself succeeded but never wrote the cnpg.io/Backup
// name. Cleanup must silently no-op rather than block finalizer removal.
func TestBackupCleanup_CNPG_NoMetadataIsNoOp(t *testing.T) {
apiGroup := strategyv1alpha1.GroupVersion.Group
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &apiGroup, Kind: strategyv1alpha1.CNPGStrategyKind, Name: "s",
},
},
}
c := newBackupTestClient(t, backup)
r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
if _, err := r.cleanupOnDelete(context.Background(), backup); err != nil {
t.Fatalf("cleanupOnDelete returned unexpected error %v", err)
}
}
// TestBackupCleanup_Velero_DispatchUnchanged guards the Velero path: the
// dispatcher refactor must still route Velero-strategy Backups through
// cleanupVeleroBackup and create a DeleteBackupRequest.
func TestBackupCleanup_Velero_DispatchUnchanged(t *testing.T) {
apiGroup := strategyv1alpha1.GroupVersion.Group
veleroBk := &velerov1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: veleroNamespace, Name: "vb-1"},
}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &apiGroup, Kind: strategyv1alpha1.VeleroStrategyKind, Name: "s",
},
DriverMetadata: map[string]string{
veleroBackupNameMetadataKey: "vb-1",
veleroBackupNamespaceMetadataKey: veleroNamespace,
},
},
}
c := newBackupTestClient(t, backup, veleroBk)
r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
if _, err := r.cleanupOnDelete(context.Background(), backup); err != nil {
t.Fatalf("cleanupOnDelete returned %v", err)
}
dbrList := &velerov1.DeleteBackupRequestList{}
if err := c.List(context.Background(), dbrList, client.InNamespace(veleroNamespace)); err != nil {
t.Fatalf("list DeleteBackupRequests: %v", err)
}
if len(dbrList.Items) != 1 {
t.Fatalf("expected 1 DeleteBackupRequest, got %d", len(dbrList.Items))
}
if dbrList.Items[0].Spec.BackupName != "vb-1" {
t.Errorf("DeleteBackupRequest targets unexpected backup: %q", dbrList.Items[0].Spec.BackupName)
}
}
// TestBackupCleanup_Altinity_DoesNotFallThroughToVelero locks in the
// review-flagged bug fix: the dispatcher must explicitly route Altinity-
// strategy Backups to a no-op cleanup, not fall through to the Velero
// default. The Velero cleanup is keyed on a metadata field that never
// gets set on Altinity Backups, so without an explicit branch the call
// silently no-ops AND any pre-existing DriverMetadata (e.g. a stray
// velero-owned key from earlier experiments) would mistakenly trigger a
// DeleteBackupRequest. The branch makes the contract explicit:
// clickhouse-backup owns the S3 lifecycle; Cozystack does not delete it.
func TestBackupCleanup_Altinity_DoesNotFallThroughToVelero(t *testing.T) {
apiGroup := strategyv1alpha1.GroupVersion.Group
veleroBk := &velerov1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: veleroNamespace, Name: "stale-vb"},
}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &apiGroup, Kind: strategyv1alpha1.AltinityStrategyKind, Name: "altinity",
},
// Intentionally include the velero metadata keys to expose the
// bug behaviour: with the old default-fallback dispatch, this
// would synthesise a DeleteBackupRequest. The Altinity branch
// must short-circuit before that happens.
DriverMetadata: map[string]string{
veleroBackupNameMetadataKey: "stale-vb",
veleroBackupNamespaceMetadataKey: veleroNamespace,
},
},
}
c := newBackupTestClient(t, backup, veleroBk)
r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
if _, err := r.cleanupOnDelete(context.Background(), backup); err != nil {
t.Fatalf("cleanupOnDelete returned %v", err)
}
dbrList := &velerov1.DeleteBackupRequestList{}
if err := c.List(context.Background(), dbrList, client.InNamespace(veleroNamespace)); err != nil {
t.Fatalf("list DeleteBackupRequests: %v", err)
}
if len(dbrList.Items) != 0 {
t.Fatalf("expected no DeleteBackupRequests for Altinity Backup, got %d (Velero fall-through leak)", len(dbrList.Items))
}
// Velero Backup itself must remain untouched.
got := &velerov1.Backup{}
if err := c.Get(context.Background(), client.ObjectKey{Namespace: veleroNamespace, Name: "stale-vb"}, got); err != nil {
t.Fatalf("Velero Backup unexpectedly removed: %v", err)
}
}
// TestBackupCleanup_MariaDB_DoesNotFallThroughToVelero locks in the same
// invariant as the Altinity test above for the MariaDB strategy. The
// dispatcher must explicitly route MariaDB-strategy Backups to a no-op
// cleanup rather than fall through to the Velero default - the MariaDB
// driver does not own the operator-side k8s.mariadb.com/Backup CR or the
// S3/PVC archive behind it (rbac.yaml grants no delete verb on
// k8s.mariadb.com/backups for that reason). The test seeds the Velero
// metadata keys plus a matching velero.io/Backup; with the explicit branch
// no DeleteBackupRequest is synthesised, and the Velero Backup survives.
// Without the branch the default falls through to cleanupVeleroBackup,
// which would observe the metadata key and create a DBR.
func TestBackupCleanup_MariaDB_DoesNotFallThroughToVelero(t *testing.T) {
apiGroup := strategyv1alpha1.GroupVersion.Group
veleroBk := &velerov1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: veleroNamespace, Name: "stale-vb-mdb"},
}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk-mdb"},
Spec: backupsv1alpha1.BackupSpec{
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &apiGroup, Kind: strategyv1alpha1.MariaDBStrategyKind, Name: "mariadb-strategy-default",
},
// Seed the velero metadata keys so the test would fail with
// a DeleteBackupRequest leak if MariaDB ever fell through to
// the Velero default branch.
DriverMetadata: map[string]string{
veleroBackupNameMetadataKey: "stale-vb-mdb",
veleroBackupNamespaceMetadataKey: veleroNamespace,
},
},
}
c := newBackupTestClient(t, backup, veleroBk)
r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
if _, err := r.cleanupOnDelete(context.Background(), backup); err != nil {
t.Fatalf("cleanupOnDelete returned %v", err)
}
dbrList := &velerov1.DeleteBackupRequestList{}
if err := c.List(context.Background(), dbrList, client.InNamespace(veleroNamespace)); err != nil {
t.Fatalf("list DeleteBackupRequests: %v", err)
}
if len(dbrList.Items) != 0 {
t.Fatalf("expected no DeleteBackupRequests for MariaDB Backup, got %d (Velero fall-through leak)", len(dbrList.Items))
}
got := &velerov1.Backup{}
if err := c.Get(context.Background(), client.ObjectKey{Namespace: veleroNamespace, Name: "stale-vb-mdb"}, got); err != nil {
t.Fatalf("Velero Backup unexpectedly removed: %v", err)
}
}
// TestBackupCleanup_FoundationDB_DoesNotFallThroughToVelero locks in the
// same invariant as the Altinity / MariaDB variants. PR #2606 FU-1: the
// dispatcher must explicitly route FoundationDB-strategy Backups to a
// no-op cleanup rather than fall through to the Velero default — the FDB
// driver does not own the operator-side foundationdb.org/FoundationDBBackup
// CR or its archive (the same "we do not own S3" contract as Altinity /
// MariaDB). Without the explicit case a future refactor that incidentally
// stamps velero.io/backup-name onto FDB driverMetadata would silently
// synthesise a DeleteBackupRequest. The test seeds those keys plus a
// matching velero.io/Backup; with the explicit branch no DBR is created
// and the Velero Backup survives.
func TestBackupCleanup_FoundationDB_DoesNotFallThroughToVelero(t *testing.T) {
apiGroup := strategyv1alpha1.GroupVersion.Group
veleroBk := &velerov1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: veleroNamespace, Name: "stale-vb-fdb"},
}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk-fdb"},
Spec: backupsv1alpha1.BackupSpec{
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &apiGroup, Kind: strategyv1alpha1.FoundationDBStrategyKind, Name: "foundationdb-strategy-default",
},
DriverMetadata: map[string]string{
veleroBackupNameMetadataKey: "stale-vb-fdb",
veleroBackupNamespaceMetadataKey: veleroNamespace,
},
},
}
c := newBackupTestClient(t, backup, veleroBk)
r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
if _, err := r.cleanupOnDelete(context.Background(), backup); err != nil {
t.Fatalf("cleanupOnDelete returned %v", err)
}
dbrList := &velerov1.DeleteBackupRequestList{}
if err := c.List(context.Background(), dbrList, client.InNamespace(veleroNamespace)); err != nil {
t.Fatalf("list DeleteBackupRequests: %v", err)
}
if len(dbrList.Items) != 0 {
t.Fatalf("expected no DeleteBackupRequests for FoundationDB Backup, got %d (Velero fall-through leak)", len(dbrList.Items))
}
got := &velerov1.Backup{}
if err := c.Get(context.Background(), client.ObjectKey{Namespace: veleroNamespace, Name: "stale-vb-fdb"}, got); err != nil {
t.Fatalf("Velero Backup unexpectedly removed: %v", err)
}
}
// TestBackupCleanup_MongoDB_DoesNotFallThroughToVelero locks in the same
// invariant as the Altinity / MariaDB / FoundationDB variants for the MongoDB
// strategy. The dispatcher must route MongoDB-strategy Backups to the MongoDB
// cleanup (which prunes a system-bucket archive through the operator CR's
// finalizer and leaves a legacy one alone) rather than fall through to the
// Velero default. Without the explicit case a future refactor that
// incidentally stamps velero.io/backup-name onto MongoDB driverMetadata would
// silently synthesise a DeleteBackupRequest. The test seeds those keys plus a
// matching velero.io/Backup and no MongoDB metadata; with the explicit branch
// no DBR is created and the Velero Backup survives.
func TestBackupCleanup_MongoDB_DoesNotFallThroughToVelero(t *testing.T) {
apiGroup := strategyv1alpha1.GroupVersion.Group
veleroBk := &velerov1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: veleroNamespace, Name: "stale-vb-mongo"},
}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk-mongo"},
Spec: backupsv1alpha1.BackupSpec{
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &apiGroup, Kind: strategyv1alpha1.MongoDBStrategyKind, Name: "mongodb-strategy-default",
},
DriverMetadata: map[string]string{
veleroBackupNameMetadataKey: "stale-vb-mongo",
veleroBackupNamespaceMetadataKey: veleroNamespace,
},
},
}
c := newBackupTestClient(t, backup, veleroBk)
r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
if _, err := r.cleanupOnDelete(context.Background(), backup); err != nil {
t.Fatalf("cleanupOnDelete returned %v", err)
}
dbrList := &velerov1.DeleteBackupRequestList{}
if err := c.List(context.Background(), dbrList, client.InNamespace(veleroNamespace)); err != nil {
t.Fatalf("list DeleteBackupRequests: %v", err)
}
if len(dbrList.Items) != 0 {
t.Fatalf("expected no DeleteBackupRequests for MongoDB Backup, got %d (Velero fall-through leak)", len(dbrList.Items))
}
got := &velerov1.Backup{}
if err := c.Get(context.Background(), client.ObjectKey{Namespace: veleroNamespace, Name: "stale-vb-mongo"}, got); err != nil {
t.Fatalf("Velero Backup unexpectedly removed: %v", err)
}
}
// TestStrategyKindForBackup mirrors the dispatcher's strategy lookup. Useful
// in isolation when reasoning about edge cases (empty strategyRef, etc.).
func TestStrategyKindForBackup(t *testing.T) {
apiGroup := strategyv1alpha1.GroupVersion.Group
cases := []struct {
name string
kind string
want string
}{
{"velero", strategyv1alpha1.VeleroStrategyKind, strategyv1alpha1.VeleroStrategyKind},
{"cnpg", strategyv1alpha1.CNPGStrategyKind, strategyv1alpha1.CNPGStrategyKind},
{"job", strategyv1alpha1.JobStrategyKind, strategyv1alpha1.JobStrategyKind},
{"altinity", strategyv1alpha1.AltinityStrategyKind, strategyv1alpha1.AltinityStrategyKind},
{"mariadb", strategyv1alpha1.MariaDBStrategyKind, strategyv1alpha1.MariaDBStrategyKind},
{"empty", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
b := &backupsv1alpha1.Backup{
Spec: backupsv1alpha1.BackupSpec{
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &apiGroup, Kind: tc.kind, Name: "s",
},
},
}
if got := strategyKindForBackup(b); got != tc.want {
t.Errorf("got %q want %q", got, tc.want)
}
})
}
}
func newBackupTestClient(t *testing.T, objs ...client.Object) client.Client {
t.Helper()
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
_ = backupsv1alpha1.AddToScheme(s)
_ = velerov1.AddToScheme(s)
_ = cnpgtypes.AddToScheme(s)
return clientfake.NewClientBuilder().WithScheme(s).WithObjects(objs...).Build()
}
// newRabbitmqBackupReconcilerClient builds a client for the Reconcile-level
// Rabbitmq cleanup tests: it registers the Rabbitmq strategy CRD and a Job
// status subresource (so the delete Job's completion can be simulated).
func newRabbitmqBackupReconcilerClient(objs ...client.Object) (client.Client, *runtime.Scheme) {
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
_ = backupsv1alpha1.AddToScheme(s)
_ = strategyv1alpha1.AddToScheme(s)
return clientfake.NewClientBuilder().WithScheme(s).
WithStatusSubresource(&batchv1.Job{}).
WithObjects(objs...).Build(), s
}
// TestBackupReconcile_HoldsFinalizerWhileRabbitmqCleanupRuns pins the
// deletion-blocking wiring: while the delete Job runs, Reconcile keeps the
// finalizer (the Backup is not removed until the object is gone), and removes it
// once the Job completes. Deleting the requeue block in Reconcile turns this red.
func TestBackupReconcile_HoldsFinalizerWhileRabbitmqCleanupRuns(t *testing.T) {
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}}
backup := rabbitmqBackup("rmq-src") // status.artifact.uri set by the fixture
backup.Finalizers = []string{backupFinalizer}
strategy := newRabbitmqStrategy("cozy-default-rabbitmq")
c, s := newRabbitmqBackupReconcilerClient(ns, backup, strategy)
r := &BackupReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
ctx := context.Background()
// Delete the Backup: the finalizer holds it in Terminating.
if err := c.Delete(ctx, backup); err != nil {
t.Fatalf("delete backup: %v", err)
}
key := client.ObjectKeyFromObject(backup)
res, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: key})
if err != nil {
t.Fatalf("Reconcile() error = %v", err)
}
if res.RequeueAfter == 0 {
t.Error("expected Reconcile to requeue while the delete Job runs")
}
got := &backupsv1alpha1.Backup{}
if err := c.Get(ctx, key, got); err != nil {
t.Fatalf("Backup must still exist while cleanup runs: %v", err)
}
if !controllerutil.ContainsFinalizer(got, backupFinalizer) {
t.Error("expected the finalizer to be retained while the delete Job runs")
}
jobs := &batchv1.JobList{}
if err := c.List(ctx, jobs, client.InNamespace("tenant-test")); err != nil {
t.Fatalf("list jobs: %v", err)
}
if len(jobs.Items) != 1 {
t.Fatalf("expected the cleanup Job, got %d", len(jobs.Items))
}
// Complete the Job; the next reconcile removes the finalizer -> Backup gone.
job := jobs.Items[0]
job.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue}}
if err := c.Status().Update(ctx, &job); err != nil {
t.Fatalf("update job status: %v", err)
}
if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: key}); err != nil {
t.Fatalf("Reconcile() second call error = %v", err)
}
if err := c.Get(ctx, key, got); !apierrors.IsNotFound(err) {
t.Errorf("expected the Backup removed after the delete Job completed, got err=%v", err)
}
}
// TestBackupReconcile_ReleasesInTerminatingNamespace pins the teardown fix: a
// Rabbitmq Backup in a Terminating namespace must release (CREATE is forbidden
// there, so the delete Job cannot run) rather than wedge the namespace, and must
// record a Warning Event naming the object left behind.
func TestBackupReconcile_ReleasesInTerminatingNamespace(t *testing.T) {
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant-test", Finalizers: []string{"kubernetes"}}}
backup := rabbitmqBackup("rmq-src")
backup.Finalizers = []string{backupFinalizer}
strategy := newRabbitmqStrategy("cozy-default-rabbitmq")
rec := record.NewFakeRecorder(10)
c, s := newRabbitmqBackupReconcilerClient(ns, backup, strategy)
r := &BackupReconciler{Client: c, Scheme: s, Recorder: rec}
ctx := context.Background()
// Put both the namespace and the Backup into Terminating.
if err := c.Delete(ctx, ns); err != nil {
t.Fatalf("delete namespace: %v", err)
}
if err := c.Delete(ctx, backup); err != nil {
t.Fatalf("delete backup: %v", err)
}
key := client.ObjectKeyFromObject(backup)
if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: key}); err != nil {
t.Fatalf("Reconcile() error = %v", err)
}
got := &backupsv1alpha1.Backup{}
if err := c.Get(ctx, key, got); !apierrors.IsNotFound(err) {
t.Errorf("expected the Backup released in a terminating namespace (no wedge), got err=%v", err)
}
jobs := &batchv1.JobList{}
if err := c.List(ctx, jobs, client.InNamespace("tenant-test")); err != nil {
t.Fatalf("list jobs: %v", err)
}
if len(jobs.Items) != 0 {
t.Errorf("expected no cleanup Job created in a terminating namespace, got %d", len(jobs.Items))
}
select {
case ev := <-rec.Events:
if !strings.Contains(ev, "ArtifactNotDeleted") {
t.Errorf("expected an ArtifactNotDeleted Warning Event, got %q", ev)
}
default:
t.Error("expected a Warning Event naming the object left behind")
}
}
// redisCleanupBackup is a Redis Backup carrying the object key on its
// DriverMetadata - the object cleanupRedisBackup must delete.
func redisCleanupBackup(name, objectKey string) *backupsv1alpha1.Backup {
return &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "tenant-test"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{
APIGroup: new(backupsv1alpha1.DefaultApplicationAPIGroup),
Kind: "Redis",
Name: "cache",
},
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: new(strategyv1alpha1.GroupVersion.Group),
Kind: strategyv1alpha1.RedisStrategyKind,
Name: "cozy-default-redis",
},
TakenAt: metav1.Now(),
DriverMetadata: map[string]string{redisObjectMetaKey: objectKey},
},
}
}
// newRedisCleanupStrategy renders in cleanup mode: its template references only
// .Mode and .ObjectKey (never .Application), which cleanup passes as nil.
func newRedisCleanupStrategy(name string) *strategyv1alpha1.Redis {
return &strategyv1alpha1.Redis{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: strategyv1alpha1.RedisSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
Containers: []corev1.Container{{
Name: "cleanup",
Image: "redis-backup:test",
Args: []string{"--mode={{ .Mode }}", "--key={{ .ObjectKey }}"},
}},
},
},
},
}
}
// TestBackupReconcile_HoldsFinalizerWhileRedisCleanupRuns pins that a deleted
// Redis Backup deletes its object (the Redis driver owns it) instead of falling
// through to the Velero no-op: Reconcile spawns a cleanup Job, holds the
// finalizer while it runs, and removes the Backup once the Job completes.
// Dropping the Redis case in cleanupOnDelete turns this red (no Job, Backup
// removed immediately with its object orphaned).
func TestBackupReconcile_HoldsFinalizerWhileRedisCleanupRuns(t *testing.T) {
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}}
backup := redisCleanupBackup("redis-src", "tenant-test/cache/redis-src.rdb")
backup.Finalizers = []string{backupFinalizer}
strategy := newRedisCleanupStrategy("cozy-default-redis")
c, s := newRabbitmqBackupReconcilerClient(ns, backup, strategy)
r := &BackupReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
ctx := context.Background()
if err := c.Delete(ctx, backup); err != nil {
t.Fatalf("delete backup: %v", err)
}
key := client.ObjectKeyFromObject(backup)
res, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: key})
if err != nil {
t.Fatalf("Reconcile() error = %v", err)
}
if res.RequeueAfter == 0 {
t.Error("expected Reconcile to requeue while the delete Job runs")
}
got := &backupsv1alpha1.Backup{}
if err := c.Get(ctx, key, got); err != nil {
t.Fatalf("Backup must still exist while cleanup runs: %v", err)
}
if !controllerutil.ContainsFinalizer(got, backupFinalizer) {
t.Error("expected the finalizer to be retained while the delete Job runs")
}
jobs := &batchv1.JobList{}
if err := c.List(ctx, jobs, client.InNamespace("tenant-test")); err != nil {
t.Fatalf("list jobs: %v", err)
}
if len(jobs.Items) != 1 {
t.Fatalf("expected the cleanup Job, got %d", len(jobs.Items))
}
if got := jobs.Items[0].Labels[redisLabelMode]; got != redisModeCleanup {
t.Errorf("expected the Job labelled mode=%q, got %q", redisModeCleanup, got)
}
// Complete the Job; the next reconcile removes the finalizer -> Backup gone.
job := jobs.Items[0]
job.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue}}
if err := c.Status().Update(ctx, &job); err != nil {
t.Fatalf("update job status: %v", err)
}
if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: key}); err != nil {
t.Fatalf("Reconcile() second call error = %v", err)
}
if err := c.Get(ctx, key, got); !apierrors.IsNotFound(err) {
t.Errorf("expected the Backup removed after the delete Job completed, got err=%v", err)
}
}
// TestBackupCleanup_Redis_NoObjectKeyIsNoOp pins that a legacy Redis Backup
// with no recorded object key releases without spawning a Job (nothing to
// delete), rather than wedging.
func TestBackupCleanup_Redis_NoObjectKeyIsNoOp(t *testing.T) {
backup := redisCleanupBackup("redis-legacy", "")
backup.Spec.DriverMetadata = nil
c, s := newRabbitmqBackupReconcilerClient(backup)
r := &BackupReconciler{Client: c, Scheme: s, Recorder: record.NewFakeRecorder(10)}
res, err := r.cleanupOnDelete(context.Background(), backup)
if err != nil {
t.Fatalf("cleanupOnDelete returned %v", err)
}
if res.RequeueAfter != 0 {
t.Error("expected no requeue when there is no object to delete")
}
jobs := &batchv1.JobList{}
if err := c.List(context.Background(), jobs, client.InNamespace("tenant-test")); err != nil {
t.Fatalf("list jobs: %v", err)
}
if len(jobs.Items) != 0 {
t.Errorf("expected no cleanup Job for a keyless Backup, got %d", len(jobs.Items))
}
}