Files
cozystack/internal/backupcontroller/restorejob_controller_test.go

289 lines
11 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package backupcontroller
import (
"context"
"testing"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
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"
strategyv1alpha1 "github.com/cozystack/cozystack/api/backups/strategy/v1alpha1"
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
)
// TestStrategyKindForRestoreJob asserts the dispatcher correctly identifies
// the strategy that produced a RestoreJob's side state. Locks in the bug
// fix: previously the cleanup ran Velero-only deletion regardless of the
// RestoreJob's actual strategy, papered over by the fact that DeleteAllOf
// is a no-op when nothing matches the label selector.
func TestStrategyKindForRestoreJob(t *testing.T) {
apiGroup := strategyv1alpha1.GroupVersion.Group
cases := []struct {
name string
backupKind string
want string
}{
{"velero strategy", strategyv1alpha1.VeleroStrategyKind, strategyv1alpha1.VeleroStrategyKind},
{"cnpg strategy", strategyv1alpha1.CNPGStrategyKind, strategyv1alpha1.CNPGStrategyKind},
{"job strategy", strategyv1alpha1.JobStrategyKind, strategyv1alpha1.JobStrategyKind},
{"mariadb strategy", strategyv1alpha1.MariaDBStrategyKind, strategyv1alpha1.MariaDBStrategyKind},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{
BackupRef: corev1.LocalObjectReference{Name: "bk"},
},
}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &apiGroup,
Kind: tc.backupKind,
Name: "strategy",
},
},
}
c := newRestoreJobTestClient(t, rj, backup)
got := strategyKindForRestoreJob(context.Background(), c, rj)
if got != tc.want {
t.Errorf("got %q want %q", got, tc.want)
}
})
}
t.Run("missing backup yields empty kind", func(t *testing.T) {
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{
BackupRef: corev1.LocalObjectReference{Name: "missing"},
},
}
c := newRestoreJobTestClient(t, rj)
if got := strategyKindForRestoreJob(context.Background(), c, rj); got != "" {
t.Errorf("expected empty string for missing backup, got %q", got)
}
})
}
// TestCleanupOnDelete_CNPG_DoesNotTouchVeleroNamespace asserts the dispatch
// branch: a CNPG RestoreJob's deletion must not issue DeleteAllOf against
// cozy-velero. We seed a Velero Restore in cozy-velero that *would* match
// the CNPG RestoreJob's labels - so if the dispatcher mistakenly fell into
// the Velero cleanup branch, DeleteAllOf would reap it. The previous
// regression test seeded a different-owner stray, which left it alone
// regardless of which cleanup branch ran (DeleteAllOf with a label selector
// is a no-op against non-matching objects), so it passed even on the broken
// implementation.
func TestCleanupOnDelete_CNPG_DoesNotTouchVeleroNamespace(t *testing.T) {
apiGroup := strategyv1alpha1.GroupVersion.Group
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{BackupRef: corev1.LocalObjectReference{Name: "bk"}},
}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &apiGroup, Kind: strategyv1alpha1.CNPGStrategyKind, Name: "s",
},
},
}
// Pre-seed a Velero Restore that *matches* the CNPG RestoreJob's owner
// labels. CNPG dispatch must not enter cleanupVeleroRestore, so this
// object must survive. If the dispatcher regresses to the old
// unconditional Velero cleanup, DeleteAllOf will reap it and the test
// will fail.
owned := &velerov1.Restore{
ObjectMeta: metav1.ObjectMeta{
Namespace: veleroNamespace,
Name: "would-be-victim",
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: "rj",
backupsv1alpha1.OwningJobNamespaceLabel: "tenant",
},
},
}
c := newRestoreJobTestClient(t, rj, backup, owned)
r := &RestoreJobReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
r.cleanupOnDelete(context.Background(), rj)
got := &velerov1.Restore{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(owned), got); err != nil {
t.Fatalf("CNPG cleanup incorrectly routed to Velero cleanup; matching Velero Restore was deleted (err=%v)", err)
}
}
// TestCleanupOnDelete_FoundationDB_DoesNotTouchVeleroNamespace mirrors the
// CNPG cleanup test for the FoundationDB strategy. The dispatch switch in
// cleanupOnDelete groups CNPG / Job / Altinity / MariaDB / FoundationDB
// into the no-op cleanup branch (none of them materialise namespaced
// artefacts in cozy-velero that outlive the RestoreJob). A future
// refactor that drops FoundationDB from the no-op group would fall
// through to the conservative `default` branch which runs the Velero
// cleanup unconditionally - and silently start reaping matching
// velero.io/Restore objects in cozy-velero. Seeding a label-matching
// Velero Restore proves the FoundationDB branch stays no-op.
func TestCleanupOnDelete_FoundationDB_DoesNotTouchVeleroNamespace(t *testing.T) {
apiGroup := strategyv1alpha1.GroupVersion.Group
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{BackupRef: corev1.LocalObjectReference{Name: "bk"}},
}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &apiGroup, Kind: strategyv1alpha1.FoundationDBStrategyKind, Name: "s",
},
},
}
owned := &velerov1.Restore{
ObjectMeta: metav1.ObjectMeta{
Namespace: veleroNamespace,
Name: "would-be-victim",
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: "rj",
backupsv1alpha1.OwningJobNamespaceLabel: "tenant",
},
},
}
c := newRestoreJobTestClient(t, rj, backup, owned)
r := &RestoreJobReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
r.cleanupOnDelete(context.Background(), rj)
got := &velerov1.Restore{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(owned), got); err != nil {
t.Fatalf("FoundationDB cleanup incorrectly routed to Velero cleanup; matching Velero Restore was deleted (err=%v)", err)
}
}
// TestCleanupOnDelete_Velero_DeletesOwnedRestore confirms the dispatcher
// still routes Velero RestoreJob deletions to the Velero cleanup path.
func TestCleanupOnDelete_Velero_DeletesOwnedRestore(t *testing.T) {
apiGroup := strategyv1alpha1.GroupVersion.Group
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{BackupRef: corev1.LocalObjectReference{Name: "bk"}},
}
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &apiGroup, Kind: strategyv1alpha1.VeleroStrategyKind, Name: "s",
},
},
}
owned := &velerov1.Restore{
ObjectMeta: metav1.ObjectMeta{
Namespace: veleroNamespace,
Name: "owned",
Labels: map[string]string{
backupsv1alpha1.OwningJobNameLabel: "rj",
backupsv1alpha1.OwningJobNamespaceLabel: "tenant",
},
},
}
c := newRestoreJobTestClient(t, rj, backup, owned)
r := &RestoreJobReconciler{Client: c, Recorder: record.NewFakeRecorder(10)}
r.cleanupOnDelete(context.Background(), rj)
got := &velerov1.Restore{}
err := c.Get(context.Background(), client.ObjectKeyFromObject(owned), got)
if err == nil {
t.Errorf("expected owned Velero Restore to be deleted, but it still exists")
}
}
func newRestoreJobTestClient(t *testing.T, objs ...client.Object) client.Client {
t.Helper()
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
_ = backupsv1alpha1.AddToScheme(s)
_ = velerov1.AddToScheme(s)
return clientfake.NewClientBuilder().
WithScheme(s).
WithObjects(objs...).
WithStatusSubresource(&backupsv1alpha1.RestoreJob{}, &backupsv1alpha1.BackupJob{}).
Build()
}
// TestMarkRestoreJobFailed_DoesNotAppendDuplicateReady locks in the fix:
// markRestoreJobFailed used to `append` to Status.Conditions, which violates
// the +listType=map +listMapKey=type contract on the field. Two terminal
// failures (or a terminal failure on top of a transient Ready=False
// condition that a previous reconcile already wrote, e.g.
// WaitingForArchive) used to leave two entries with Type="Ready" - any
// consumer doing meta.FindStatusCondition reads whichever copy comes first
// in the slice, not necessarily the latest. SetStatusCondition keeps the
// list a proper map.
func TestMarkRestoreJobFailed_DoesNotAppendDuplicateReady(t *testing.T) {
t.Run("two failures keep exactly one Ready condition", func(t *testing.T) {
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "rj"},
}
c := newRestoreJobTestClient(t, rj)
r := &RestoreJobReconciler{Client: c}
if _, err := r.markRestoreJobFailed(context.Background(), rj, "first failure"); err != nil {
t.Fatalf("first markRestoreJobFailed: %v", err)
}
if _, err := r.markRestoreJobFailed(context.Background(), rj, "second failure"); err != nil {
t.Fatalf("second markRestoreJobFailed: %v", err)
}
readyCount := 0
for _, cond := range rj.Status.Conditions {
if cond.Type == "Ready" {
readyCount++
}
}
if readyCount != 1 {
t.Fatalf("expected exactly 1 Ready condition, got %d (full slice: %+v)", readyCount, rj.Status.Conditions)
}
ready := meta.FindStatusCondition(rj.Status.Conditions, "Ready")
if ready == nil || ready.Message != "second failure" {
t.Errorf("expected latest failure message preserved, got %+v", ready)
}
})
t.Run("transient Ready=False condition is replaced in place", func(t *testing.T) {
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "rj"},
Status: backupsv1alpha1.RestoreJobStatus{
Conditions: []metav1.Condition{
{Type: "Ready", Status: metav1.ConditionFalse, Reason: "WaitingForArchive", Message: "stale"},
},
},
}
c := newRestoreJobTestClient(t, rj)
r := &RestoreJobReconciler{Client: c}
if _, err := r.markRestoreJobFailed(context.Background(), rj, "archive deadline exceeded"); err != nil {
t.Fatalf("markRestoreJobFailed: %v", err)
}
if got := len(rj.Status.Conditions); got != 1 {
t.Fatalf("expected 1 condition, got %d (full slice: %+v)", got, rj.Status.Conditions)
}
if rj.Status.Conditions[0].Reason != "RestoreFailed" {
t.Errorf("expected Reason=RestoreFailed, got %q", rj.Status.Conditions[0].Reason)
}
if rj.Status.Conditions[0].Message != "archive deadline exceeded" {
t.Errorf("expected new message, got %q", rj.Status.Conditions[0].Message)
}
})
}