Skip to content

Commit d83b81b

Browse files
committed
test(operator): cover backup retention and health evaluation
Add focused tests for retention merge and defaulting, extended backup health evaluation, and repository health condition handling.
1 parent ef16d8d commit d83b81b

File tree

3 files changed

+428
-0
lines changed

3 files changed

+428
-0
lines changed

api/v1alpha1/common_types_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package v1alpha1
2+
3+
import (
4+
"testing"
5+
6+
"k8s.io/utils/ptr"
7+
)
8+
9+
func TestMergeBackupConfig_Retention(t *testing.T) {
10+
t.Parallel()
11+
12+
t.Run("child overrides parent", func(t *testing.T) {
13+
t.Parallel()
14+
parent := &BackupConfig{
15+
Type: BackupTypeS3,
16+
Retention: &RetentionPolicy{FullCount: ptr.To(int32(4)), DifferentialCount: ptr.To(int32(1))},
17+
}
18+
child := &BackupConfig{
19+
Type: BackupTypeS3,
20+
Retention: &RetentionPolicy{FullCount: ptr.To(int32(7))},
21+
}
22+
merged := MergeBackupConfig(child, parent)
23+
if merged.Retention == nil {
24+
t.Fatal("expected Retention to be set")
25+
}
26+
if *merged.Retention.FullCount != 7 {
27+
t.Errorf("FullCount = %d, want 7", *merged.Retention.FullCount)
28+
}
29+
if *merged.Retention.DifferentialCount != 1 {
30+
t.Errorf("DifferentialCount = %d, want 1 (from parent)", *merged.Retention.DifferentialCount)
31+
}
32+
})
33+
34+
t.Run("parent preserved when child unset", func(t *testing.T) {
35+
t.Parallel()
36+
parent := &BackupConfig{
37+
Type: BackupTypeS3,
38+
S3: &S3BackupConfig{Bucket: "b", Region: "r"},
39+
Retention: &RetentionPolicy{FullCount: ptr.To(int32(5)), DifferentialCount: ptr.To(int32(3))},
40+
}
41+
child := &BackupConfig{
42+
Type: BackupTypeS3,
43+
S3: &S3BackupConfig{Bucket: "b", Region: "r"},
44+
}
45+
merged := MergeBackupConfig(child, parent)
46+
if merged.Retention == nil {
47+
t.Fatal("expected parent Retention to be preserved")
48+
}
49+
if *merged.Retention.FullCount != 5 {
50+
t.Errorf("FullCount = %d, want 5", *merged.Retention.FullCount)
51+
}
52+
if *merged.Retention.DifferentialCount != 3 {
53+
t.Errorf("DifferentialCount = %d, want 3", *merged.Retention.DifferentialCount)
54+
}
55+
})
56+
57+
t.Run("nil child retention preserves parent", func(t *testing.T) {
58+
t.Parallel()
59+
parent := &BackupConfig{
60+
Type: BackupTypeS3,
61+
Retention: &RetentionPolicy{FullCount: ptr.To(int32(4)), DifferentialCount: ptr.To(int32(2))},
62+
}
63+
child := &BackupConfig{
64+
Type: BackupTypeS3,
65+
S3: &S3BackupConfig{Bucket: "child-bucket", Region: "us-west-2"},
66+
}
67+
merged := MergeBackupConfig(child, parent)
68+
if merged.Retention == nil {
69+
t.Fatal("expected Retention from parent to be preserved")
70+
}
71+
if *merged.Retention.FullCount != 4 {
72+
t.Errorf("FullCount = %d, want 4", *merged.Retention.FullCount)
73+
}
74+
})
75+
}
76+
77+
func TestMergeBackupConfig_S3PointerBools(t *testing.T) {
78+
t.Parallel()
79+
80+
t.Run("parent preserved when child unset", func(t *testing.T) {
81+
t.Parallel()
82+
parent := &BackupConfig{
83+
Type: BackupTypeS3,
84+
S3: &S3BackupConfig{
85+
Bucket: "parent-bucket",
86+
Region: "us-east-1",
87+
CleanupOnDelete: ptr.To(true),
88+
AllowStaleMetadataRecovery: ptr.To(true),
89+
},
90+
}
91+
child := &BackupConfig{
92+
Type: BackupTypeS3,
93+
S3: &S3BackupConfig{Bucket: "child-bucket", Region: "us-east-1"},
94+
}
95+
merged := MergeBackupConfig(child, parent)
96+
if merged.S3.CleanupOnDelete == nil || !*merged.S3.CleanupOnDelete {
97+
t.Error("expected parent CleanupOnDelete=true to be preserved")
98+
}
99+
if merged.S3.AllowStaleMetadataRecovery == nil || !*merged.S3.AllowStaleMetadataRecovery {
100+
t.Error("expected parent AllowStaleMetadataRecovery=true to be preserved")
101+
}
102+
if merged.S3.Bucket != "child-bucket" {
103+
t.Errorf("Bucket = %s, want child-bucket", merged.S3.Bucket)
104+
}
105+
})
106+
107+
t.Run("child overrides parent", func(t *testing.T) {
108+
t.Parallel()
109+
parent := &BackupConfig{
110+
Type: BackupTypeS3,
111+
S3: &S3BackupConfig{
112+
Bucket: "b",
113+
Region: "r",
114+
CleanupOnDelete: ptr.To(true),
115+
},
116+
}
117+
child := &BackupConfig{
118+
Type: BackupTypeS3,
119+
S3: &S3BackupConfig{
120+
Bucket: "b",
121+
Region: "r",
122+
CleanupOnDelete: ptr.To(false),
123+
},
124+
}
125+
merged := MergeBackupConfig(child, parent)
126+
if merged.S3.CleanupOnDelete == nil || *merged.S3.CleanupOnDelete {
127+
t.Error("expected child CleanupOnDelete=false to override parent true")
128+
}
129+
})
130+
}
131+
132+
func TestBackupConfig_DeepCopyPreservesRetention(t *testing.T) {
133+
t.Parallel()
134+
original := &BackupConfig{
135+
Type: BackupTypeS3,
136+
S3: &S3BackupConfig{Bucket: "b", Region: "r"},
137+
Retention: &RetentionPolicy{FullCount: ptr.To(int32(4))},
138+
}
139+
copied := original.DeepCopy()
140+
*copied.Retention.FullCount = 99
141+
if *original.Retention.FullCount != 4 {
142+
t.Errorf("DeepCopy leaked pointer: original FullCount changed to %d", *original.Retention.FullCount)
143+
}
144+
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package backuphealth_test
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
multipoolermanagerdatapb "github.com/multigres/multigres/go/pb/multipoolermanagerdata"
8+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
9+
"k8s.io/utils/ptr"
10+
11+
multigresv1alpha1 "github.com/multigres/multigres-operator/api/v1alpha1"
12+
"github.com/multigres/multigres-operator/pkg/data-handler/backuphealth"
13+
)
14+
15+
func newTestShard() *multigresv1alpha1.Shard {
16+
return &multigresv1alpha1.Shard{
17+
ObjectMeta: metav1.ObjectMeta{
18+
Name: "test-shard",
19+
Namespace: "default",
20+
Generation: 1,
21+
Labels: map[string]string{"multigres.com/cluster": "test-cluster"},
22+
},
23+
}
24+
}
25+
26+
// recentBackupID generates a pgBackRest-style backup ID for a backup that
27+
// completed `age` ago. Uses UTC because ParseTime (which uses time.Parse
28+
// without a location) returns UTC timestamps.
29+
func recentBackupID(age time.Duration) string {
30+
return time.Now().UTC().Add(-age).Format("20060102-150405")
31+
}
32+
33+
func TestEvaluateBackupsExtended_BackupCounts(t *testing.T) {
34+
t.Parallel()
35+
shard := newTestShard()
36+
backups := []*multipoolermanagerdatapb.BackupMetadata{
37+
{BackupId: recentBackupID(1 * time.Hour), Status: multipoolermanagerdatapb.BackupMetadata_COMPLETE, Type: "full"},
38+
{BackupId: recentBackupID(2 * time.Hour), Status: multipoolermanagerdatapb.BackupMetadata_COMPLETE, Type: "full"},
39+
{BackupId: recentBackupID(30 * time.Minute), Status: multipoolermanagerdatapb.BackupMetadata_COMPLETE, Type: "diff"},
40+
{BackupId: recentBackupID(3 * time.Hour), Status: multipoolermanagerdatapb.BackupMetadata_INCOMPLETE, Type: "full"},
41+
}
42+
retention := &multigresv1alpha1.RetentionPolicy{FullCount: ptr.To(int32(4))}
43+
44+
result := backuphealth.EvaluateBackupsExtended(shard, backups, retention, nil)
45+
46+
if result.FullBackupCount != 2 {
47+
t.Errorf("expected 2 full backups, got %d", result.FullBackupCount)
48+
}
49+
if result.DiffBackupCount != 1 {
50+
t.Errorf("expected 1 diff backup, got %d", result.DiffBackupCount)
51+
}
52+
}
53+
54+
func TestEvaluateBackupsExtended_OldestBackupAge(t *testing.T) {
55+
t.Parallel()
56+
shard := newTestShard()
57+
backups := []*multipoolermanagerdatapb.BackupMetadata{
58+
{BackupId: recentBackupID(1 * time.Hour), Status: multipoolermanagerdatapb.BackupMetadata_COMPLETE, Type: "full"},
59+
{BackupId: recentBackupID(48 * time.Hour), Status: multipoolermanagerdatapb.BackupMetadata_COMPLETE, Type: "full"},
60+
}
61+
62+
result := backuphealth.EvaluateBackupsExtended(shard, backups, nil, nil)
63+
64+
if result.OldestBackupAge < 47*time.Hour || result.OldestBackupAge > 50*time.Hour {
65+
t.Errorf("expected oldest age ~48h, got %v", result.OldestBackupAge)
66+
}
67+
}
68+
69+
func TestEvaluateBackupsExtended_NoBackups_OldestAgeZero(t *testing.T) {
70+
t.Parallel()
71+
shard := newTestShard()
72+
73+
result := backuphealth.EvaluateBackupsExtended(shard, nil, nil, nil)
74+
75+
if result.OldestBackupAge != 0 {
76+
t.Errorf("expected zero oldest age with no backups, got %v", result.OldestBackupAge)
77+
}
78+
}
79+
80+
func TestEvaluateBackupsExtended_IntegrityCheckPass(t *testing.T) {
81+
t.Parallel()
82+
shard := newTestShard()
83+
backups := []*multipoolermanagerdatapb.BackupMetadata{
84+
{BackupId: recentBackupID(1 * time.Hour), Status: multipoolermanagerdatapb.BackupMetadata_COMPLETE, Type: "full"},
85+
}
86+
check := &backuphealth.IntegrityCheckResult{Passed: true}
87+
88+
result := backuphealth.EvaluateBackupsExtended(shard, backups, nil, check)
89+
90+
if result.RepositoryHealthy == nil || !*result.RepositoryHealthy {
91+
t.Error("expected RepositoryHealthy=true when integrity check passes")
92+
}
93+
if result.RepositoryReason != backuphealth.ReasonHealthy {
94+
t.Errorf("expected reason %q, got %q", backuphealth.ReasonHealthy, result.RepositoryReason)
95+
}
96+
}
97+
98+
func TestEvaluateBackupsExtended_IntegrityCheckFail(t *testing.T) {
99+
t.Parallel()
100+
shard := newTestShard()
101+
backups := []*multipoolermanagerdatapb.BackupMetadata{
102+
{BackupId: recentBackupID(1 * time.Hour), Status: multipoolermanagerdatapb.BackupMetadata_COMPLETE, Type: "full"},
103+
}
104+
check := &backuphealth.IntegrityCheckResult{Passed: false, Error: "stanza mismatch"}
105+
106+
result := backuphealth.EvaluateBackupsExtended(shard, backups, nil, check)
107+
108+
if result.RepositoryHealthy == nil || *result.RepositoryHealthy {
109+
t.Error("expected RepositoryHealthy=false when integrity check fails")
110+
}
111+
if result.RepositoryReason != backuphealth.ReasonIntegrityCheckFailed {
112+
t.Errorf("expected reason %q, got %q", backuphealth.ReasonIntegrityCheckFailed, result.RepositoryReason)
113+
}
114+
}
115+
116+
func TestEvaluateBackupsExtended_NoIntegrityCheck_Nil(t *testing.T) {
117+
t.Parallel()
118+
shard := newTestShard()
119+
backups := []*multipoolermanagerdatapb.BackupMetadata{
120+
{BackupId: recentBackupID(1 * time.Hour), Status: multipoolermanagerdatapb.BackupMetadata_COMPLETE, Type: "full"},
121+
}
122+
123+
result := backuphealth.EvaluateBackupsExtended(shard, backups, nil, nil)
124+
125+
if result.RepositoryHealthy != nil {
126+
t.Errorf("expected RepositoryHealthy=nil when no integrity check, got %v", *result.RepositoryHealthy)
127+
}
128+
}
129+
130+
func TestEvaluateBackupsExtended_RetentionCountWarning(t *testing.T) {
131+
t.Parallel()
132+
shard := newTestShard()
133+
backups := []*multipoolermanagerdatapb.BackupMetadata{
134+
{BackupId: recentBackupID(1 * time.Hour), Status: multipoolermanagerdatapb.BackupMetadata_COMPLETE, Type: "full"},
135+
}
136+
retention := &multigresv1alpha1.RetentionPolicy{FullCount: ptr.To(int32(4))}
137+
138+
result := backuphealth.EvaluateBackupsExtended(shard, backups, retention, nil)
139+
140+
if !result.RetentionCountWarning {
141+
t.Error("expected retention count warning when 1 full backup vs retention=4")
142+
}
143+
}
144+
145+
func TestEvaluateBackupsExtended_RetentionCountOK(t *testing.T) {
146+
t.Parallel()
147+
shard := newTestShard()
148+
backups := []*multipoolermanagerdatapb.BackupMetadata{
149+
{BackupId: recentBackupID(1 * time.Hour), Status: multipoolermanagerdatapb.BackupMetadata_COMPLETE, Type: "full"},
150+
{BackupId: recentBackupID(25 * time.Hour), Status: multipoolermanagerdatapb.BackupMetadata_COMPLETE, Type: "full"},
151+
{BackupId: recentBackupID(49 * time.Hour), Status: multipoolermanagerdatapb.BackupMetadata_COMPLETE, Type: "full"},
152+
}
153+
retention := &multigresv1alpha1.RetentionPolicy{FullCount: ptr.To(int32(3))}
154+
155+
result := backuphealth.EvaluateBackupsExtended(shard, backups, retention, nil)
156+
157+
if result.RetentionCountWarning {
158+
t.Error("expected no retention count warning when count matches retention")
159+
}
160+
}
161+
162+
func TestApplyExtended_SetsConditionTrue(t *testing.T) {
163+
t.Parallel()
164+
shard := newTestShard()
165+
healthy := true
166+
result := &backuphealth.ExtendedResult{
167+
RepositoryHealthy: &healthy,
168+
RepositoryReason: backuphealth.ReasonHealthy,
169+
RepositoryMessage: "OK",
170+
}
171+
172+
backuphealth.ApplyExtended(shard, result)
173+
174+
found := false
175+
for _, c := range shard.Status.Conditions {
176+
if c.Type == backuphealth.ConditionRepositoryHealthy {
177+
found = true
178+
if c.Status != metav1.ConditionTrue {
179+
t.Errorf("expected True, got %s", c.Status)
180+
}
181+
}
182+
}
183+
if !found {
184+
t.Error("BackupRepositoryHealthy condition not set")
185+
}
186+
}
187+
188+
func TestApplyExtended_SetsConditionFalse(t *testing.T) {
189+
t.Parallel()
190+
shard := newTestShard()
191+
unhealthy := false
192+
result := &backuphealth.ExtendedResult{
193+
RepositoryHealthy: &unhealthy,
194+
RepositoryReason: backuphealth.ReasonIntegrityCheckFailed,
195+
RepositoryMessage: "check failed",
196+
}
197+
198+
backuphealth.ApplyExtended(shard, result)
199+
200+
for _, c := range shard.Status.Conditions {
201+
if c.Type == backuphealth.ConditionRepositoryHealthy {
202+
if c.Status != metav1.ConditionFalse {
203+
t.Errorf("expected False, got %s", c.Status)
204+
}
205+
if c.Reason != backuphealth.ReasonIntegrityCheckFailed {
206+
t.Errorf("expected reason %q, got %q", backuphealth.ReasonIntegrityCheckFailed, c.Reason)
207+
}
208+
return
209+
}
210+
}
211+
t.Error("BackupRepositoryHealthy condition not set")
212+
}
213+
214+
func TestApplyExtended_RemovesStaleCondition(t *testing.T) {
215+
t.Parallel()
216+
shard := newTestShard()
217+
// Pre-set a condition
218+
shard.Status.Conditions = []metav1.Condition{
219+
{Type: backuphealth.ConditionRepositoryHealthy, Status: metav1.ConditionTrue, Reason: "Healthy"},
220+
{Type: "Available", Status: metav1.ConditionTrue, Reason: "AllPodsReady"},
221+
}
222+
223+
// Apply with nil RepositoryHealthy — should remove the stale condition
224+
result := &backuphealth.ExtendedResult{RepositoryHealthy: nil}
225+
backuphealth.ApplyExtended(shard, result)
226+
227+
for _, c := range shard.Status.Conditions {
228+
if c.Type == backuphealth.ConditionRepositoryHealthy {
229+
t.Error("expected BackupRepositoryHealthy condition to be removed, but it's still present")
230+
}
231+
}
232+
// Other conditions should be preserved
233+
if len(shard.Status.Conditions) != 1 {
234+
t.Errorf("expected 1 remaining condition, got %d", len(shard.Status.Conditions))
235+
}
236+
}

0 commit comments

Comments
 (0)