Files
cozystack/internal/backupcontroller/cnpgstrategy_controller_test.go
2026-05-28 13:49:59 +04:00

1528 lines
59 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package backupcontroller
import (
"context"
"strings"
"testing"
"time"
apiequality "k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
dynamicfake "k8s.io/client-go/dynamic/fake"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
corev1 "k8s.io/api/core/v1"
strategyv1alpha1 "github.com/cozystack/cozystack/api/backups/strategy/v1alpha1"
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
"github.com/cozystack/cozystack/internal/backupcontroller/cnpgtypes"
"github.com/cozystack/cozystack/internal/backupcontroller/postgresapp"
)
func TestCNPGClusterNameForApp(t *testing.T) {
got := cnpgClusterNameForApp("foo")
if got != "postgres-foo" {
t.Fatalf("cnpgClusterNameForApp(%q) = %q, want postgres-foo", "foo", got)
}
}
func TestBuildBarmanObjectStore_AllFields(t *testing.T) {
jobs := int32(4)
tmpl := strategyv1alpha1.BarmanObjectStoreTemplate{
DestinationPath: "s3://bucket/path/",
EndpointURL: "http://s3:9000",
RetentionPolicy: "30d",
S3Credentials: &strategyv1alpha1.S3CredentialsTemplate{
SecretRef: corev1.LocalObjectReference{Name: "creds"},
},
Wal: &strategyv1alpha1.BarmanWalTemplate{Compression: "gzip"},
Data: &strategyv1alpha1.BarmanDataTemplate{Compression: "gzip", Jobs: &jobs},
}
got := buildBarmanObjectStore(tmpl, "my-server")
// retentionPolicy lives at spec.backup.retentionPolicy in CNPG; it is
// emitted by applyClusterBarmanObjectStore and intentionally absent here.
want := &cnpgtypes.BarmanObjectStoreConfiguration{
DestinationPath: "s3://bucket/path/",
EndpointURL: "http://s3:9000",
ServerName: "my-server",
S3Credentials: &cnpgtypes.S3Credentials{
AccessKeyID: &cnpgtypes.SecretKeySelector{Name: "creds", Key: defaultS3AccessKeyIDKey},
SecretAccessKey: &cnpgtypes.SecretKeySelector{Name: "creds", Key: defaultS3SecretAccessKeyKey},
},
Wal: &cnpgtypes.WalBackupConfiguration{Compression: "gzip"},
Data: &cnpgtypes.DataBackupConfiguration{Compression: "gzip", Jobs: ptr.To(int32(4))},
}
if !apiequality.Semantic.DeepEqual(got, want) {
t.Fatalf("buildBarmanObjectStore mismatch\n got: %#v\nwant: %#v", got, want)
}
}
func TestBuildBarmanObjectStore_Minimal(t *testing.T) {
tmpl := strategyv1alpha1.BarmanObjectStoreTemplate{
DestinationPath: "s3://only/",
}
got := buildBarmanObjectStore(tmpl, "fallback")
want := &cnpgtypes.BarmanObjectStoreConfiguration{
DestinationPath: "s3://only/",
ServerName: "fallback",
}
if !apiequality.Semantic.DeepEqual(got, want) {
t.Fatalf("minimal buildBarmanObjectStore mismatch\n got: %#v\nwant: %#v", got, want)
}
}
func TestBuildBarmanObjectStore_CustomCredentialKeys(t *testing.T) {
tmpl := strategyv1alpha1.BarmanObjectStoreTemplate{
DestinationPath: "s3://x/",
S3Credentials: &strategyv1alpha1.S3CredentialsTemplate{
SecretRef: corev1.LocalObjectReference{Name: "creds"},
AccessKeyIDKey: "AKID",
SecretAccessKeyKey: "SKID",
},
}
got := buildBarmanObjectStore(tmpl, "s")
if got.S3Credentials.AccessKeyID.Key != "AKID" {
t.Fatalf("expected custom access key id key AKID, got %q", got.S3Credentials.AccessKeyID.Key)
}
if got.S3Credentials.SecretAccessKey.Key != "SKID" {
t.Fatalf("expected custom secret access key key SKID, got %q", got.S3Credentials.SecretAccessKey.Key)
}
}
// TestBuildBarmanObjectStore_EndpointCA covers the TLS-with-self-signed-CA
// path: a strategy that names a Secret holding the CA bundle gets translated
// into the matching cnpgtypes.SecretKeySelector on the live Cluster, defaulting
// the key name to "ca.crt" when not specified.
func TestBuildBarmanObjectStore_EndpointCA(t *testing.T) {
t.Run("default key", func(t *testing.T) {
tmpl := strategyv1alpha1.BarmanObjectStoreTemplate{
DestinationPath: "s3://b/",
EndpointCA: &strategyv1alpha1.EndpointCARef{
SecretRef: corev1.LocalObjectReference{Name: "trust-bundle"},
},
}
got := buildBarmanObjectStore(tmpl, "s")
if got.EndpointCA == nil || got.EndpointCA.Name != "trust-bundle" || got.EndpointCA.Key != "ca.crt" {
t.Fatalf("EndpointCA mismatch: %#v", got.EndpointCA)
}
})
t.Run("custom key", func(t *testing.T) {
tmpl := strategyv1alpha1.BarmanObjectStoreTemplate{
DestinationPath: "s3://b/",
EndpointCA: &strategyv1alpha1.EndpointCARef{
SecretRef: corev1.LocalObjectReference{Name: "trust-bundle"},
Key: "tls.crt",
},
}
got := buildBarmanObjectStore(tmpl, "s")
if got.EndpointCA == nil || got.EndpointCA.Key != "tls.crt" {
t.Fatalf("EndpointCA key not honored: %#v", got.EndpointCA)
}
})
t.Run("nil block stays nil", func(t *testing.T) {
tmpl := strategyv1alpha1.BarmanObjectStoreTemplate{DestinationPath: "s3://b/"}
got := buildBarmanObjectStore(tmpl, "s")
if got.EndpointCA != nil {
t.Fatalf("EndpointCA must be nil when not configured; got %#v", got.EndpointCA)
}
})
t.Run("empty SecretRef.Name treated as not configured", func(t *testing.T) {
tmpl := strategyv1alpha1.BarmanObjectStoreTemplate{
DestinationPath: "s3://b/",
EndpointCA: &strategyv1alpha1.EndpointCARef{Key: "ca.crt"},
}
got := buildBarmanObjectStore(tmpl, "s")
if got.EndpointCA != nil {
t.Fatalf("expected nil EndpointCA when SecretRef.Name is empty; got %#v", got.EndpointCA)
}
})
}
func TestRenderCNPGTemplate_TemplatingApplicationName(t *testing.T) {
tmpl := strategyv1alpha1.CNPGTemplate{
ServerName: "{{ .Application.metadata.name }}",
BarmanObjectStore: strategyv1alpha1.BarmanObjectStoreTemplate{
DestinationPath: "s3://bucket/{{ .Application.metadata.name }}/",
S3Credentials: &strategyv1alpha1.S3CredentialsTemplate{
SecretRef: corev1.LocalObjectReference{Name: "{{ .Application.metadata.name }}-creds"},
},
},
}
app := newPostgresApp("pg-src", "tenant")
got, err := renderCNPGTemplate(tmpl, app, nil)
if err != nil {
t.Fatalf("renderCNPGTemplate: %v", err)
}
if got.ServerName != "pg-src" {
t.Errorf("ServerName not templated: got %q", got.ServerName)
}
if got.BarmanObjectStore.DestinationPath != "s3://bucket/pg-src/" {
t.Errorf("DestinationPath not templated: got %q", got.BarmanObjectStore.DestinationPath)
}
if got.BarmanObjectStore.S3Credentials.SecretRef.Name != "pg-src-creds" {
t.Errorf("SecretRef.Name not templated: got %q", got.BarmanObjectStore.S3Credentials.SecretRef.Name)
}
}
// TestRenderCNPGTemplate_RoundTripsParametersThroughSnapshot locks in the
// fix for review blocker 1 (#4387688473): a strategy template that uses
// {{ .Parameters.foo }} for a Secret reference / endpointCA / key name
// must yield the same rendered value at restore time as it did at backup
// time. Before the fix, the restore reconciler called renderCNPGTemplate
// with parameters=nil, so any .Parameters reference rendered "<no value>"
// and the patched target Postgres app got broken Secret references that
// CNPG would later reject.
//
// Verifies the contract end-to-end: render-backup -> marshal snapshot ->
// unmarshal snapshot -> render-restore. Also asserts the buggy path
// (parameters=nil at restore) produces a recognisable broken value, so
// a regression that drops the persistence side gets caught here too.
func TestRenderCNPGTemplate_RoundTripsParametersThroughSnapshot(t *testing.T) {
tmpl := strategyv1alpha1.CNPGTemplate{
BarmanObjectStore: strategyv1alpha1.BarmanObjectStoreTemplate{
DestinationPath: "s3://bucket/{{ .Application.metadata.name }}/",
S3Credentials: &strategyv1alpha1.S3CredentialsTemplate{
SecretRef: corev1.LocalObjectReference{Name: "{{ .Parameters.credsSecret }}"},
},
EndpointCA: &strategyv1alpha1.EndpointCARef{
SecretRef: corev1.LocalObjectReference{Name: "{{ .Parameters.caSecret }}"},
Key: "ca.crt",
},
},
}
app := newPostgresApp("pg-src", "tenant")
parameters := map[string]string{
"credsSecret": "tenant-shared-creds",
"caSecret": "tenant-shared-ca",
}
// Backup-time render: real values flow through.
rendBackup, err := renderCNPGTemplate(tmpl, app, parameters)
if err != nil {
t.Fatalf("backup-time render: %v", err)
}
if rendBackup.BarmanObjectStore.S3Credentials.SecretRef.Name != "tenant-shared-creds" {
t.Fatalf("backup-time SecretRef.Name: got %q want %q",
rendBackup.BarmanObjectStore.S3Credentials.SecretRef.Name, "tenant-shared-creds")
}
if rendBackup.BarmanObjectStore.EndpointCA.SecretRef.Name != "tenant-shared-ca" {
t.Fatalf("backup-time EndpointCA.SecretRef.Name: got %q want %q",
rendBackup.BarmanObjectStore.EndpointCA.SecretRef.Name, "tenant-shared-ca")
}
// Round-trip parameters through Backup.status.underlyingResources.
raw, err := marshalCNPGBackupSnapshot(app, parameters)
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
bk := &backupsv1alpha1.Backup{Status: backupsv1alpha1.BackupStatus{UnderlyingResources: raw}}
_, _, gotParams, err := unmarshalCNPGBackupSnapshot(bk)
if err != nil {
t.Fatalf("unmarshal snapshot: %v", err)
}
// Restore-time render with retrieved parameters: same values out.
rendRestore, err := renderCNPGTemplate(tmpl, app, gotParams)
if err != nil {
t.Fatalf("restore-time render: %v", err)
}
if rendRestore.BarmanObjectStore.S3Credentials.SecretRef.Name != "tenant-shared-creds" {
t.Errorf("restore-time SecretRef.Name: got %q want %q",
rendRestore.BarmanObjectStore.S3Credentials.SecretRef.Name, "tenant-shared-creds")
}
if rendRestore.BarmanObjectStore.EndpointCA.SecretRef.Name != "tenant-shared-ca" {
t.Errorf("restore-time EndpointCA.SecretRef.Name: got %q want %q",
rendRestore.BarmanObjectStore.EndpointCA.SecretRef.Name, "tenant-shared-ca")
}
// Buggy path: rendering at restore with parameters=nil produces
// "<no value>". Catching this here means a future change that
// silently drops parameters off the snapshot fails this test before
// it ships a broken patch to a real Postgres app.
rendBroken, err := renderCNPGTemplate(tmpl, app, nil)
if err != nil {
t.Fatalf("nil-params render: %v", err)
}
if rendBroken.BarmanObjectStore.S3Credentials.SecretRef.Name != "<no value>" {
t.Errorf("expected broken render for nil params (so the test catches regressions), got %q",
rendBroken.BarmanObjectStore.S3Credentials.SecretRef.Name)
}
}
func TestParseCNPGRestoreOptions(t *testing.T) {
cases := []struct {
name string
raw string
wantRecoveryTime string
wantTimeout int64
wantErr bool
}{
{"nil blob", "", "", 0, false},
{"missing fields", `{"foo":"bar"}`, "", 0, false},
{"recovery time only", `{"recoveryTime":"2026-05-01T12:00:00Z"}`, "2026-05-01T12:00:00Z", 0, false},
{"timeout only", `{"restoreTimeoutSeconds":7200}`, "", 7200, false},
{"both", `{"recoveryTime":"2026-05-01T12:00:00Z","restoreTimeoutSeconds":3600}`, "2026-05-01T12:00:00Z", 3600, false},
// A malformed blob must surface a parse error so callers can log it.
// Returning silently used to mask "why didn't recoveryTime apply" for
// tenants debugging restores; see Blocker 5 in the branch review.
{"malformed", `not-json`, "", 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var ext *runtime.RawExtension
if tc.raw != "" {
ext = &runtime.RawExtension{Raw: []byte(tc.raw)}
}
got, err := parseCNPGRestoreOptions(ext)
if tc.wantErr {
if err == nil {
t.Fatalf("expected parse error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
if got.RecoveryTime != tc.wantRecoveryTime {
t.Errorf("recoveryTime: got %q want %q", got.RecoveryTime, tc.wantRecoveryTime)
}
if got.RestoreTimeoutSeconds != tc.wantTimeout {
t.Errorf("restoreTimeoutSeconds: got %d want %d", got.RestoreTimeoutSeconds, tc.wantTimeout)
}
})
}
}
func TestEffectiveRestoreDeadline(t *testing.T) {
cases := []struct {
name string
opts CNPGRestoreOptions
want time.Duration
}{
{"unset falls back to default", CNPGRestoreOptions{}, cnpgDefaultRestoreDeadline},
{"zero falls back to default", CNPGRestoreOptions{RestoreTimeoutSeconds: 0}, cnpgDefaultRestoreDeadline},
{"negative falls back to default", CNPGRestoreOptions{RestoreTimeoutSeconds: -5}, cnpgDefaultRestoreDeadline},
{"positive override", CNPGRestoreOptions{RestoreTimeoutSeconds: 7200}, 2 * time.Hour},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := tc.opts.effectiveRestoreDeadline()
if got != tc.want {
t.Errorf("got %s want %s", got, tc.want)
}
})
}
}
// TestEffectiveWALArchiveDeadline mirrors TestEffectiveRestoreDeadline for
// the dedicated WAL-archive gate cap. The two deadlines exist independently
// so a big-DB restore can extend RestoreTimeoutSeconds without simultaneously
// loosening the gate that catches a stuck archive_command.
func TestEffectiveWALArchiveDeadline(t *testing.T) {
cases := []struct {
name string
opts CNPGRestoreOptions
want time.Duration
}{
{"unset falls back to default", CNPGRestoreOptions{}, cnpgWALArchiveDeadline},
{"zero falls back to default", CNPGRestoreOptions{WALArchiveTimeoutSeconds: 0}, cnpgWALArchiveDeadline},
{"negative falls back to default", CNPGRestoreOptions{WALArchiveTimeoutSeconds: -5}, cnpgWALArchiveDeadline},
{"positive override", CNPGRestoreOptions{WALArchiveTimeoutSeconds: 600}, 10 * time.Minute},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := tc.opts.effectiveWALArchiveDeadline()
if got != tc.want {
t.Errorf("got %s want %s", got, tc.want)
}
})
}
}
// TestValidateCNPGApplicationRef locks in the API-group guard. Without it a
// non-Cozystack ref like other.example.com/Postgres slipped past the Kind
// check and hit the hard-wired apps.cozystack.io typed client, reconciling
// against the wrong CRD entirely.
func TestValidateCNPGApplicationRef(t *testing.T) {
cozyGroup := postgresapp.GroupName
otherGroup := "other.example.com"
cases := []struct {
name string
ref corev1.TypedLocalObjectReference
wantErr bool
}{
{
name: "kind=Postgres, apiGroup=apps.cozystack.io: accepted",
ref: corev1.TypedLocalObjectReference{APIGroup: &cozyGroup, Kind: postgresAppKind, Name: "pg"},
},
{
name: "kind=Postgres, nil apiGroup: accepted (default applies upstream)",
ref: corev1.TypedLocalObjectReference{Kind: postgresAppKind, Name: "pg"},
},
{
name: "wrong kind: rejected",
ref: corev1.TypedLocalObjectReference{APIGroup: &cozyGroup, Kind: "MySQL", Name: "pg"},
wantErr: true,
},
{
name: "right kind, wrong apiGroup: rejected",
ref: corev1.TypedLocalObjectReference{APIGroup: &otherGroup, Kind: postgresAppKind, Name: "pg"},
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateCNPGApplicationRef(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)
}
})
}
}
func TestResolveCNPGRestoreTarget(t *testing.T) {
apiGroup := backupsv1alpha1.DefaultApplicationAPIGroup
src := &backupsv1alpha1.Backup{}
src.Namespace = "tenant-foo"
src.Spec.ApplicationRef.APIGroup = &apiGroup
src.Spec.ApplicationRef.Kind = postgresAppKind
src.Spec.ApplicationRef.Name = "pg-src"
r := &RestoreJobReconciler{}
t.Run("source app when targetApplicationRef nil", func(t *testing.T) {
rj := &backupsv1alpha1.RestoreJob{}
got := r.resolveCNPGRestoreTarget(rj, src)
if got.AppName != "pg-src" || got.Namespace != "tenant-foo" || got.Kind != postgresAppKind {
t.Fatalf("unexpected target: %+v", got)
}
})
t.Run("override app name when targetApplicationRef differs", func(t *testing.T) {
rj := &backupsv1alpha1.RestoreJob{}
rj.Spec.TargetApplicationRef = newTypedRef(apiGroup, postgresAppKind, "pg-target")
got := r.resolveCNPGRestoreTarget(rj, src)
if got.AppName != "pg-target" {
t.Fatalf("unexpected app name: %q", got.AppName)
}
})
t.Run("keep source app name when targetApplicationRef matches source", func(t *testing.T) {
rj := &backupsv1alpha1.RestoreJob{}
rj.Spec.TargetApplicationRef = newTypedRef(apiGroup, postgresAppKind, "pg-src")
got := r.resolveCNPGRestoreTarget(rj, src)
if got.AppName != "pg-src" {
t.Fatalf("unexpected app name: %q", got.AppName)
}
})
}
func TestCNPGBackupPhase(t *testing.T) {
cases := []struct {
name string
backup *cnpgtypes.Backup
wantPhase string
wantMsg string
}{
{"nil", nil, "", ""},
{"completed", &cnpgtypes.Backup{Status: cnpgtypes.BackupStatus{Phase: "completed"}}, "completed", ""},
{"failed-with-error", &cnpgtypes.Backup{Status: cnpgtypes.BackupStatus{Phase: "failed", Error: "boom"}}, "failed", "boom"},
{"empty status", &cnpgtypes.Backup{}, "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
phase, msg := cnpgBackupPhase(tc.backup)
if phase != tc.wantPhase || msg != tc.wantMsg {
t.Fatalf("got (%q,%q), want (%q,%q)", phase, msg, tc.wantPhase, tc.wantMsg)
}
})
}
}
// newTypedRef is a small helper to build a TypedLocalObjectReference without
// the test having to bother with apiGroup pointer plumbing.
func newTypedRef(apiGroup, kind, name string) *corev1.TypedLocalObjectReference {
return &corev1.TypedLocalObjectReference{APIGroup: &apiGroup, Kind: kind, Name: name}
}
// newPostgresApp builds a typed Postgres CR for tests.
func newPostgresApp(name, namespace string) *postgresapp.Postgres {
return &postgresapp.Postgres{
TypeMeta: metav1.TypeMeta{
APIVersion: postgresapp.GroupVersion.String(),
Kind: postgresapp.Kind,
},
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
}
}
// TestBuildPostgresAppRestorePatch_ForwardsSecretRef_NotCleartext locks in
// the security-critical contract that S3 credentials are forwarded to the
// chart as a Secret reference, not pasted into the CR .spec as cleartext.
// Regressing this would expose access keys via etcd, audit logs, and
// tenant-readable kubectl get -o yaml.
func TestBuildPostgresAppRestorePatch_ForwardsSecretRef_NotCleartext(t *testing.T) {
app := newPostgresApp("pg-target", "tenant-foo")
creds := &strategyv1alpha1.S3CredentialsTemplate{
SecretRef: corev1.LocalObjectReference{Name: "pg-target-cnpg-backup-creds"},
}
patched := buildPostgresAppRestorePatch(
app,
"pg-src",
"s3://bucket/pg-src/",
"https://s3.example",
"",
creds,
nil,
nil, nil,
)
if got := patched.Spec.Backup.S3CredentialsSecret.Name; got != "pg-target-cnpg-backup-creds" {
t.Errorf("s3CredentialsSecret.name: got %q want %q", got, "pg-target-cnpg-backup-creds")
}
// Cleartext key fields must NOT be set. Their presence on the CR .spec is
// the bug this test guards against.
if got := patched.Spec.Backup.S3AccessKey; got != "" {
t.Errorf("spec.backup.s3AccessKey must not be set on the patched app; got %q", got)
}
if got := patched.Spec.Backup.S3SecretKey; got != "" {
t.Errorf("spec.backup.s3SecretKey must not be set on the patched app; got %q", got)
}
}
// TestBuildPostgresAppRestorePatch_ForwardsCustomKeyOverrides verifies the
// strategy template's optional key-name overrides round-trip into the chart
// values so non-default Secret layouts work end-to-end.
func TestBuildPostgresAppRestorePatch_ForwardsCustomKeyOverrides(t *testing.T) {
app := newPostgresApp("pg", "tenant")
creds := &strategyv1alpha1.S3CredentialsTemplate{
SecretRef: corev1.LocalObjectReference{Name: "creds"},
AccessKeyIDKey: "ACCESS_KEY",
SecretAccessKeyKey: "SECRET_KEY",
}
patched := buildPostgresAppRestorePatch(app, "src", "s3://b/", "", "", creds, nil, nil, nil)
want := postgresapp.S3CredentialsSecret{
Name: "creds",
AccessKeyIDKey: "ACCESS_KEY",
SecretAccessKeyKey: "SECRET_KEY",
}
if got := patched.Spec.Backup.S3CredentialsSecret; got != want {
t.Fatalf("s3CredentialsSecret mismatch\n got: %#v\nwant: %#v", got, want)
}
}
// TestBuildPostgresAppRestorePatch_NoSecretRefIsSkipped verifies the
// patcher does not stamp an empty s3CredentialsSecret block when the strategy
// did not declare credentials. Otherwise the chart would render an empty
// Secret name and helm install would fail.
func TestBuildPostgresAppRestorePatch_NoSecretRefIsSkipped(t *testing.T) {
app := newPostgresApp("pg", "tenant")
patched := buildPostgresAppRestorePatch(app, "src", "s3://b/", "", "", nil, nil, nil, nil)
if got := patched.Spec.Backup.S3CredentialsSecret; got != (postgresapp.S3CredentialsSecret{}) {
t.Errorf("spec.backup.s3CredentialsSecret must be zero when credsRef is nil; got %#v", got)
}
}
// TestCNPGPurgeNeeded locks in the dual-guard against re-purging the
// freshly-recovered Cluster on a status-update failure. The controller used
// to rely solely on a Status condition: if the post-purge Status().Update
// raced or failed, the next reconcile would re-purge the just-restored
// Cluster, destroying recovery progress. Cross-checking the live Cluster's
// bootstrap.recovery makes the second purge a no-op.
func TestCNPGPurgeNeeded(t *testing.T) {
cases := []struct {
name string
purgedCondition bool
hasRecovery bool
want bool
}{
{
name: "fresh restore, old cluster still in place: purge",
purgedCondition: false,
hasRecovery: false,
want: true,
},
{
name: "purge already recorded: skip",
purgedCondition: true,
hasRecovery: false,
want: false,
},
{
name: "live cluster already recovered (status write raced): skip - the bug fix",
purgedCondition: false,
hasRecovery: true,
want: false,
},
{
name: "both true: skip (post-purge steady state)",
purgedCondition: true,
hasRecovery: true,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cnpgPurgeNeeded(tc.purgedCondition, tc.hasRecovery)
if got != tc.want {
t.Errorf("got %v want %v", got, tc.want)
}
})
}
}
// TestBuildPostgresAppRestorePatch_ReplacesTargetUsersAndDatabases locks
// in the authoritative-from-source semantics: target's stale users and
// databases must be wiped and replaced with source's exact map. The
// recovered cluster carries source's role catalog and data; if target's
// pre-restore drift survives, the chart's init-job either tries to
// re-create roles against the wrong data or leaks cleartext passwords
// from a previous tenant configuration.
func TestBuildPostgresAppRestorePatch_ReplacesTargetUsersAndDatabases(t *testing.T) {
app := newPostgresApp("pg-target", "tenant")
// Target had pre-existing users/databases (e.g. from a previous restore
// or operator drift). Replace must wipe them.
app.Spec.Users = map[string]postgresapp.User{
"stale-target-user": {Password: "leak-me"},
}
app.Spec.Databases = map[string]postgresapp.Database{
"stale-target-db": {Extensions: []string{"pgcrypto"}},
}
sourceUsers := map[string]postgresapp.User{
"app": {Password: "src"},
}
sourceDatabases := map[string]postgresapp.Database{
"appdb": {Extensions: []string{"hstore"}},
}
patched := buildPostgresAppRestorePatch(app, "src", "s3://b/", "", "", nil, nil, sourceDatabases, sourceUsers)
if _, ok := patched.Spec.Users["stale-target-user"]; ok {
t.Errorf("stale target user survived restore; replace semantics regressed")
}
if u, ok := patched.Spec.Users["app"]; !ok {
t.Errorf("source user was not propagated onto target")
} else if u.Password != "src" {
t.Errorf("source user password mismatch; got %q want %q", u.Password, "src")
}
if _, ok := patched.Spec.Databases["stale-target-db"]; ok {
t.Errorf("stale target database survived restore; replace semantics regressed")
}
if _, ok := patched.Spec.Databases["appdb"]; !ok {
t.Errorf("source database was not propagated onto target")
}
}
// TestBuildPostgresAppRestorePatch_ScrubsStaleBackupSettings guards the
// stale-state hygiene called out by coderabbitai on
// internal/backupcontroller/cnpgstrategy_controller.go:597-604: a re-restore
// into the same target must not inherit a previous restore's recoveryTime,
// endpointURL, S3 credentials Secret reference, or inline cleartext S3 keys.
func TestBuildPostgresAppRestorePatch_ScrubsStaleBackupSettings(t *testing.T) {
app := newPostgresApp("pg-target", "tenant")
app.Spec.Bootstrap.RecoveryTime = "2025-01-01T00:00:00Z"
app.Spec.Backup.EndpointURL = "https://stale.example"
app.Spec.Backup.S3AccessKey = "stale-ak"
app.Spec.Backup.S3SecretKey = "stale-sk"
app.Spec.Backup.S3CredentialsSecret = postgresapp.S3CredentialsSecret{
Name: "stale-creds", AccessKeyIDKey: "OLD_ID", SecretAccessKeyKey: "OLD_KEY",
}
app.Spec.Backup.EndpointCA = postgresapp.EndpointCA{Name: "stale-ca", Key: "stale.crt"}
// Restore with no recoveryTime, no endpointURL, no creds, no CA -
// everything stale on the target must be wiped.
patched := buildPostgresAppRestorePatch(app, "src", "s3://b/", "", "", nil, nil, nil, nil)
if got := patched.Spec.Bootstrap.RecoveryTime; got != "" {
t.Errorf("stale recoveryTime survived; got %q", got)
}
if got := patched.Spec.Backup.EndpointURL; got != "" {
t.Errorf("stale endpointURL survived; got %q", got)
}
if got := patched.Spec.Backup.S3AccessKey; got != "" {
t.Errorf("inline s3AccessKey was not scrubbed; got %q", got)
}
if got := patched.Spec.Backup.S3SecretKey; got != "" {
t.Errorf("inline s3SecretKey was not scrubbed; got %q", got)
}
if patched.Spec.Backup.S3CredentialsSecret != (postgresapp.S3CredentialsSecret{}) {
t.Errorf("stale s3CredentialsSecret survived; got %+v", patched.Spec.Backup.S3CredentialsSecret)
}
if patched.Spec.Backup.EndpointCA != (postgresapp.EndpointCA{}) {
t.Errorf("stale endpointCA survived; got %+v", patched.Spec.Backup.EndpointCA)
}
}
// TestBuildPostgresAppRestorePatch_ForwardsEndpointCA verifies that a
// strategy template with an EndpointCA reference flows through to the
// target app's spec.backup.endpointCA. Without this the chart's
// externalClusters[].barmanObjectStore.endpointCA stays empty and the
// recovery init job panics in CNPG's instance-manager when the S3
// endpoint serves a self-signed certificate (cozystack seaweedfs-s3 does).
func TestBuildPostgresAppRestorePatch_ForwardsEndpointCA(t *testing.T) {
app := newPostgresApp("pg", "tenant")
caRef := &strategyv1alpha1.EndpointCARef{
SecretRef: corev1.LocalObjectReference{Name: "pg-cnpg-backup-ca"},
Key: "ca.crt",
}
patched := buildPostgresAppRestorePatch(app, "src", "s3://b/", "", "", nil, caRef, nil, nil)
want := postgresapp.EndpointCA{Name: "pg-cnpg-backup-ca", Key: "ca.crt"}
if got := patched.Spec.Backup.EndpointCA; got != want {
t.Fatalf("endpointCA mismatch\n got: %#v\nwant: %#v", got, want)
}
}
// TestMarshalUnmarshalCNPGBackupSnapshot round-trips the source-spec
// snapshot persisted in Backup.status.underlyingResources, including the
// BackupClassStrategy parameters that were in effect at backup time -
// without persisting those a strategy that uses {{ .Parameters.foo }} for
// a Secret reference / endpointCA / key name would re-render at restore
// time with parameters=nil and produce a broken patch.
func TestMarshalUnmarshalCNPGBackupSnapshot(t *testing.T) {
src := newPostgresApp("pg-src", "tenant")
src.Spec.Databases = map[string]postgresapp.Database{
"app": {Extensions: []string{"pgcrypto"}},
}
src.Spec.Users = map[string]postgresapp.User{
"app": {Password: "p"},
}
parameters := map[string]string{
"credsSecret": "tenant-shared-creds",
"region": "eu-west-1",
}
raw, err := marshalCNPGBackupSnapshot(src, parameters)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if raw == nil {
t.Fatalf("expected non-nil RawExtension")
}
bk := &backupsv1alpha1.Backup{Status: backupsv1alpha1.BackupStatus{UnderlyingResources: raw}}
dbs, users, params, err := unmarshalCNPGBackupSnapshot(bk)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if dbs["app"].Extensions[0] != "pgcrypto" {
t.Errorf("databases round-trip mismatch: %#v", dbs)
}
if users["app"].Password != "p" {
t.Errorf("users round-trip mismatch: %#v", users)
}
if params["credsSecret"] != "tenant-shared-creds" {
t.Errorf("parameters round-trip mismatch: got %#v", params)
}
if params["region"] != "eu-west-1" {
t.Errorf("parameters round-trip mismatch: got %#v", params)
}
}
// TestMarshalUnmarshalCNPGBackupSnapshot_BackwardCompat covers a Backup
// taken by an older controller version (no parameters block in the
// snapshot JSON). The unmarshal path must treat that as "no parameter
// overrides" rather than failing - the strategy template might have used
// .Parameters at the time, and we cannot reconstruct values we never
// persisted, so the caller proceeds with parameters=nil and lets the
// rendered output fall back to whatever the template defaults emit.
func TestMarshalUnmarshalCNPGBackupSnapshot_BackwardCompat(t *testing.T) {
legacyRaw := []byte(`{"kind":"CNPGBackupSnapshot","apiVersion":"backups.cozystack.io/v1alpha1","databases":{"app":{}},"users":{"app":{}}}`)
bk := &backupsv1alpha1.Backup{Status: backupsv1alpha1.BackupStatus{UnderlyingResources: &runtime.RawExtension{Raw: legacyRaw}}}
_, _, params, err := unmarshalCNPGBackupSnapshot(bk)
if err != nil {
t.Fatalf("unmarshal legacy snapshot: %v", err)
}
if len(params) != 0 {
t.Errorf("legacy snapshot must yield empty parameters, got %#v", params)
}
}
// TestUnmarshalCNPGBackupSnapshot_RejectsMissingOrWrongKind locks in the
// fail-loud contract: a Backup without a snapshot must NOT silently
// proceed - it would let the chart's init-job DROP recovered roles. Same
// for a Backup whose snapshot carries the wrong Kind (foreign payload).
func TestUnmarshalCNPGBackupSnapshot_RejectsMissingOrWrongKind(t *testing.T) {
cases := []struct {
name string
ur *runtime.RawExtension
}{
{"nil underlyingResources", nil},
{"empty raw", &runtime.RawExtension{Raw: []byte{}}},
{"wrong kind", &runtime.RawExtension{Raw: []byte(`{"kind":"VMInstanceResources","apiVersion":"backups.cozystack.io/v1alpha1"}`)}},
{"malformed json", &runtime.RawExtension{Raw: []byte(`not-json`)}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
bk := &backupsv1alpha1.Backup{Status: backupsv1alpha1.BackupStatus{UnderlyingResources: tc.ur}}
_, _, _, err := unmarshalCNPGBackupSnapshot(bk)
if err == nil {
t.Fatalf("expected error, got nil")
}
})
}
}
// TestCNPGBackupDeadlineExceeded locks in the wall-clock cap that prevents a
// perpetually-failed cnpg.io/Backup from pinning the BackupJob in Running
// forever. Without this guard the Plan-controller queue would back up
// behind a single broken backup.
func TestCNPGBackupDeadlineExceeded(t *testing.T) {
cases := []struct {
name string
startedAt *metav1.Time
want bool
}{
{
name: "nil startedAt does not trip the gate",
startedAt: nil,
want: false,
},
{
name: "fresh start is well under the deadline",
startedAt: &metav1.Time{Time: time.Now()},
want: false,
},
{
name: "just under deadline does not trip",
startedAt: &metav1.Time{Time: time.Now().Add(-(cnpgDefaultBackupDeadline - time.Minute))},
want: false,
},
{
name: "past deadline trips the gate",
startedAt: &metav1.Time{Time: time.Now().Add(-(cnpgDefaultBackupDeadline + time.Minute))},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cnpgBackupDeadlineExceeded(tc.startedAt)
if got != tc.want {
t.Errorf("got %v want %v", got, tc.want)
}
})
}
}
// TestCreateCNPGBackupArtifact_AlreadyExistsReturnsExisting locks in the
// idempotency guard for createCNPGBackupArtifact: an AlreadyExists collision
// (typical when the previous reconcile created the artifact and then raced
// on the BackupJob status update) must return the existing artifact rather
// than propagate the error and flip a completed run to Failed.
func TestCreateCNPGBackupArtifact_AlreadyExistsReturnsExisting(t *testing.T) {
apiGroup := backupsv1alpha1.DefaultApplicationAPIGroup
strategyGroup := strategyv1alpha1.GroupVersion.Group
preExisting := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{APIGroup: &apiGroup, Kind: postgresAppKind, Name: "pg"},
StrategyRef: corev1.TypedLocalObjectReference{APIGroup: &strategyGroup, Kind: strategyv1alpha1.CNPGStrategyKind, Name: "strat"},
DriverMetadata: map[string]string{"marker": "first-reconcile"},
},
}
c := newCNPGStrategyTestClient(t, preExisting)
r := &BackupJobReconciler{Client: c}
j := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj"},
Spec: backupsv1alpha1.BackupJobSpec{
ApplicationRef: corev1.TypedLocalObjectReference{APIGroup: &apiGroup, Kind: postgresAppKind, Name: "pg"},
},
}
resolved := &ResolvedBackupConfig{
StrategyRef: corev1.TypedLocalObjectReference{APIGroup: &strategyGroup, Kind: strategyv1alpha1.CNPGStrategyKind, Name: "strat"},
}
cnpgBk := &cnpgtypes.Backup{ObjectMeta: metav1.ObjectMeta{Name: "cnpg-bk"}}
rendered := &strategyv1alpha1.CNPGTemplate{
BarmanObjectStore: strategyv1alpha1.BarmanObjectStoreTemplate{DestinationPath: "s3://b/"},
}
sourceApp := newPostgresApp("pg", "tenant")
got, err := r.createCNPGBackupArtifact(context.Background(), j, resolved, cnpgBk, "postgres-pg", "postgres-pg", rendered, sourceApp)
if err != nil {
t.Fatalf("expected AlreadyExists to be swallowed, got error %v", err)
}
if got == nil {
t.Fatalf("expected existing Backup returned, got nil")
}
if got.Spec.DriverMetadata["marker"] != "first-reconcile" {
t.Errorf("expected the existing object back, got %#v", got.Spec.DriverMetadata)
}
}
// TestApplyClusterBarmanObjectStore_NotFoundOnMissingCluster locks in the
// precondition added for the smaller blocker on applyClusterBarmanObjectStore:
// when the live cnpg.io Cluster has not yet been rendered by the chart, the
// driver must surface NotFound (so the caller can retry-with-backoff)
// instead of issuing a doomed SSA Apply that would be rejected by the API
// server for missing required Cluster fields.
func TestApplyClusterBarmanObjectStore_NotFoundOnMissingCluster(t *testing.T) {
c := newCNPGStrategyTestClient(t)
r := &BackupJobReconciler{Client: c}
tmpl := &strategyv1alpha1.CNPGTemplate{
BarmanObjectStore: strategyv1alpha1.BarmanObjectStoreTemplate{
DestinationPath: "s3://bucket/x/",
},
}
err := r.applyClusterBarmanObjectStore(context.Background(), "tenant", "postgres-missing", tmpl, "postgres-missing")
if err == nil {
t.Fatalf("expected NotFound error, got nil")
}
if !apierrors.IsNotFound(err) {
t.Fatalf("expected IsNotFound error, got %v", err)
}
}
// TestClusterHasRecoveryBootstrap_TerminatingCluster locks in the
// DeletionTimestamp guard. Without it, a Cluster CR mid-deletion (after
// purgeExistingCluster fired r.Delete but cnpg.io's finalizers haven't
// drained yet) would get treated as "still has bootstrap" by the
// reconciler. The caller would then either skip the next purge or wait
// for healthy on a CR that's about to disappear; in either case Helm
// might SSA-merge the chart's bootstrap.recovery onto the terminating
// CR and cnpg-operator's bootstrap-immutability check would drop the
// change, leaving the cluster on the original initdb spec.
func TestClusterHasRecoveryBootstrap_TerminatingCluster(t *testing.T) {
now := metav1.Now()
t.Run("terminating cluster reports not-yet-recovered", func(t *testing.T) {
cluster := &cnpgtypes.Cluster{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant",
Name: "postgres-app",
DeletionTimestamp: &now,
Finalizers: []string{"cnpg.io/cluster"}, // satisfies finalizer requirement for DeletionTimestamp
},
Spec: cnpgtypes.ClusterSpec{
Bootstrap: &cnpgtypes.BootstrapConfiguration{
Recovery: &cnpgtypes.RecoverySource{Source: "pg-src"},
},
},
}
c := newCNPGStrategyTestClient(t, cluster)
r := &RestoreJobReconciler{Client: c}
got, err := r.clusterHasRecoveryBootstrap(context.Background(), "tenant", "postgres-app")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got {
t.Fatalf("expected false for terminating cluster, got true")
}
})
t.Run("live recovery cluster reports recovered", func(t *testing.T) {
cluster := &cnpgtypes.Cluster{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "postgres-app"},
Spec: cnpgtypes.ClusterSpec{
Bootstrap: &cnpgtypes.BootstrapConfiguration{
Recovery: &cnpgtypes.RecoverySource{Source: "pg-src"},
},
},
}
c := newCNPGStrategyTestClient(t, cluster)
r := &RestoreJobReconciler{Client: c}
got, err := r.clusterHasRecoveryBootstrap(context.Background(), "tenant", "postgres-app")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got {
t.Fatalf("expected true for live recovery cluster, got false")
}
})
t.Run("missing cluster reports not-yet", func(t *testing.T) {
c := newCNPGStrategyTestClient(t)
r := &RestoreJobReconciler{Client: c}
got, err := r.clusterHasRecoveryBootstrap(context.Background(), "tenant", "postgres-app")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got {
t.Fatalf("expected false for missing cluster, got true")
}
})
}
// TestCNPGBackupWALArchived locks in the gate that defers the destructive
// in-place purge until the underlying cnpg.io/Backup is in a state that
// guarantees its closing WAL is in object storage. CNPG only flips
// Backup.status.phase=completed (and writes endWal) after barman-cloud-
// backup has confirmed the upload, so checking the two together is what
// the gate uses; an earlier version of this gate compared
// Cluster.status.lastArchivedWAL to Backup.status.endWal but
// lastArchivedWAL is not present on CNPG 1.27.x cluster status, and the
// gate silently treated the missing field as the empty string and never
// cleared.
func TestCNPGBackupWALArchived(t *testing.T) {
const (
ns = "tenant"
cluster = "postgres-pg-src"
backupID = "pg-src-adhoc"
endWal = "000000010000000000000004"
)
mkPlatformBackup := func() *backupsv1alpha1.Backup {
return &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: backupID},
Spec: backupsv1alpha1.BackupSpec{
DriverMetadata: map[string]string{
cnpgBackupNameKey: backupID,
cnpgClusterNameKey: cluster,
},
},
}
}
mkCNPGBackup := func(phase, end string) *cnpgtypes.Backup {
return &cnpgtypes.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: backupID},
Spec: cnpgtypes.BackupSpec{Cluster: cnpgtypes.ClusterReference{Name: cluster}},
Status: cnpgtypes.BackupStatus{Phase: phase, EndWal: end},
}
}
t.Run("blocks while underlying cnpg.io/Backup is still running", func(t *testing.T) {
c := newCNPGStrategyTestClient(t, mkCNPGBackup("running", ""))
r := &RestoreJobReconciler{Client: c}
ready, msg, err := r.cnpgBackupWALArchived(context.Background(), mkPlatformBackup())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ready {
t.Fatalf("expected not-ready, got ready (%s)", msg)
}
if msg == "" {
t.Errorf("expected human-readable message, got empty")
}
})
t.Run("blocks when underlying cnpg.io/Backup is in a failure state", func(t *testing.T) {
// Failure state must NOT clear the gate even when endWal somehow has
// a value - the failure could mean barman is mid-retry and the
// closing WAL upload never landed.
c := newCNPGStrategyTestClient(t, mkCNPGBackup("failed", endWal))
r := &RestoreJobReconciler{Client: c}
ready, _, err := r.cnpgBackupWALArchived(context.Background(), mkPlatformBackup())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ready {
t.Fatalf("expected not-ready for phase=failed")
}
})
t.Run("clears when phase=completed and endWal is set", func(t *testing.T) {
c := newCNPGStrategyTestClient(t, mkCNPGBackup(cnpgBackupPhaseComplete, endWal))
r := &RestoreJobReconciler{Client: c}
ready, _, err := r.cnpgBackupWALArchived(context.Background(), mkPlatformBackup())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ready {
t.Fatalf("expected ready when phase=completed and endWal is set")
}
})
t.Run("blocks when phase=completed but endWal is still empty", func(t *testing.T) {
// Defensive: if a future CNPG version flips phase to completed
// before persisting endWal (or a buggy build does), we want the
// gate to stay closed rather than proceeding to a destructive
// purge against a backup whose closing WAL we cannot prove is
// in object storage.
c := newCNPGStrategyTestClient(t, mkCNPGBackup(cnpgBackupPhaseComplete, ""))
r := &RestoreJobReconciler{Client: c}
ready, _, err := r.cnpgBackupWALArchived(context.Background(), mkPlatformBackup())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ready {
t.Fatalf("expected not-ready when endWal is empty")
}
})
t.Run("conservative on missing cnpg.io/Backup (treat as ready, retention case)", func(t *testing.T) {
c := newCNPGStrategyTestClient(t)
r := &RestoreJobReconciler{Client: c}
ready, _, err := r.cnpgBackupWALArchived(context.Background(), mkPlatformBackup())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ready {
t.Fatalf("expected ready when underlying cnpg.io/Backup is gone (retention)")
}
})
t.Run("blocks when driverMetadata is missing the backup name", func(t *testing.T) {
c := newCNPGStrategyTestClient(t)
r := &RestoreJobReconciler{Client: c}
platform := mkPlatformBackup()
delete(platform.Spec.DriverMetadata, cnpgBackupNameKey)
ready, _, err := r.cnpgBackupWALArchived(context.Background(), platform)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ready {
t.Fatalf("expected not-ready when driverMetadata is missing the backup name")
}
})
}
// TestSetCNPGRestoreHRSuspended locks in the toggle helper used by the
// destructive purge to suspend helm-controller before mutating the
// Postgres app spec, and to resume it once the live Cluster + PVCs are
// gone. Without this guard helm-controller observes the bootstrap.enabled=
// true flip while the live Cluster still has bootstrap.initdb, attempts an
// upgrade that CNPG admission rejects, and rolls back into a fresh initdb
// cluster that races our purge.
func TestSetCNPGRestoreHRSuspended(t *testing.T) {
const (
ns = "tenant"
name = "postgres-app"
)
mkHR := func(suspended *bool) *unstructured.Unstructured {
hr := &unstructured.Unstructured{}
hr.SetGroupVersionKind(schema.GroupVersionKind{Group: "helm.toolkit.fluxcd.io", Version: "v2", Kind: "HelmRelease"})
hr.SetNamespace(ns)
hr.SetName(name)
spec := map[string]interface{}{}
if suspended != nil {
spec["suspend"] = *suspended
}
_ = unstructured.SetNestedMap(hr.Object, spec, "spec")
return hr
}
suspendedField := func(t *testing.T, dyn dynamic.Interface) bool {
t.Helper()
hr, err := dyn.Resource(helmReleaseGVR).Namespace(ns).Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
t.Fatalf("get HR: %v", err)
}
got, _, _ := unstructured.NestedBool(hr.Object, "spec", "suspend")
return got
}
t.Run("flips spec.suspend false -> true", func(t *testing.T) {
falsePtr := false
dyn := dynamicfake.NewSimpleDynamicClient(testCNPGScheme(t), mkHR(&falsePtr))
r := &RestoreJobReconciler{Interface: dyn}
if err := r.setCNPGRestoreHRSuspended(context.Background(), ns, name, true); err != nil {
t.Fatalf("suspend: %v", err)
}
if !suspendedField(t, dyn) {
t.Fatalf("expected spec.suspend=true after suspend call")
}
})
t.Run("flips spec.suspend true -> false", func(t *testing.T) {
truePtr := true
dyn := dynamicfake.NewSimpleDynamicClient(testCNPGScheme(t), mkHR(&truePtr))
r := &RestoreJobReconciler{Interface: dyn}
if err := r.setCNPGRestoreHRSuspended(context.Background(), ns, name, false); err != nil {
t.Fatalf("resume: %v", err)
}
if suspendedField(t, dyn) {
t.Fatalf("expected spec.suspend=false after resume call")
}
})
t.Run("idempotent when already at desired state", func(t *testing.T) {
// Calling suspend on an already-suspended HR (or resume on an
// already-resumed one) must be a no-op so retries from a
// flapping reconcile loop don't churn HR resourceVersions.
truePtr := true
dyn := dynamicfake.NewSimpleDynamicClient(testCNPGScheme(t), mkHR(&truePtr))
r := &RestoreJobReconciler{Interface: dyn}
if err := r.setCNPGRestoreHRSuspended(context.Background(), ns, name, true); err != nil {
t.Fatalf("idempotent suspend: %v", err)
}
if !suspendedField(t, dyn) {
t.Fatalf("expected spec.suspend to stay true")
}
})
t.Run("missing HR is a no-op", func(t *testing.T) {
// A target Postgres app whose chart-rendered HR has not landed
// yet (or has been deleted) must not fail the destructive flow:
// the controller proceeds with patch+purge and the chart will
// later create a fresh HR from the patched values.
dyn := dynamicfake.NewSimpleDynamicClient(testCNPGScheme(t))
r := &RestoreJobReconciler{Interface: dyn}
if err := r.setCNPGRestoreHRSuspended(context.Background(), ns, name, true); err != nil {
t.Fatalf("missing HR: %v", err)
}
})
}
// TestCNPGClusterFullyGone locks in the wait-for-purge helper that gates
// HR resume until the live Cluster + its PVCs have actually disappeared.
// Returning early on a cluster that is mid-deletion (DeletionTimestamp
// set, finalizers still draining) lets helm race the still-terminating CR
// and we end up back in the "Only one bootstrap" admission-rejection loop.
func TestCNPGClusterFullyGone(t *testing.T) {
const (
ns = "tenant"
name = "postgres-app"
)
now := metav1.Now()
t.Run("cluster + PVCs absent -> gone=true", func(t *testing.T) {
c := newCNPGStrategyTestClient(t)
r := &RestoreJobReconciler{Client: c}
gone, err := r.cnpgClusterFullyGone(context.Background(), ns, name)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gone {
t.Fatalf("expected gone=true with no Cluster and no PVCs")
}
})
t.Run("live cluster -> gone=false", func(t *testing.T) {
cluster := &cnpgtypes.Cluster{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name},
}
c := newCNPGStrategyTestClient(t, cluster)
r := &RestoreJobReconciler{Client: c}
gone, err := r.cnpgClusterFullyGone(context.Background(), ns, name)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gone {
t.Fatalf("expected gone=false when cluster still exists")
}
})
t.Run("terminating cluster (DeletionTimestamp) -> gone=false", func(t *testing.T) {
cluster := &cnpgtypes.Cluster{
ObjectMeta: metav1.ObjectMeta{
Namespace: ns,
Name: name,
DeletionTimestamp: &now,
Finalizers: []string{"cnpg.io/cluster"},
},
}
c := newCNPGStrategyTestClient(t, cluster)
r := &RestoreJobReconciler{Client: c}
gone, err := r.cnpgClusterFullyGone(context.Background(), ns, name)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gone {
t.Fatalf("expected gone=false for terminating cluster")
}
})
t.Run("cluster gone but PVC remains -> gone=false", func(t *testing.T) {
// CNPG's PVCs are deleted alongside the Cluster but storage
// drivers (linstor, ceph) can hold them in a Terminating state
// for tens of seconds. Resuming the HR while a PVC with the
// cluster label still exists lets helm re-render and immediately
// re-attach to a leftover volume.
pvc := &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
Namespace: ns,
Name: name + "-1",
Labels: map[string]string{cnpgClusterLabel: name},
},
}
c := newCNPGStrategyTestClient(t, pvc)
r := &RestoreJobReconciler{Client: c}
gone, err := r.cnpgClusterFullyGone(context.Background(), ns, name)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gone {
t.Fatalf("expected gone=false when a labelled PVC is still around")
}
})
}
// testCNPGScheme returns a runtime.Scheme that knows the unstructured
// HelmRelease GVK used by the dynamic-client tests above.
func testCNPGScheme(t *testing.T) *runtime.Scheme {
t.Helper()
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
s.AddKnownTypeWithName(schema.GroupVersionKind{Group: "helm.toolkit.fluxcd.io", Version: "v2", Kind: "HelmRelease"}, &unstructured.Unstructured{})
s.AddKnownTypeWithName(schema.GroupVersionKind{Group: "helm.toolkit.fluxcd.io", Version: "v2", Kind: "HelmReleaseList"}, &unstructured.UnstructuredList{})
return s
}
// TestApplyClusterBarmanObjectStore_PatchesExistingCluster confirms the
// precondition does not regress the happy path: when the Cluster exists
// the driver still SSA-patches spec.backup.barmanObjectStore.
func TestApplyClusterBarmanObjectStore_PatchesExistingCluster(t *testing.T) {
cluster := &cnpgtypes.Cluster{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "postgres-app"},
}
c := newCNPGStrategyTestClient(t, cluster)
r := &BackupJobReconciler{Client: c}
tmpl := &strategyv1alpha1.CNPGTemplate{
BarmanObjectStore: strategyv1alpha1.BarmanObjectStoreTemplate{
DestinationPath: "s3://bucket/x/",
RetentionPolicy: "30d",
},
}
if err := r.applyClusterBarmanObjectStore(context.Background(), "tenant", "postgres-app", tmpl, "postgres-app"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := &cnpgtypes.Cluster{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(cluster), got); err != nil {
t.Fatalf("get cluster after patch: %v", err)
}
if got.Spec.Backup == nil || got.Spec.Backup.BarmanObjectStore == nil {
t.Fatalf("expected spec.backup.barmanObjectStore to be set, got %+v", got.Spec.Backup)
}
if got.Spec.Backup.BarmanObjectStore.DestinationPath != "s3://bucket/x/" {
t.Errorf("destinationPath: got %q", got.Spec.Backup.BarmanObjectStore.DestinationPath)
}
if got.Spec.Backup.RetentionPolicy != "30d" {
t.Errorf("retentionPolicy: got %q", got.Spec.Backup.RetentionPolicy)
}
}
// TestReconcileCNPG_StartedAtPreservedAcrossStaleCache locks in the bug fix
// for the StartedAt race called out in the branch review: a second reconcile
// with a stale local copy (which observes StartedAt==nil) must NOT advance
// the persisted timestamp the first reconcile already wrote. Without the
// refetch-before-write guard, the wall-clock deadline gate slides forward
// on every poll, defeating the purpose of cnpgDefaultBackupDeadline.
func TestReconcileCNPG_StartedAtPreservedAcrossStaleCache(t *testing.T) {
// metav1.Time loses sub-second precision on JSON round-trip; truncate
// upfront so the equality check below isn't fooled by nanosecond drift.
original := metav1.NewTime(time.Now().Add(-10 * time.Minute).Truncate(time.Second))
stored := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj"},
Status: backupsv1alpha1.BackupJobStatus{
StartedAt: &original,
},
}
c := newCNPGStrategyTestClient(t, stored)
r := &BackupJobReconciler{Client: c}
// Stale local copy as the reconcile would have observed it: StartedAt==nil.
stale := stored.DeepCopy()
stale.Status.StartedAt = nil
// Inline the refetch-before-write block from reconcileCNPG. (The full
// reconciler path requires more wiring than this lightweight test
// builds; the block under test is the bug-fix surface, not the
// surrounding decode/template logic.)
fresh := &backupsv1alpha1.BackupJob{}
if err := r.Get(context.Background(), client.ObjectKeyFromObject(stored), fresh); err != nil {
t.Fatalf("get fresh: %v", err)
}
if fresh.Status.StartedAt != nil {
stale.Status.StartedAt = fresh.Status.StartedAt
} else {
base := fresh.DeepCopy()
now := metav1.Now()
fresh.Status.StartedAt = &now
if err := r.Status().Patch(context.Background(), fresh, client.MergeFrom(base)); err != nil {
t.Fatalf("patch: %v", err)
}
stale.Status.StartedAt = fresh.Status.StartedAt
}
got := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(stored), got); err != nil {
t.Fatalf("get after fix: %v", err)
}
if got.Status.StartedAt == nil || !got.Status.StartedAt.Equal(&original) {
t.Errorf("persisted StartedAt was advanced by stale-cache reconcile; got %v, want %v",
got.Status.StartedAt, original)
}
if stale.Status.StartedAt == nil || !stale.Status.StartedAt.Equal(&original) {
t.Errorf("local copy did not pick up persisted StartedAt; got %v, want %v",
stale.Status.StartedAt, original)
}
}
// TestReconcileCNPG_StartedAtReturnsEarlyToAvoidStaleResourceVersion locks
// in the early-return after the StartedAt patch. Before the fix, reconcileCNPG
// patched StartedAt onto a refetched copy and then continued the same call
// using the local `j` whose ResourceVersion was stale, so any subsequent
// r.Status().Update on `j` (e.g. the markBackupJobFailed path or the
// ClusterNotReady condition write) carried a pre-patch RV and produced a
// Conflict on the very first poll. The fix returns RequeueAfter immediately
// after the StartedAt patch so the next reconcile reads `j` cleanly.
func TestReconcileCNPG_StartedAtReturnsEarlyToAvoidStaleResourceVersion(t *testing.T) {
apiGroup := backupsv1alpha1.DefaultApplicationAPIGroup
strategyGroup := strategyv1alpha1.GroupVersion.Group
stored := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj"},
Spec: backupsv1alpha1.BackupJobSpec{
ApplicationRef: corev1.TypedLocalObjectReference{APIGroup: &apiGroup, Kind: postgresAppKind, Name: "pg"},
},
}
c := newCNPGStrategyTestClient(t, stored)
r := &BackupJobReconciler{Client: c}
j := stored.DeepCopy()
resolved := &ResolvedBackupConfig{
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &strategyGroup,
Kind: strategyv1alpha1.CNPGStrategyKind,
Name: "missing-strategy",
},
}
result, err := r.reconcileCNPG(context.Background(), j, resolved)
if err != nil {
t.Fatalf("reconcileCNPG: unexpected error %v", err)
}
if result.RequeueAfter == 0 {
t.Fatalf("expected RequeueAfter > 0 after StartedAt patch, got %+v", result)
}
persisted := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(stored), persisted); err != nil {
t.Fatalf("get persisted: %v", err)
}
if persisted.Status.StartedAt == nil {
t.Errorf("expected StartedAt persisted, got nil")
}
// Without the early return, reconcileCNPG would have proceeded past the
// StartedAt block, found the strategy missing, and called
// markBackupJobFailed - persisting Phase=Failed (and, depending on RV
// enforcement, returning Conflict). With the fix, Phase stays empty and
// the next reconcile picks up cleanly with the post-patch RV.
if persisted.Status.Phase != "" {
t.Errorf("expected Phase unchanged after StartedAt early return, got %q", persisted.Status.Phase)
}
}
// TestReconcileCNPGRestore_MissingStrategyFailsClosedAfterDeadline covers the
// restore-path blocker: a RestoreJob whose referenced CNPG Strategy CR never
// appears must NOT requeue forever. requeueRestoreStrategyNotReady gates
// terminal-vs-transient on Status.StartedAt, and the "fail closed" guarantee
// only holds if every driver stamps StartedAt before it can reach the Strategy
// NotFound branch. reconcileCNPGRestore stamps StartedAt and returns early on
// the first reconcile (well before the strategy lookup), so the deadline clock
// starts even when the job begins with StartedAt==nil; once the deadline
// elapses the next reconcile flips the RestoreJob to Phase=Failed.
//
// The existing TestRequeueRestoreStrategyNotReady_BoundedByDeadline only
// exercises the helper in isolation with StartedAt pre-populated, so it cannot
// catch a driver that reaches the NotFound branch without ever stamping
// StartedAt. This test proves the guarantee end to end at the driver call site.
func TestReconcileCNPGRestore_MissingStrategyFailsClosedAfterDeadline(t *testing.T) {
apiGroup := backupsv1alpha1.DefaultApplicationAPIGroup
strategyGroup := strategyv1alpha1.GroupVersion.Group
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{APIGroup: &apiGroup, Kind: postgresAppKind, Name: "pg"},
StrategyRef: corev1.TypedLocalObjectReference{
APIGroup: &strategyGroup, Kind: strategyv1alpha1.CNPGStrategyKind, Name: "missing-strategy",
},
},
}
// Status.StartedAt deliberately nil: the driver itself must start the clock.
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{BackupRef: corev1.LocalObjectReference{Name: backup.Name}},
}
// No CNPG Strategy CR seeded - the NotFound branch is the point.
c := newCNPGStrategyTestClient(t, backup, rj)
r := &RestoreJobReconciler{Client: c}
ctx := context.Background()
// Pass 1: StartedAt is nil, so the driver stamps it and requeues. It must
// NOT fail yet - the bootstrap grace window has only just opened.
res, err := r.reconcileCNPGRestore(ctx, rj, backup)
if err != nil {
t.Fatalf("pass 1 reconcileCNPGRestore: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("pass 1 must requeue after stamping StartedAt, got %+v", res)
}
persisted := &backupsv1alpha1.RestoreJob{}
if err := c.Get(ctx, client.ObjectKeyFromObject(rj), persisted); err != nil {
t.Fatalf("get after pass 1: %v", err)
}
if persisted.Status.StartedAt == nil {
t.Fatalf("driver did not stamp StartedAt before the Strategy NotFound branch; " +
"the deadline clock would never start and the RestoreJob would requeue forever")
}
if persisted.Status.Phase == backupsv1alpha1.RestoreJobPhaseFailed {
t.Fatalf("must not fail within the grace window, got Phase=%q", persisted.Status.Phase)
}
// Simulate StrategyNotReadyDeadline of wall-clock elapsing by backdating the
// persisted StartedAt past the deadline.
stale := metav1.NewTime(persisted.Status.StartedAt.Add(-StrategyNotReadyDeadline - time.Minute))
base := persisted.DeepCopy()
persisted.Status.StartedAt = &stale
if err := c.Status().Patch(ctx, persisted, client.MergeFrom(base)); err != nil {
t.Fatalf("backdate StartedAt: %v", err)
}
// Pass 2: StartedAt is now past the deadline and the Strategy CR still does
// not exist, so the driver call site must fail closed.
reloaded := &backupsv1alpha1.RestoreJob{}
if err := c.Get(ctx, client.ObjectKeyFromObject(rj), reloaded); err != nil {
t.Fatalf("get before pass 2: %v", err)
}
res, err = r.reconcileCNPGRestore(ctx, reloaded, backup)
if err != nil {
t.Fatalf("pass 2 reconcileCNPGRestore: %v", err)
}
if res.RequeueAfter != 0 {
t.Fatalf("terminal failure must not requeue, got %+v", res)
}
final := &backupsv1alpha1.RestoreJob{}
if err := c.Get(ctx, client.ObjectKeyFromObject(rj), final); err != nil {
t.Fatalf("get after pass 2: %v", err)
}
if final.Status.Phase != backupsv1alpha1.RestoreJobPhaseFailed {
t.Fatalf("expected Phase=Failed once the deadline elapsed, got %q", final.Status.Phase)
}
if !strings.Contains(final.Status.Message, "missing-strategy") {
t.Errorf("failure message should name the missing strategy, got %q", final.Status.Message)
}
}
// newCNPGStrategyTestClient returns a fake client.Client wired up with
// the schemes the CNPG-strategy reconciler needs.
func newCNPGStrategyTestClient(t *testing.T, objs ...client.Object) client.Client {
t.Helper()
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
_ = backupsv1alpha1.AddToScheme(s)
_ = strategyv1alpha1.AddToScheme(s)
_ = cnpgtypes.AddToScheme(s)
_ = postgresapp.AddToScheme(s)
return clientfake.NewClientBuilder().
WithScheme(s).
WithObjects(objs...).
WithStatusSubresource(&backupsv1alpha1.BackupJob{}, &backupsv1alpha1.RestoreJob{}, &backupsv1alpha1.Backup{}).
Build()
}