Files
cozystack/internal/backupcontroller/etcdstrategy_controller_test.go
Aleksei Sviridkin 520af57905 chore: drop private cluster, host and personal names from the tree
Private infrastructure names, a real hostname, a commented-out
internal mirror block, an internal product name, and a reviewer's
name in a comment do not belong in a public repository; drop them
and replace what needs a stand-in with neutral placeholders.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-10-07 04:38:06 +03:00

1887 lines
77 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package backupcontroller
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
apimeta "k8s.io/apimachinery/pkg/api/meta"
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/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"
"github.com/cozystack/cozystack/internal/backupcontroller/etcdapp"
"github.com/cozystack/cozystack/internal/backupcontroller/etcdtypes"
)
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
func newEtcdTestScheme(t *testing.T) *runtime.Scheme {
t.Helper()
s := runtime.NewScheme()
_ = scheme.AddToScheme(s)
_ = backupsv1alpha1.AddToScheme(s)
_ = strategyv1alpha1.AddToScheme(s)
_ = etcdapp.AddToScheme(s)
_ = etcdtypes.AddToScheme(s)
return s
}
func newEtcdTestClient(t *testing.T, objs ...client.Object) client.Client {
t.Helper()
return clientfake.NewClientBuilder().
WithScheme(newEtcdTestScheme(t)).
WithObjects(objs...).
WithStatusSubresource(
&backupsv1alpha1.BackupJob{},
&backupsv1alpha1.RestoreJob{},
&backupsv1alpha1.Backup{},
&etcdtypes.EtcdCluster{},
&etcdtypes.EtcdSnapshot{},
).
Build()
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
func TestValidateEtcdApplicationRef(t *testing.T) {
apps := etcdapp.GroupName
other := "other.example.com"
cases := []struct {
name string
ref corev1.TypedLocalObjectReference
wantErr bool
}{
{"happy path with apps group", corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "x", APIGroup: &apps}, false},
{"empty apiGroup accepted", corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "x"}, false},
{"foreign apiGroup rejected", corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "x", APIGroup: &other}, true},
{"wrong kind rejected", corev1.TypedLocalObjectReference{Kind: "Postgres", Name: "x", APIGroup: &apps}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateEtcdApplicationRef(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 TestValidateRenderedEtcdDestination(t *testing.T) {
good := strategyv1alpha1.EtcdDestinationTemplate{
S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "b",
Endpoint: "https://e",
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
},
}
if err := validateRenderedEtcdDestination(good); err != nil {
t.Fatalf("good S3: %v", err)
}
cases := []struct {
name string
in strategyv1alpha1.EtcdDestinationTemplate
}{
// Empty destination - rejected because S3 is required (PVC is
// rejected at the CRD admission layer; this test only exercises
// the runtime defensive check for clients that bypass admission).
{"empty rejected", strategyv1alpha1.EtcdDestinationTemplate{}},
{"missing bucket", strategyv1alpha1.EtcdDestinationTemplate{S3: &strategyv1alpha1.EtcdS3Template{
Endpoint: "https://e",
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
}}},
{"missing endpoint", strategyv1alpha1.EtcdDestinationTemplate{S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "b",
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
}}},
{"missing credsRef name", strategyv1alpha1.EtcdDestinationTemplate{S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "b", Endpoint: "https://e",
}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := validateRenderedEtcdDestination(tc.in); err == nil {
t.Fatalf("expected error")
}
})
}
}
// TestEtcdDestinationTemplate_NoPVCField pins the strategy CR API
// contract: PVC destinations are NOT exposed by this strategy. The
// upstream etcd-operator's PVC backup-write path
// (/backup/data/<subPath>/<backupName>.db with BACKUP_INCLUDE_REVISION
// / BACKUP_TIMESTAMP suffixes) and PVC restore-read path
// (/backup/data/<subPath> opened as a file, or /backup/data/snapshot.db
// when subPath is empty) disagree on the filename - a PVC backup taken
// via this strategy would be unrestoreable.
//
// Until upstream lands symmetric semantics (or surfaces the final
// on-disk path via the equivalent of BackupSnapshot.URI for PVC), the
// strategy CR rejects PVC at the CRD admission layer (XValidation
// rule "has(self.s3)") AND removes the Go-level field entirely so a
// future regression that re-adds it without fixing upstream fails
// this test instead of producing silently-unrestoreable backups.
//
// See review feedback in github.com/cozystack/cozystack PR #2641,
// review #4327696458, blocker #1.
func TestEtcdDestinationTemplate_NoPVCField(t *testing.T) {
typ := reflect.TypeFor[strategyv1alpha1.EtcdDestinationTemplate]()
if _, ok := typ.FieldByName("PVC"); ok {
t.Fatal("EtcdDestinationTemplate must not expose a PVC field; " +
"upstream PVC restore is broken (asymmetric path between " +
"backup-write and restore-read). Re-add only after upstream " +
"makes the paths symmetric; see PR #2641 review #4327696458.")
}
// Defense-in-depth: same for the snapshot persistence struct.
// snap.Destination embeds EtcdDestinationTemplate, so a re-added
// PVC field would also leak through Backup.status.underlyingResources.
snapType := reflect.TypeFor[etcdBackupSnapshot]()
destField, ok := snapType.FieldByName("Destination")
if !ok {
t.Fatal("etcdBackupSnapshot must have a Destination field")
}
if _, has := destField.Type.FieldByName("PVC"); has {
t.Fatal("etcdBackupSnapshot.Destination must not expose a PVC field")
}
}
// ---------------------------------------------------------------------------
// Templating
// ---------------------------------------------------------------------------
func TestRenderEtcdTemplate_AppName(t *testing.T) {
// The rendered template walks every string field and substitutes Go
// templates against .Application / .Parameters. Pin the canonical
// pattern (per-app credentials Secret name keyed off the app name)
// so a future refactor that drops templating from CredentialsSecretRef
// fails this test.
app := &etcdapp.Etcd{ObjectMeta: metav1.ObjectMeta{Name: "etcd-src", Namespace: "tenant-root"}}
in := strategyv1alpha1.EtcdTemplate{
Destination: strategyv1alpha1.EtcdDestinationTemplate{
S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "{{ .Parameters.bucket }}",
Endpoint: "https://s3.example.invalid",
Key: "{{ .Application.metadata.name }}/",
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{
Name: "{{ .Application.metadata.name }}-etcd-backup-creds",
},
},
},
}
rendered, err := renderEtcdTemplate(in, app, map[string]string{"bucket": "etcd-backups"})
if err != nil {
t.Fatalf("render: %v", err)
}
if rendered.Destination.S3.Bucket != "etcd-backups" {
t.Errorf("bucket: got %q want etcd-backups", rendered.Destination.S3.Bucket)
}
if rendered.Destination.S3.Key != "etcd-src/" {
t.Errorf("key: got %q want etcd-src/", rendered.Destination.S3.Key)
}
if rendered.Destination.S3.CredentialsSecretRef.Name != "etcd-src-etcd-backup-creds" {
t.Errorf("credentialsSecretRef.Name: got %q want etcd-src-etcd-backup-creds",
rendered.Destination.S3.CredentialsSecretRef.Name)
}
}
// ---------------------------------------------------------------------------
// strategyToEtcdBackupDestination shape-cast
// ---------------------------------------------------------------------------
func TestStrategyToEtcdBackupDestination_S3(t *testing.T) {
in := strategyv1alpha1.EtcdDestinationTemplate{
S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "b",
Endpoint: "https://e",
Key: "etcd-src/",
Region: "us-east-1",
ForcePathStyle: new(true),
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
},
}
out, err := strategyToEtcdBackupDestination(in)
if err != nil {
t.Fatalf("convert: %v", err)
}
if out.S3 == nil || out.PVC != nil {
t.Fatalf("expected S3 set, PVC nil; got s3=%v pvc=%v", out.S3, out.PVC)
}
if out.S3.Bucket != "b" || out.S3.Endpoint != "https://e" || out.S3.Key != "etcd-src/" ||
out.S3.Region != "us-east-1" || out.S3.CredentialsSecretRef.Name != "creds" {
t.Errorf("shape cast: %+v", out.S3)
}
if out.S3.ForcePathStyle == nil || !*out.S3.ForcePathStyle {
t.Errorf("force path style not propagated: %v", out.S3.ForcePathStyle)
}
// Pin pointer-independence so a future refactor that aliases the
// strategy pointer into the operator type doesn't silently couple
// downstream mutations back into the strategy CR cache.
*in.S3.ForcePathStyle = false
if !*out.S3.ForcePathStyle {
t.Errorf("output should be independent of input pointer")
}
}
// ---------------------------------------------------------------------------
// Snapshot encode/decode contract
// ---------------------------------------------------------------------------
func TestEtcdBackupSnapshot_RoundTrip(t *testing.T) {
rendered := &strategyv1alpha1.EtcdTemplate{
Destination: strategyv1alpha1.EtcdDestinationTemplate{
S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "b",
Endpoint: "https://e",
Key: "etcd-src/",
ForcePathStyle: new(true),
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
},
},
}
raw, err := marshalEtcdBackupSnapshot(rendered, map[string]string{"bucket": "etcd-backups"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
snap, err := decodeEtcdBackupSnapshot(raw)
if err != nil {
t.Fatalf("decode: %v", err)
}
if snap == nil || snap.Destination.S3 == nil || snap.Destination.S3.Bucket != "b" {
t.Fatalf("round-trip lost data: %+v", snap)
}
if snap.Parameters["bucket"] != "etcd-backups" {
t.Errorf("parameters not persisted: %v", snap.Parameters)
}
}
func TestEtcdBackupSnapshot_UnrecognisedKindIsTerminal(t *testing.T) {
bad := map[string]any{
"kind": "SomethingElse",
"apiVersion": etcdBackupSnapshotAPIVersion,
"destination": map[string]any{"s3": map[string]any{"bucket": "b", "endpoint": "e", "credentialsSecretRef": map[string]any{"name": "c"}}},
}
raw, _ := json.Marshal(bad)
_, err := decodeEtcdBackupSnapshot(&runtime.RawExtension{Raw: raw})
if !errors.Is(err, errEtcdSnapshotUnrecognised) {
t.Fatalf("expected errEtcdSnapshotUnrecognised, got %v", err)
}
}
func TestEtcdBackupSnapshot_MalformedJSONIsRecoverable(t *testing.T) {
_, err := decodeEtcdBackupSnapshot(&runtime.RawExtension{Raw: []byte("{not json")})
if err == nil || errors.Is(err, errEtcdSnapshotUnrecognised) {
t.Fatalf("expected recoverable decode error, got %v", err)
}
}
// ---------------------------------------------------------------------------
// resolveEtcdRestoreDestination: snapshot wins, driverMetadata falls back
// ---------------------------------------------------------------------------
func TestResolveEtcdRestoreDestination_PrefersSnapshot(t *testing.T) {
snap := &etcdBackupSnapshot{
Destination: strategyv1alpha1.EtcdDestinationTemplate{
S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "from-snap",
Endpoint: "https://snap",
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds-snap"},
},
},
}
backup := &backupsv1alpha1.Backup{Spec: backupsv1alpha1.BackupSpec{DriverMetadata: map[string]string{
etcdBackupBucketKey: "from-md",
etcdBackupEndpointKey: "https://md",
etcdBackupCredsSecretKey: "creds-md",
}}}
d, ok := resolveEtcdRestoreDestination(backup, snap)
if !ok || d.S3 == nil {
t.Fatalf("expected S3 destination, got %+v ok=%v", d, ok)
}
if d.S3.Bucket != "from-snap" || d.S3.CredentialsSecretRef.Name != "creds-snap" {
t.Errorf("snapshot should win; got %+v", d.S3)
}
}
func TestResolveEtcdRestoreDestination_FallsBackToDriverMetadata(t *testing.T) {
backup := &backupsv1alpha1.Backup{Spec: backupsv1alpha1.BackupSpec{DriverMetadata: map[string]string{
etcdBackupBucketKey: "from-md",
etcdBackupEndpointKey: "https://md",
etcdBackupCredsSecretKey: "creds-md",
etcdBackupForcePathStyleKey: "true",
}}}
d, ok := resolveEtcdRestoreDestination(backup, nil)
if !ok || d.S3 == nil {
t.Fatalf("expected S3 destination, got %+v ok=%v", d, ok)
}
if d.S3.Bucket != "from-md" || d.S3.CredentialsSecretRef.Name != "creds-md" {
t.Errorf("driverMetadata fallback incorrect: %+v", d.S3)
}
if d.S3.ForcePathStyle == nil || !*d.S3.ForcePathStyle {
t.Errorf("ForcePathStyle should parse as true: %v", d.S3.ForcePathStyle)
}
}
func TestResolveEtcdRestoreDestination_MissingEverythingFails(t *testing.T) {
if _, ok := resolveEtcdRestoreDestination(&backupsv1alpha1.Backup{}, nil); ok {
t.Fatalf("expected !ok for empty backup")
}
}
// TestResolveEtcdRestoreDestination_ForcePathStyleNilRoundTrip pins
// the round-trip invariant for the optional ForcePathStyle field:
// when a backup is taken with ForcePathStyle unset (nil), the restore
// destination must ALSO be nil — neither path should silently default
// it to true (matching the chart's legacy backup.forcePathStyle=true)
// nor to false. This catches a future refactor where the backup-side
// driverMetadata encoding flips to write "false" unconditionally, or
// the restore-side decoder defaults missing keys to true.
//
// Exercises both resolution paths (snapshot + driverMetadata fallback).
func TestResolveEtcdRestoreDestination_ForcePathStyleNilRoundTrip(t *testing.T) {
t.Run("snapshot path: nil stays nil", func(t *testing.T) {
snap := &etcdBackupSnapshot{
Destination: strategyv1alpha1.EtcdDestinationTemplate{
S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "b", Endpoint: "https://e",
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
// ForcePathStyle deliberately unset (nil).
},
},
}
backup := &backupsv1alpha1.Backup{Spec: backupsv1alpha1.BackupSpec{
DriverMetadata: map[string]string{etcdBackupNameKey: "bk"},
}}
d, ok := resolveEtcdRestoreDestination(backup, snap)
if !ok || d.S3 == nil {
t.Fatalf("expected S3 destination, got %+v ok=%v", d, ok)
}
if d.S3.ForcePathStyle != nil {
t.Errorf("ForcePathStyle must round-trip as nil; got %v", *d.S3.ForcePathStyle)
}
})
t.Run("driverMetadata path: absent key stays nil", func(t *testing.T) {
// No etcdBackupForcePathStyleKey in DriverMetadata - this is
// exactly what the BackupJob path writes when
// s.ForcePathStyle == nil (see createEtcdBackupArtifact).
backup := &backupsv1alpha1.Backup{Spec: backupsv1alpha1.BackupSpec{DriverMetadata: map[string]string{
etcdBackupBucketKey: "from-md",
etcdBackupEndpointKey: "https://md",
etcdBackupCredsSecretKey: "creds-md",
// etcdBackupForcePathStyleKey deliberately absent.
}}}
d, ok := resolveEtcdRestoreDestination(backup, nil)
if !ok || d.S3 == nil {
t.Fatalf("expected S3 destination, got %+v ok=%v", d, ok)
}
if d.S3.ForcePathStyle != nil {
t.Errorf("ForcePathStyle must round-trip as nil when driverMetadata key is absent; got %v", *d.S3.ForcePathStyle)
}
})
}
// TestResolveEtcdRestoreDestination_FullS3KeyForRestore pins the operator
// filename convention. The backup-side persists the strategy-rendered
// prefix (e.g. "etcd/") because internal/controller/factory/backup_job.go
// appends "<backupName>.db" when writing. The restore-agent reads
// S3_KEY verbatim, so the driver must rebuild "<prefix>/<backupName>.db"
// at restore-resolve time. A regression here surfaces in production as
// "downloaded snapshot is empty (0 bytes)" - the agent fetches the
// directory marker for the prefix.
func TestResolveEtcdRestoreDestination_FullS3KeyForRestore(t *testing.T) {
t.Run("snapshot prefix gets backup-name appended", func(t *testing.T) {
snap := &etcdBackupSnapshot{
Destination: strategyv1alpha1.EtcdDestinationTemplate{
S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "b",
Endpoint: "https://e",
Key: "etcd/",
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
},
},
}
backup := &backupsv1alpha1.Backup{Spec: backupsv1alpha1.BackupSpec{
DriverMetadata: map[string]string{etcdBackupNameKey: "etcd-bk-abc123"},
}}
d, ok := resolveEtcdRestoreDestination(backup, snap)
if !ok || d.S3 == nil {
t.Fatalf("expected S3 destination, got %+v ok=%v", d, ok)
}
if d.S3.Key != "etcd/etcd-bk-abc123.db" {
t.Errorf("S3 restore key: got %q want %q", d.S3.Key, "etcd/etcd-bk-abc123.db")
}
})
t.Run("driverMetadata fallback also gets the filename", func(t *testing.T) {
backup := &backupsv1alpha1.Backup{Spec: backupsv1alpha1.BackupSpec{DriverMetadata: map[string]string{
etcdBackupBucketKey: "b",
etcdBackupEndpointKey: "https://e",
etcdBackupKeyKey: "etcd/",
etcdBackupCredsSecretKey: "creds",
etcdBackupNameKey: "etcd-bk-def456",
}}}
d, ok := resolveEtcdRestoreDestination(backup, nil)
if !ok || d.S3 == nil {
t.Fatalf("expected S3 destination, got %+v ok=%v", d, ok)
}
if d.S3.Key != "etcd/etcd-bk-def456.db" {
t.Errorf("S3 restore key: got %q want %q", d.S3.Key, "etcd/etcd-bk-def456.db")
}
})
}
func TestBuildEtcdRestoreS3Key(t *testing.T) {
cases := []struct {
prefix string
name string
want string
}{
{"", "bk", "bk.db"},
{"etcd/", "bk", "etcd/bk.db"},
{"etcd", "bk", "etcd/bk.db"},
{"etcd/backups/", "bk", "etcd/backups/bk.db"},
{"etcd/", "", "etcd/"}, // empty backup-name → prefix verbatim (legacy artefact)
}
for _, tc := range cases {
if got := buildEtcdRestoreS3Key(tc.prefix, tc.name); got != tc.want {
t.Errorf("buildEtcdRestoreS3Key(%q, %q): got %q want %q", tc.prefix, tc.name, got, tc.want)
}
}
}
// TestS3KeyFromArtifactURI pins the parser that pulls the FULL S3 key
// from the operator-reported artifact URI. The driver prefers this
// over buildEtcdRestoreS3Key because BACKUP_INCLUDE_REVISION /
// BACKUP_TIMESTAMP env vars (set in operator v0.4.4 defaults) make
// the agent append "-rev<N>" / "-<timestamp>" to the filename at
// write time — values buildEtcdRestoreS3Key can NEVER reconstruct
// from spec alone. Without this path the restore-agent 404s on the
// suffix-less key the driver would otherwise emit.
func TestS3KeyFromArtifactURI(t *testing.T) {
cases := []struct {
name string
artifact *backupsv1alpha1.BackupArtifact
bucket string
wantKey string
wantOK bool
}{
{
name: "agent-emitted -rev2 suffix round-trips verbatim",
artifact: &backupsv1alpha1.BackupArtifact{URI: "s3://bucket-abc/etcd/etcd-backup-job-xyz-rev2.db"},
bucket: "bucket-abc",
wantKey: "etcd/etcd-backup-job-xyz-rev2.db",
wantOK: true,
},
{
name: "agent-emitted timestamp suffix round-trips",
artifact: &backupsv1alpha1.BackupArtifact{URI: "s3://bucket-abc/etcd/bk-20260519T164740Z.db"},
bucket: "bucket-abc",
wantKey: "etcd/bk-20260519T164740Z.db",
wantOK: true,
},
{
name: "no prefix, root-level object",
artifact: &backupsv1alpha1.BackupArtifact{URI: "s3://bucket-abc/bk-rev1.db"},
bucket: "bucket-abc",
wantKey: "bk-rev1.db",
wantOK: true,
},
{
name: "nil artifact → no",
artifact: nil,
bucket: "bucket-abc",
wantOK: false,
},
{
name: "empty URI → no",
artifact: &backupsv1alpha1.BackupArtifact{URI: ""},
bucket: "bucket-abc",
wantOK: false,
},
{
name: "non-s3 scheme (PVC file://) → no",
artifact: &backupsv1alpha1.BackupArtifact{URI: "file:///backup/data/bk.db"},
bucket: "bucket-abc",
wantOK: false,
},
{
name: "URI with different bucket → no (safety belt)",
artifact: &backupsv1alpha1.BackupArtifact{URI: "s3://other-bucket/etcd/bk.db"},
bucket: "bucket-abc",
wantOK: false,
},
{
name: "URI with no key, trailing slash → no",
artifact: &backupsv1alpha1.BackupArtifact{URI: "s3://bucket-abc/"},
bucket: "bucket-abc",
wantOK: false,
},
{
name: "URI with no slash after bucket → no",
artifact: &backupsv1alpha1.BackupArtifact{URI: "s3://bucket-abc"},
bucket: "bucket-abc",
wantOK: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := s3KeyFromArtifactURI(tc.artifact, tc.bucket)
if ok != tc.wantOK {
t.Fatalf("ok=%v want %v (got=%q)", ok, tc.wantOK, got)
}
if ok && got != tc.wantKey {
t.Errorf("key=%q want %q", got, tc.wantKey)
}
})
}
}
// TestResolveEtcdRestoreDestination_PrefersArtifactURIForRevSuffix is
// the integration test for the bug found on a test cluster: with a v0.4.4
// operator (BACKUP_INCLUDE_REVISION=true), the agent wrote
// "etcd/etcd-backup-job-6gghq-rev2.db" but the driver's
// buildEtcdRestoreS3Key would emit "etcd/etcd-backup-job-6gghq.db" —
// the restore-agent 404s. The fix: prefer Backup.status.artifact.uri.
// This test fails on the pre-fix code and passes after.
func TestResolveEtcdRestoreDestination_PrefersArtifactURIForRevSuffix(t *testing.T) {
snap := &etcdBackupSnapshot{
Destination: strategyv1alpha1.EtcdDestinationTemplate{
S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "bucket-37bfe98a",
Endpoint: "https://s3.example",
Key: "etcd/", // suffix-less prefix that buildEtcdRestoreS3Key would round-trip
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
},
},
}
backup := &backupsv1alpha1.Backup{
Spec: backupsv1alpha1.BackupSpec{
DriverMetadata: map[string]string{etcdBackupNameKey: "etcd-backup-job-6gghq"},
},
Status: backupsv1alpha1.BackupStatus{
// The operator-reported URI with the BACKUP_INCLUDE_REVISION suffix.
Artifact: &backupsv1alpha1.BackupArtifact{
URI: "s3://bucket-37bfe98a/etcd/etcd-backup-job-6gghq-rev2.db",
SizeBytes: 20512,
Checksum: "sha256:abc123",
},
},
}
d, ok := resolveEtcdRestoreDestination(backup, snap)
if !ok || d.S3 == nil {
t.Fatalf("expected S3 destination, got %+v ok=%v", d, ok)
}
if d.S3.Key != "etcd/etcd-backup-job-6gghq-rev2.db" {
t.Errorf("expected agent-emitted URI to win over buildEtcdRestoreS3Key reconstruction; got key=%q", d.S3.Key)
}
}
// TestResolveEtcdRestoreDestination_FallsBackWhenArtifactBucketMismatches
// pins the safety belt: if a future agent or a corrupted Backup
// artifact reports a URI for a different bucket than the
// destination's, the driver MUST fall back to reconstruction rather
// than divert the restore to a wrong bucket. The bucket-match check
// in s3KeyFromArtifactURI is the load-bearing guard.
func TestResolveEtcdRestoreDestination_FallsBackWhenArtifactBucketMismatches(t *testing.T) {
snap := &etcdBackupSnapshot{
Destination: strategyv1alpha1.EtcdDestinationTemplate{
S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "bucket-correct",
Endpoint: "https://s3.example",
Key: "etcd/",
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
},
},
}
backup := &backupsv1alpha1.Backup{
Spec: backupsv1alpha1.BackupSpec{
DriverMetadata: map[string]string{etcdBackupNameKey: "etcd-bk-abc"},
},
Status: backupsv1alpha1.BackupStatus{
Artifact: &backupsv1alpha1.BackupArtifact{
URI: "s3://wrong-bucket/etcd/etcd-bk-abc-rev1.db",
},
},
}
d, ok := resolveEtcdRestoreDestination(backup, snap)
if !ok || d.S3 == nil {
t.Fatalf("expected S3 destination, got %+v ok=%v", d, ok)
}
if d.S3.Bucket != "bucket-correct" {
t.Errorf("destination bucket must come from snapshot, not artifact: got %q", d.S3.Bucket)
}
if d.S3.Key != "etcd/etcd-bk-abc.db" {
t.Errorf("expected fallback to buildEtcdRestoreS3Key on bucket mismatch; got key=%q", d.S3.Key)
}
}
// ---------------------------------------------------------------------------
// ensureEtcdBackup idempotency: same labels => reuse, not duplicate
// ---------------------------------------------------------------------------
func TestEnsureEtcdBackup_LabelIdempotency(t *testing.T) {
bj := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "bj-1"},
}
c := newEtcdTestClient(t, bj)
r := &BackupJobReconciler{Client: c, Scheme: newEtcdTestScheme(t)}
rendered := &strategyv1alpha1.EtcdTemplate{
Destination: strategyv1alpha1.EtcdDestinationTemplate{
S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "b",
Endpoint: "https://e",
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
},
},
}
first, err := r.ensureEtcdSnapshot(context.Background(), bj, rendered)
if err != nil {
t.Fatalf("first ensure: %v", err)
}
second, err := r.ensureEtcdSnapshot(context.Background(), bj, rendered)
if err != nil {
t.Fatalf("second ensure: %v", err)
}
if first.Name != second.Name {
t.Errorf("expected idempotent reuse; got %q vs %q", first.Name, second.Name)
}
// And there's exactly one EtcdBackup in the namespace.
list := &etcdtypes.EtcdSnapshotList{}
if err := c.List(context.Background(), list, client.InNamespace(bj.Namespace)); err != nil {
t.Fatalf("list: %v", err)
}
if len(list.Items) != 1 {
t.Errorf("expected 1 EtcdBackup, got %d", len(list.Items))
}
}
func TestEnsureEtcdBackup_NoOwnerReference(t *testing.T) {
// Same contract as the FoundationDB driver: per-BackupJob CRs must
// NOT carry an OwnerReference back to the BackupJob, because a
// kubectl delete && kubectl apply with the same BackupJob name has
// to reuse the prior operator CR via OwningJob labels. An owner ref
// would let Kubernetes GC reap the operator CR on parent deletion
// and defeat the reuse contract.
bj := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "bj-1", UID: "abc"},
}
c := newEtcdTestClient(t, bj)
r := &BackupJobReconciler{Client: c, Scheme: newEtcdTestScheme(t)}
rendered := &strategyv1alpha1.EtcdTemplate{
Destination: strategyv1alpha1.EtcdDestinationTemplate{
S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "b",
Endpoint: "https://e",
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
},
},
}
eb, err := r.ensureEtcdSnapshot(context.Background(), bj, rendered)
if err != nil {
t.Fatalf("ensure: %v", err)
}
if len(eb.OwnerReferences) != 0 {
t.Errorf("EtcdBackup must not carry an OwnerReference; got %+v", eb.OwnerReferences)
}
}
// ---------------------------------------------------------------------------
// createEtcdBackupArtifact: pass-through from EtcdBackup.status.snapshot
// ---------------------------------------------------------------------------
// TestCreateEtcdBackupArtifact_PassesSnapshotThrough pins the contract
// between this driver and the upstream etcd-operator change that
// introduced EtcdBackup.status.snapshot (URI / SizeBytes / Checksum).
// The cozystack Backup.status.artifact must mirror those fields, NOT
// synthesise a URI from the spec destination (the rendered destination
// is the prefix, not the final key — the agent rewrites the suffix at
// write time, see the upstream BackupSnapshot godoc).
func TestCreateEtcdBackupArtifact_PassesSnapshotThrough(t *testing.T) {
apps := etcdapp.GroupName
bj := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "bj-1"},
Spec: backupsv1alpha1.BackupJobSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd", APIGroup: &apps},
},
}
c := newEtcdTestClient(t, bj)
r := &BackupJobReconciler{Client: c, Scheme: newEtcdTestScheme(t)}
eb := &etcdtypes.EtcdSnapshot{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "bj-1-abcde"},
Status: etcdtypes.EtcdSnapshotStatus{
Phase: etcdtypes.BackupPhaseComplete,
Artifact: &etcdtypes.EtcdSnapshotArtifact{
URI: "s3://my-bucket/etcd/bj-1-abcde-rev42.db",
SizeBytes: 20512,
Checksum: "sha256:abcd1234",
},
},
}
rendered := &strategyv1alpha1.EtcdTemplate{
Destination: strategyv1alpha1.EtcdDestinationTemplate{
S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "my-bucket",
Endpoint: "https://s3.example",
Key: "etcd/",
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
},
},
}
resolved := &ResolvedBackupConfig{StrategyRef: corev1.TypedLocalObjectReference{
Kind: "Etcd", Name: "etcd-strategy-default",
}}
backup, err := r.createEtcdBackupArtifact(context.Background(), bj, resolved, eb, rendered)
if err != nil {
t.Fatalf("createEtcdBackupArtifact: %v", err)
}
if backup.Status.Artifact == nil {
t.Fatal("status.artifact: got nil, want pass-through")
}
if backup.Status.Artifact.URI != eb.Status.Artifact.URI {
t.Errorf("artifact.URI: got %q want %q", backup.Status.Artifact.URI, eb.Status.Artifact.URI)
}
if backup.Status.Artifact.SizeBytes != eb.Status.Artifact.SizeBytes {
t.Errorf("artifact.SizeBytes: got %d want %d", backup.Status.Artifact.SizeBytes, eb.Status.Artifact.SizeBytes)
}
if backup.Status.Artifact.Checksum != eb.Status.Artifact.Checksum {
t.Errorf("artifact.Checksum: got %q want %q", backup.Status.Artifact.Checksum, eb.Status.Artifact.Checksum)
}
}
// TestCreateEtcdBackupArtifact_NoSnapshotLeavesArtifactNil pins the
// older-operator fallback path: when EtcdBackup.status.snapshot is nil
// (operator pre-dates the upstream feature), the driver must leave
// Backup.status.artifact unset. Synthesising a URI from spec.destination
// would point at the prefix, not the actual object the agent wrote —
// strictly worse than nothing.
func TestCreateEtcdBackupArtifact_NoSnapshotLeavesArtifactNil(t *testing.T) {
apps := etcdapp.GroupName
bj := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "bj-2"},
Spec: backupsv1alpha1.BackupJobSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd", APIGroup: &apps},
},
}
c := newEtcdTestClient(t, bj)
r := &BackupJobReconciler{Client: c, Scheme: newEtcdTestScheme(t)}
eb := &etcdtypes.EtcdSnapshot{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "bj-2-xyz"},
Status: etcdtypes.EtcdSnapshotStatus{
Phase: etcdtypes.BackupPhaseComplete,
// Snapshot is nil — older operator version.
},
}
rendered := &strategyv1alpha1.EtcdTemplate{
Destination: strategyv1alpha1.EtcdDestinationTemplate{
S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "b",
Endpoint: "https://e",
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
},
},
}
resolved := &ResolvedBackupConfig{StrategyRef: corev1.TypedLocalObjectReference{
Kind: "Etcd", Name: "etcd-strategy-default",
}}
backup, err := r.createEtcdBackupArtifact(context.Background(), bj, resolved, eb, rendered)
if err != nil {
t.Fatalf("createEtcdBackupArtifact: %v", err)
}
if backup.Status.Artifact != nil {
t.Errorf("status.artifact: got %+v, want nil (older operator without snapshot field)", backup.Status.Artifact)
}
}
// ---------------------------------------------------------------------------
// RestoreJob: to-copy rejection
// ---------------------------------------------------------------------------
func TestReconcileEtcdRestore_RejectsToCopy(t *testing.T) {
apps := etcdapp.GroupName
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd", APIGroup: &apps},
},
}
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{
BackupRef: corev1.LocalObjectReference{Name: "bk"},
TargetApplicationRef: &corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd-dst", APIGroup: &apps},
},
}
c := newEtcdTestClient(t, backup, rj)
r := &RestoreJobReconciler{Client: c, Scheme: newEtcdTestScheme(t), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileEtcdRestore(context.Background(), rj, backup); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &backupsv1alpha1.RestoreJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(rj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase != backupsv1alpha1.RestoreJobPhaseFailed {
t.Errorf("expected Failed, got %q", got.Status.Phase)
}
if !strings.Contains(got.Status.Message, "to-copy") {
t.Errorf("message should explain the limitation; got %q", got.Status.Message)
}
}
// TestReconcileEtcdRestore_RejectsForeignTargetAPIGroup pins the
// defence-in-depth gate on the TARGET ref's APIGroup. Without this
// gate a tenant could craft a RestoreJob with
// {Kind: "Etcd", Name: <source>, APIGroup: "other.example.com"}:
// the Name+Kind checks accept (name matches the Backup's source,
// Kind matches "Etcd"), resolveEtcdRestoreTarget silently drops the
// APIGroup, and the restore runs against the Cozystack Etcd app
// instead of whatever foreign CRD the tenant intended. Mirrors the
// source-side validateEtcdApplicationRef check.
func TestReconcileEtcdRestore_RejectsForeignTargetAPIGroup(t *testing.T) {
apps := etcdapp.GroupName
other := "other.example.com"
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd", APIGroup: &apps},
},
}
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{
BackupRef: corev1.LocalObjectReference{Name: "bk"},
// Same name as the Backup's source so Name gate accepts;
// same Kind so Kind gate accepts; only APIGroup differs.
TargetApplicationRef: &corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd", APIGroup: &other},
},
}
c := newEtcdTestClient(t, backup, rj)
r := &RestoreJobReconciler{Client: c, Scheme: newEtcdTestScheme(t), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileEtcdRestore(context.Background(), rj, backup); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &backupsv1alpha1.RestoreJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(rj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase != backupsv1alpha1.RestoreJobPhaseFailed {
t.Errorf("expected Failed phase on foreign target APIGroup; got %q (msg=%q)", got.Status.Phase, got.Status.Message)
}
if !strings.Contains(got.Status.Message, other) {
t.Errorf("message should name the rejected APIGroup; got %q", got.Status.Message)
}
}
// ---------------------------------------------------------------------------
// RestoreJob options parsing
// ---------------------------------------------------------------------------
func TestParseEtcdRestoreOptions(t *testing.T) {
t.Run("empty options falls back to default deadline", func(t *testing.T) {
o, err := parseEtcdRestoreOptions(nil)
if err != nil {
t.Fatalf("parse: %v", err)
}
if got := o.effectiveRestoreDeadline(); got != etcdDefaultRestoreDeadline {
t.Errorf("default deadline: got %v want %v", got, etcdDefaultRestoreDeadline)
}
})
t.Run("malformed JSON is terminal", func(t *testing.T) {
_, err := parseEtcdRestoreOptions(&runtime.RawExtension{Raw: []byte("{nope")})
if err == nil {
t.Fatalf("expected parse error")
}
})
t.Run("override propagates", func(t *testing.T) {
o, err := parseEtcdRestoreOptions(&runtime.RawExtension{Raw: []byte(`{"restoreTimeoutSeconds":120}`)})
if err != nil {
t.Fatalf("parse: %v", err)
}
if d := o.effectiveRestoreDeadline(); d.Seconds() != 120 {
t.Errorf("deadline override: got %v want 120s", d)
}
})
}
// ---------------------------------------------------------------------------
// etcdClusterReady gates on the operator-side Ready condition
// ---------------------------------------------------------------------------
func TestEtcdClusterReady(t *testing.T) {
t.Run("nil cluster is not ready", func(t *testing.T) {
if etcdClusterReady(nil) {
t.Error("nil ready?")
}
})
t.Run("no conditions is not ready", func(t *testing.T) {
c := &etcdtypes.EtcdCluster{}
if etcdClusterReady(c) {
t.Error("no-conditions ready?")
}
})
t.Run("Ready=False is not ready", func(t *testing.T) {
c := &etcdtypes.EtcdCluster{}
apimeta.SetStatusCondition(&c.Status.Conditions, metav1.Condition{
Type: etcdtypes.ClusterConditionAvailable, Status: metav1.ConditionFalse, Reason: "Bootstrapping",
})
if etcdClusterReady(c) {
t.Error("Ready=False reported ready")
}
})
t.Run("Ready=True is ready", func(t *testing.T) {
c := &etcdtypes.EtcdCluster{}
apimeta.SetStatusCondition(&c.Status.Conditions, metav1.Condition{
Type: etcdtypes.ClusterConditionAvailable, Status: metav1.ConditionTrue, Reason: "Ok",
})
if !etcdClusterReady(c) {
t.Error("Ready=True not reported ready")
}
})
}
// ---------------------------------------------------------------------------
// BackupJob terminal paths
// ---------------------------------------------------------------------------
func TestReconcileEtcd_RejectsWrongKind(t *testing.T) {
apps := etcdapp.GroupName
bj := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "bj"},
Spec: backupsv1alpha1.BackupJobSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "Postgres", Name: "x", APIGroup: &apps},
BackupClassName: "etcd-default",
},
}
c := newEtcdTestClient(t, bj)
r := &BackupJobReconciler{Client: c, Scheme: newEtcdTestScheme(t), Recorder: record.NewFakeRecorder(10)}
resolved := &ResolvedBackupConfig{
StrategyRef: corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd-strategy-default", APIGroup: &apps},
}
if _, err := r.reconcileEtcd(context.Background(), bj, resolved); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(bj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed {
t.Errorf("expected Failed, got %q (msg=%q)", got.Status.Phase, got.Status.Message)
}
}
// TestReconcileEtcd_StrategyMissingIsTransient pins the bootstrap-window
// contract: when the Strategy CR referenced by a (resolved) BackupClass
// is not (yet) present, the driver must surface a transient
// Ready=False/StrategyNotReady condition and requeue rather than
// terminate the BackupJob. Strategy CRs are gated on a populated
// BucketClaim.status.bucketName in the backupstrategy-controller chart;
// during the platform bootstrap window (Bucket → BucketClaim → COSI
// provisioning still in flight) BackupClass cozy-default exists but
// its strategies do not. Terminal failure would force tenants to
// recreate BackupJobs every time they raced the bootstrap.
func TestReconcileEtcd_StrategyMissingIsTransient(t *testing.T) {
apps := etcdapp.GroupName
now := metav1.Now()
bj := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "bj"},
Spec: backupsv1alpha1.BackupJobSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd", APIGroup: &apps},
BackupClassName: "etcd-default",
},
Status: backupsv1alpha1.BackupJobStatus{StartedAt: &now},
}
c := newEtcdTestClient(t, bj)
r := &BackupJobReconciler{Client: c, Scheme: newEtcdTestScheme(t), Recorder: record.NewFakeRecorder(10)}
apiGroup := "strategy.backups.cozystack.io"
strategyRef := corev1.TypedLocalObjectReference{
Kind: "Etcd",
Name: "missing-strategy",
APIGroup: &apiGroup,
}
resolved := &ResolvedBackupConfig{StrategyRef: strategyRef}
res, err := r.reconcileEtcd(context.Background(), bj, resolved)
if err != nil {
t.Fatalf("reconcile: %v", err)
}
if res.RequeueAfter == 0 {
t.Errorf("expected RequeueAfter > 0, got %v", res.RequeueAfter)
}
got := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(bj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase == backupsv1alpha1.BackupJobPhaseFailed {
t.Errorf("expected non-Failed phase, got %q", got.Status.Phase)
}
cond := apimeta.FindStatusCondition(got.Status.Conditions, "Ready")
if cond == nil {
t.Fatalf("expected Ready condition, got none")
}
if cond.Status != metav1.ConditionFalse || cond.Reason != "StrategyNotReady" {
t.Errorf("expected Ready=False/StrategyNotReady, got %s/%s", cond.Status, cond.Reason)
}
}
// TestReconcileEtcd_BackupJobDeadlineWhileWaitingForCluster pins the
// terminate-on-deadline contract for the wait-for-cluster branch:
// when the source EtcdCluster never becomes Ready (deleted source
// app, broken etcd, stuck PVC provisioner), the BackupJob must flip
// to Failed once StartedAt + etcdDefaultBackupDeadline elapses
// instead of requeuing forever. Mirrors the deadline check that
// already exists in the post-EtcdBackup-created path.
//
// Two sub-cases:
// 1. EtcdCluster NotFound + StartedAt long-past: Failed.
// 2. EtcdCluster present but Ready=False + StartedAt long-past: Failed.
//
// The third sub-case (StartedAt fresh) verifies the deadline doesn't
// fire prematurely.
func TestReconcileEtcd_BackupJobDeadlineWhileWaitingForCluster(t *testing.T) {
apps := etcdapp.GroupName
apiGroup := "strategy.backups.cozystack.io"
strategyRef := corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd-strategy-default", APIGroup: &apiGroup}
strategy := &strategyv1alpha1.Etcd{
ObjectMeta: metav1.ObjectMeta{Name: "etcd-strategy-default"},
Spec: strategyv1alpha1.EtcdSpec{Template: strategyv1alpha1.EtcdTemplate{
Destination: strategyv1alpha1.EtcdDestinationTemplate{S3: &strategyv1alpha1.EtcdS3Template{
Bucket: "b", Endpoint: "https://e",
CredentialsSecretRef: strategyv1alpha1.EtcdLocalObjectReference{Name: "creds"},
}},
}},
}
app := &etcdapp.Etcd{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "etcd"}}
mkBJ := func(started time.Time) *backupsv1alpha1.BackupJob {
ts := metav1.NewTime(started)
return &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj"},
Spec: backupsv1alpha1.BackupJobSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd", APIGroup: &apps},
BackupClassName: "etcd-default",
},
Status: backupsv1alpha1.BackupJobStatus{StartedAt: &ts},
}
}
resolved := &ResolvedBackupConfig{StrategyRef: strategyRef}
t.Run("EtcdCluster never appears -> Failed past deadline", func(t *testing.T) {
bj := mkBJ(time.Now().Add(-2 * etcdDefaultBackupDeadline))
c := newEtcdTestClient(t, bj, strategy, app)
r := &BackupJobReconciler{Client: c, Scheme: newEtcdTestScheme(t), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileEtcd(context.Background(), bj, resolved); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(bj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed {
t.Fatalf("expected Failed phase, got %q (msg=%q)", got.Status.Phase, got.Status.Message)
}
if !strings.Contains(got.Status.Message, "did not appear within") {
t.Errorf("failure message should mention deadline-on-NotFound; got %q", got.Status.Message)
}
})
t.Run("EtcdCluster present but Available=False -> Failed past deadline", func(t *testing.T) {
bj := mkBJ(time.Now().Add(-2 * etcdDefaultBackupDeadline))
notReady := &etcdtypes.EtcdCluster{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: etcdClusterName},
}
apimeta.SetStatusCondition(&notReady.Status.Conditions, metav1.Condition{
Type: etcdtypes.ClusterConditionAvailable,
Status: metav1.ConditionFalse,
Reason: "Bootstrapping",
Message: "waiting for first quorum",
})
c := newEtcdTestClient(t, bj, strategy, app, notReady)
r := &BackupJobReconciler{Client: c, Scheme: newEtcdTestScheme(t), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileEtcd(context.Background(), bj, resolved); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(bj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase != backupsv1alpha1.BackupJobPhaseFailed {
t.Fatalf("expected Failed phase, got %q (msg=%q)", got.Status.Phase, got.Status.Message)
}
if !strings.Contains(got.Status.Message, "did not become Available within") {
t.Errorf("failure message should mention Available-deadline; got %q", got.Status.Message)
}
// And the operator-side condition reason/message must be surfaced
// so an operator inspecting the failed BackupJob can see WHY the
// cluster never became Available.
if !strings.Contains(got.Status.Message, "waiting for first quorum") {
t.Errorf("failure message should surface operator Ready condition; got %q", got.Status.Message)
}
})
t.Run("StartedAt fresh -> requeue, not Failed", func(t *testing.T) {
bj := mkBJ(time.Now()) // just started
c := newEtcdTestClient(t, bj, strategy, app)
r := &BackupJobReconciler{Client: c, Scheme: newEtcdTestScheme(t), Recorder: record.NewFakeRecorder(10)}
res, err := r.reconcileEtcd(context.Background(), bj, resolved)
if err != nil {
t.Fatalf("reconcile: %v", err)
}
if res.RequeueAfter == 0 {
t.Fatalf("expected RequeueAfter > 0 while still within deadline window")
}
got := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(bj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase == backupsv1alpha1.BackupJobPhaseFailed {
t.Fatalf("must not be Failed while still within deadline window; got Failed (msg=%q)", got.Status.Message)
}
})
}
// ---------------------------------------------------------------------------
// latestEtcdSnapshotConditionMessage prefers failure-shaped conditions
// ---------------------------------------------------------------------------
// TestLatestEtcdBackupConditionMessage_PrefersFailedCondition pins the
// behaviour that a Failed/Ready=False condition wins over a later
// housekeeping update. Without this gate the upstream operator's
// post-failure status reconcile (e.g. flipping Started=False on the
// pod-gone path) would shadow the actual failure reason and tenants
// would see a misleading message on a Cozystack BackupJob.
func TestLatestEtcdBackupConditionMessage_PrefersFailedCondition(t *testing.T) {
failedAt := metav1.NewTime(time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC))
laterAt := metav1.NewTime(failedAt.Add(5 * time.Minute))
t.Run("Failed condition wins over later housekeeping", func(t *testing.T) {
eb := &etcdtypes.EtcdSnapshot{
Status: etcdtypes.EtcdSnapshotStatus{
Conditions: []metav1.Condition{
{Type: "Failed", Status: metav1.ConditionTrue, Reason: "UploadFailed",
Message: "upload to s3://x failed: AccessDenied", LastTransitionTime: failedAt},
{Type: "Started", Status: metav1.ConditionFalse, Reason: "JobGone",
Message: "job pod garbage collected", LastTransitionTime: laterAt},
},
},
}
got := latestEtcdSnapshotConditionMessage(eb)
if !strings.Contains(got, "AccessDenied") {
t.Errorf("expected failure message, got %q", got)
}
})
t.Run("Ready=False also counts as failure-shaped", func(t *testing.T) {
eb := &etcdtypes.EtcdSnapshot{
Status: etcdtypes.EtcdSnapshotStatus{
Conditions: []metav1.Condition{
{Type: etcdtypes.ClusterConditionAvailable, Status: metav1.ConditionFalse, Reason: "Backoff",
Message: "etcdctl exited 1: tls handshake timeout", LastTransitionTime: failedAt},
{Type: "Started", Status: metav1.ConditionFalse, Reason: "Idle",
Message: "idle", LastTransitionTime: laterAt},
},
},
}
got := latestEtcdSnapshotConditionMessage(eb)
if !strings.Contains(got, "tls handshake") {
t.Errorf("expected Ready=False message to be preferred, got %q", got)
}
})
t.Run("falls back to latest when no failure-shaped condition exists", func(t *testing.T) {
eb := &etcdtypes.EtcdSnapshot{
Status: etcdtypes.EtcdSnapshotStatus{
Conditions: []metav1.Condition{
{Type: "Started", Status: metav1.ConditionTrue, Reason: "Started",
Message: "snapshot in progress", LastTransitionTime: failedAt},
{Type: "Uploaded", Status: metav1.ConditionTrue, Reason: "Done",
Message: "uploaded 20512 bytes", LastTransitionTime: laterAt},
},
},
}
got := latestEtcdSnapshotConditionMessage(eb)
if !strings.Contains(got, "uploaded") {
t.Errorf("expected latest-by-time fallback, got %q", got)
}
})
t.Run("nil and empty are empty string", func(t *testing.T) {
if latestEtcdSnapshotConditionMessage(nil) != "" {
t.Error("nil should produce empty string")
}
if latestEtcdSnapshotConditionMessage(&etcdtypes.EtcdSnapshot{}) != "" {
t.Error("empty conditions should produce empty string")
}
})
}
// ---------------------------------------------------------------------------
// requeueBackupJobWithReason flips phase to Running
// ---------------------------------------------------------------------------
// TestRequeueBackupJobWithReason_SetsRunningPhase pins the contract that
// the first observable iteration (e.g. waiting for the source EtcdCluster
// to reach Ready) sets Phase=Running so tenants tailing
// BackupJob.status.phase see activity instead of an empty string while
// the condition spells out the wait reason.
func TestRequeueBackupJobWithReason_SetsRunningPhase(t *testing.T) {
bj := &backupsv1alpha1.BackupJob{
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj"},
}
c := newEtcdTestClient(t, bj)
r := &BackupJobReconciler{Client: c, Scheme: newEtcdTestScheme(t)}
if _, err := r.requeueBackupJobWithReason(context.Background(), bj, "EtcdClusterNotAvailable", "waiting"); err != nil {
t.Fatalf("requeue: %v", err)
}
got := &backupsv1alpha1.BackupJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(bj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase != backupsv1alpha1.BackupJobPhaseRunning {
t.Errorf("expected phase=Running on first observable iteration, got %q", got.Status.Phase)
}
cond := apimeta.FindStatusCondition(got.Status.Conditions, "Ready")
if cond == nil || cond.Reason != "EtcdClusterNotAvailable" {
t.Errorf("expected Ready=False/EtcdClusterNotAvailable condition, got %+v", cond)
}
}
// ---------------------------------------------------------------------------
// RestoreJob: captured-spec on Condition.Message size cap
// ---------------------------------------------------------------------------
// testEtcdDynamicScheme returns a runtime.Scheme registered ONLY with
// the unstructured GVKs the restore path operates on through the
// dynamic client. Kept separate from newEtcdTestScheme (which carries
// the typed EtcdCluster) so the dynamic fake doesn't double-register
// the EtcdCluster GVK and panic.
func testEtcdDynamicScheme(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{})
s.AddKnownTypeWithName(schema.GroupVersionKind{Group: etcdtypes.GroupName, Version: etcdtypes.Version, Kind: "EtcdCluster"}, &unstructured.Unstructured{})
s.AddKnownTypeWithName(schema.GroupVersionKind{Group: etcdtypes.GroupName, Version: etcdtypes.Version, Kind: "EtcdClusterList"}, &unstructured.UnstructuredList{})
return s
}
// newOversizedEtcdCluster fabricates an unstructured EtcdCluster whose
// spec marshals to > etcdRestoreCapturedSpecMaxBytes. Used to exercise
// the fail-fast cap on Condition.Message overflow.
func newOversizedEtcdCluster(namespace string, bytes int) *unstructured.Unstructured {
bloat := make([]string, 0, bytes/16)
for len(bloat)*16 < bytes {
bloat = append(bloat, strings.Repeat("a", 15))
}
u := &unstructured.Unstructured{}
u.SetGroupVersionKind(schema.GroupVersionKind{Group: etcdtypes.GroupName, Version: etcdtypes.Version, Kind: "EtcdCluster"})
u.SetNamespace(namespace)
u.SetName(etcdClusterName)
_ = unstructured.SetNestedStringSlice(u.Object, bloat, "spec", "_bloat")
_ = unstructured.SetNestedField(u.Object, int64(3), "spec", "replicas")
return u
}
// TestReconcileEtcdRestore_OversizedSpecFailsFastBeforePurge pins the
// fail-fast guard on Condition.Message overflow. If the captured spec
// would exceed etcdRestoreCapturedSpecMaxBytes the destructive purge
// MUST NOT start - the spec couldn't be durably persisted, so any crash
// between snapshot and recreate would leave the RestoreJob stuck with
// the cluster gone and the spec lost forever.
func TestReconcileEtcdRestore_OversizedSpecFailsFastBeforePurge(t *testing.T) {
const ns = "tenant"
apps := etcdapp.GroupName
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd", APIGroup: &apps},
DriverMetadata: map[string]string{
etcdBackupBucketKey: "b",
etcdBackupEndpointKey: "https://e",
etcdBackupCredsSecretKey: "creds",
},
},
}
now := metav1.Now()
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{BackupRef: corev1.LocalObjectReference{Name: "bk"}},
Status: backupsv1alpha1.RestoreJobStatus{StartedAt: &now},
}
cluster := newOversizedEtcdCluster(ns, etcdRestoreCapturedSpecMaxBytes*2)
dyn := dynamicfake.NewSimpleDynamicClient(testEtcdDynamicScheme(t), cluster)
c := newEtcdTestClient(t, backup, rj)
r := &RestoreJobReconciler{Client: c, Interface: dyn, Scheme: newEtcdTestScheme(t), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileEtcdRestore(context.Background(), rj, backup); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &backupsv1alpha1.RestoreJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(rj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase != backupsv1alpha1.RestoreJobPhaseFailed {
t.Fatalf("expected Failed phase, got %q (msg=%q)", got.Status.Phase, got.Status.Message)
}
if !strings.Contains(got.Status.Message, "exceeds") {
t.Errorf("failure message should explain the cap; got %q", got.Status.Message)
}
// The cluster must still exist - we must not have started the purge.
if _, err := dyn.Resource(etcdClusterGVR).Namespace(ns).Get(context.Background(), etcdClusterName, metav1.GetOptions{}); err != nil {
t.Errorf("oversized-spec gate fired AFTER deletion (cluster gone: %v); fail-fast contract broken", err)
}
}
// TestReconcileEtcdRestore_EmptySpecFailsBeforePurge pins the empty-spec
// gate: a live EtcdCluster with an empty spec is the corrupted state the
// driver cannot recover from. The RestoreJob must terminate WITHOUT
// touching the cluster (deletion would lose the only reference to the
// chart-rendered fields needed for recreation).
func TestReconcileEtcdRestore_EmptySpecFailsBeforePurge(t *testing.T) {
const ns = "tenant"
apps := etcdapp.GroupName
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd", APIGroup: &apps},
DriverMetadata: map[string]string{
etcdBackupBucketKey: "b",
etcdBackupEndpointKey: "https://e",
etcdBackupCredsSecretKey: "creds",
},
},
}
now := metav1.Now()
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{BackupRef: corev1.LocalObjectReference{Name: "bk"}},
Status: backupsv1alpha1.RestoreJobStatus{StartedAt: &now},
}
empty := &unstructured.Unstructured{}
empty.SetGroupVersionKind(schema.GroupVersionKind{Group: etcdtypes.GroupName, Version: etcdtypes.Version, Kind: "EtcdCluster"})
empty.SetNamespace(ns)
empty.SetName(etcdClusterName)
// spec deliberately absent / empty.
dyn := dynamicfake.NewSimpleDynamicClient(testEtcdDynamicScheme(t), empty)
c := newEtcdTestClient(t, backup, rj)
r := &RestoreJobReconciler{Client: c, Interface: dyn, Scheme: newEtcdTestScheme(t), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileEtcdRestore(context.Background(), rj, backup); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &backupsv1alpha1.RestoreJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(rj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase != backupsv1alpha1.RestoreJobPhaseFailed {
t.Fatalf("expected Failed phase on empty spec, got %q", got.Status.Phase)
}
if !strings.Contains(got.Status.Message, "empty spec") {
t.Errorf("failure message should name the empty-spec condition; got %q", got.Status.Message)
}
if _, err := dyn.Resource(etcdClusterGVR).Namespace(ns).Get(context.Background(), etcdClusterName, metav1.GetOptions{}); err != nil {
t.Errorf("empty-spec gate must not delete the cluster; got: %v", err)
}
}
// TestReconcileEtcdRestore_CrashRecoveryAdvancesWhenSpecCaptured pins the
// crash-safety branch: if the controller crashed AFTER capturing the
// spec on a Condition.Message and AFTER deleting the EtcdCluster but
// BEFORE stamping TargetPurged, the next reconcile sees NotFound on the
// dynamic Get. Without specCaptured we'd treat that as "tenant never
// deployed the source" and terminate; with specCaptured we treat it as
// "the previous iteration already purged" and advance the state machine.
func TestReconcileEtcdRestore_CrashRecoveryAdvancesWhenSpecCaptured(t *testing.T) {
const ns = "tenant"
apps := etcdapp.GroupName
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd", APIGroup: &apps},
DriverMetadata: map[string]string{
etcdBackupBucketKey: "b",
etcdBackupEndpointKey: "https://e",
etcdBackupCredsSecretKey: "creds",
},
},
}
now := metav1.Now()
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{BackupRef: corev1.LocalObjectReference{Name: "bk"}},
Status: backupsv1alpha1.RestoreJobStatus{
StartedAt: &now,
Conditions: []metav1.Condition{
{
Type: etcdRestoreCondClusterSpecCaptured,
Status: metav1.ConditionTrue,
Reason: "SpecCaptured",
Message: `{"replicas":3,"storage":{"volumeClaimTemplate":{}}}`,
LastTransitionTime: now,
},
},
},
}
dyn := dynamicfake.NewSimpleDynamicClient(testEtcdDynamicScheme(t)) // no cluster -> NotFound
c := newEtcdTestClient(t, backup, rj)
r := &RestoreJobReconciler{Client: c, Interface: dyn, Scheme: newEtcdTestScheme(t), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileEtcdRestore(context.Background(), rj, backup); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &backupsv1alpha1.RestoreJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(rj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase == backupsv1alpha1.RestoreJobPhaseFailed {
t.Fatalf("expected reconcile to advance (NOT Failed); got Failed with msg=%q", got.Status.Message)
}
cond := apimeta.FindStatusCondition(got.Status.Conditions, etcdRestoreCondTargetPurged)
if cond == nil || cond.Status != metav1.ConditionTrue {
t.Errorf("expected TargetPurged=True (crash-recovery advance); got %+v", cond)
}
if cond != nil && cond.Reason != "ClusterPurgedRecovered" {
t.Errorf("expected Reason=ClusterPurgedRecovered to flag the crash-recovery path; got %q", cond.Reason)
}
}
// TestReconcileEtcdRestore_MissingClusterWithoutCaptureIsTerminal pins
// the other side of the disambiguation: a tenant who fires a RestoreJob
// without ever having deployed the source app must NOT have the
// controller fall through to the recreate phase (which would create an
// EtcdCluster with an empty captured spec). The job must terminate.
func TestReconcileEtcdRestore_MissingClusterWithoutCaptureIsTerminal(t *testing.T) {
const ns = "tenant"
apps := etcdapp.GroupName
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd", APIGroup: &apps},
DriverMetadata: map[string]string{
etcdBackupBucketKey: "b",
etcdBackupEndpointKey: "https://e",
etcdBackupCredsSecretKey: "creds",
},
},
}
now := metav1.Now()
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{BackupRef: corev1.LocalObjectReference{Name: "bk"}},
Status: backupsv1alpha1.RestoreJobStatus{StartedAt: &now},
}
dyn := dynamicfake.NewSimpleDynamicClient(testEtcdDynamicScheme(t)) // no cluster + no capture
c := newEtcdTestClient(t, backup, rj)
r := &RestoreJobReconciler{Client: c, Interface: dyn, Scheme: newEtcdTestScheme(t), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileEtcdRestore(context.Background(), rj, backup); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &backupsv1alpha1.RestoreJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(rj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase != backupsv1alpha1.RestoreJobPhaseFailed {
t.Errorf("expected Failed when source cluster never existed; got %q", got.Status.Phase)
}
}
// ---------------------------------------------------------------------------
// HR-resume on terminal failure inside the destructive window
// (without this gate, helm-controller is frozen on the Etcd app forever)
// ---------------------------------------------------------------------------
// suspendedHelmRelease fabricates an unstructured HelmRelease with the
// requested spec.suspend value, under the GVK the driver mutates.
func suspendedHelmRelease(namespace, name string, suspend bool) *unstructured.Unstructured {
hr := &unstructured.Unstructured{}
hr.SetGroupVersionKind(schema.GroupVersionKind{Group: "helm.toolkit.fluxcd.io", Version: "v2", Kind: "HelmRelease"})
hr.SetNamespace(namespace)
hr.SetName(name)
_ = unstructured.SetNestedMap(hr.Object, map[string]any{"suspend": suspend}, "spec")
return hr
}
func hrSuspended(t *testing.T, dyn dynamic.Interface, namespace, name string) bool {
t.Helper()
hr, err := dyn.Resource(helmReleaseGVR).Namespace(namespace).Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
t.Fatalf("get HR: %v", err)
}
got, _, _ := unstructured.NestedBool(hr.Object, "spec", "suspend")
return got
}
// TestReconcileEtcdRestore_AvailableDeadlineResumesHR pins the most likely
// production failure path: the recreated EtcdCluster does NOT become
// Available within restoreTimeoutSeconds (e.g. snapshot download stuck, S3
// creds wrong, PVC provisioner slow). Without this gate, the driver
// flips the RestoreJob Failed and leaves spec.suspend=true on the
// HelmRelease, freezing helm-controller on the Etcd app indefinitely —
// the only manual recovery is `kubectl patch hr/etcd -p
// '{"spec":{"suspend":false}}'`. It also pins the failure wording to the
// operator's actual condition (Available); the v1alpha2 operator emits no
// "Ready" condition.
//
// Setup walks the state machine to the Available-poll branch:
// - EtcdCluster exists (bootstrap.restore stamped, not Available),
// - pre-seeded TargetPurged AND EtcdClusterSpecCaptured so the
// destructive path is skipped on this reconcile,
// - HR is already suspended (simulating that a prior reconcile ran
// the destructive window),
// - StartedAt is far enough in the past that the deadline trips.
func TestReconcileEtcdRestore_AvailableDeadlineResumesHR(t *testing.T) {
const ns = "tenant"
apps := etcdapp.GroupName
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd", APIGroup: &apps},
DriverMetadata: map[string]string{
etcdBackupBucketKey: "b",
etcdBackupEndpointKey: "https://e",
etcdBackupCredsSecretKey: "creds",
},
},
}
long := metav1.NewTime(time.Now().Add(-2 * etcdDefaultRestoreDeadline))
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{BackupRef: corev1.LocalObjectReference{Name: "bk"}},
Status: backupsv1alpha1.RestoreJobStatus{
StartedAt: &long,
Conditions: []metav1.Condition{
{Type: etcdRestoreCondClusterSpecCaptured, Status: metav1.ConditionTrue, Reason: "SpecCaptured",
Message: `{"replicas":3,"storage":{"volumeClaimTemplate":{}}}`, LastTransitionTime: long},
{Type: etcdRestoreCondTargetPurged, Status: metav1.ConditionTrue, Reason: "ClusterPurged",
Message: "purged", LastTransitionTime: long},
},
},
}
cluster := &unstructured.Unstructured{}
cluster.SetGroupVersionKind(schema.GroupVersionKind{Group: etcdtypes.GroupName, Version: etcdtypes.Version, Kind: "EtcdCluster"})
cluster.SetNamespace(ns)
cluster.SetName(etcdClusterName)
_ = unstructured.SetNestedField(cluster.Object, int64(3), "spec", "replicas")
hr := suspendedHelmRelease(ns, etcdClusterName, true)
dyn := dynamicfake.NewSimpleDynamicClient(testEtcdDynamicScheme(t), cluster, hr)
// typed-client EtcdCluster (no Available=True condition) so the Available-poll branch trips the deadline.
typedCluster := &etcdtypes.EtcdCluster{ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: etcdClusterName}}
c := newEtcdTestClient(t, backup, rj, typedCluster)
r := &RestoreJobReconciler{Client: c, Interface: dyn, Scheme: newEtcdTestScheme(t), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileEtcdRestore(context.Background(), rj, backup); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &backupsv1alpha1.RestoreJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(rj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase != backupsv1alpha1.RestoreJobPhaseFailed {
t.Fatalf("expected Failed phase on Available deadline; got %q (msg=%q)", got.Status.Phase, got.Status.Message)
}
// The v1alpha2 operator emits no "Ready" condition; the restore-deadline
// message must report the actual gate (Available), not a condition that
// does not exist, or it misleads an operator debugging a stuck restore.
if !strings.Contains(got.Status.Message, "did not become Available within") {
t.Errorf("failure message should name the Available deadline; got %q", got.Status.Message)
}
if strings.Contains(got.Status.Message, "Ready") {
t.Errorf("failure message must not mention the nonexistent Ready condition; got %q", got.Status.Message)
}
if hrSuspended(t, dyn, ns, etcdClusterName) {
t.Fatalf("HelmRelease must NOT remain suspended after terminal Available-deadline failure - helm-controller would be frozen on the Etcd app")
}
}
// TestReconcileEtcdRestore_RecoverSpecErrorResumesHR pins the
// readCapturedEtcdClusterSpec failure branch: if the captured-spec
// condition is wiped (admission webhook corruption, manual edit, ...)
// after TargetPurged is set, the driver terminates the RestoreJob -
// and must resume the HR before doing so, because the destructive
// purge already happened on a prior iteration.
func TestReconcileEtcdRestore_RecoverSpecErrorResumesHR(t *testing.T) {
const ns = "tenant"
apps := etcdapp.GroupName
backup := &backupsv1alpha1.Backup{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "bk"},
Spec: backupsv1alpha1.BackupSpec{
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "Etcd", Name: "etcd", APIGroup: &apps},
DriverMetadata: map[string]string{
etcdBackupBucketKey: "b",
etcdBackupEndpointKey: "https://e",
etcdBackupCredsSecretKey: "creds",
},
},
}
now := metav1.Now()
rj := &backupsv1alpha1.RestoreJob{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "rj"},
Spec: backupsv1alpha1.RestoreJobSpec{BackupRef: corev1.LocalObjectReference{Name: "bk"}},
Status: backupsv1alpha1.RestoreJobStatus{
StartedAt: &now,
// TargetPurged set but EtcdClusterSpecCaptured deliberately missing.
Conditions: []metav1.Condition{
{Type: etcdRestoreCondTargetPurged, Status: metav1.ConditionTrue, Reason: "ClusterPurged",
Message: "purged", LastTransitionTime: now},
},
},
}
hr := suspendedHelmRelease(ns, etcdClusterName, true)
dyn := dynamicfake.NewSimpleDynamicClient(testEtcdDynamicScheme(t), hr) // no EtcdCluster -> recreate branch
c := newEtcdTestClient(t, backup, rj)
r := &RestoreJobReconciler{Client: c, Interface: dyn, Scheme: newEtcdTestScheme(t), Recorder: record.NewFakeRecorder(10)}
if _, err := r.reconcileEtcdRestore(context.Background(), rj, backup); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &backupsv1alpha1.RestoreJob{}
if err := c.Get(context.Background(), client.ObjectKeyFromObject(rj), got); err != nil {
t.Fatalf("refetch: %v", err)
}
if got.Status.Phase != backupsv1alpha1.RestoreJobPhaseFailed {
t.Fatalf("expected Failed phase; got %q", got.Status.Phase)
}
if hrSuspended(t, dyn, ns, etcdClusterName) {
t.Fatalf("HelmRelease must be resumed on recover-spec failure; got suspended=true")
}
}
// ---------------------------------------------------------------------------
// etcdClusterFullyGone gates HR-resume until cluster + PVCs disappear
// ---------------------------------------------------------------------------
// TestEtcdClusterFullyGone covers the wait-for-purge helper. Returning
// early on a still-terminating cluster lets the recreate step race the
// finalizer drain and the new Create races on PVC name collision.
func TestEtcdClusterFullyGone(t *testing.T) {
const ns = "tenant"
t.Run("cluster + PVCs absent -> gone=true", func(t *testing.T) {
c := newEtcdTestClient(t)
r := &RestoreJobReconciler{Client: c}
gone, err := r.etcdClusterFullyGone(context.Background(), ns)
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 := &etcdtypes.EtcdCluster{ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: etcdClusterName}}
c := newEtcdTestClient(t, cluster)
r := &RestoreJobReconciler{Client: c}
gone, err := r.etcdClusterFullyGone(context.Background(), ns)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gone {
t.Fatalf("expected gone=false when cluster still exists")
}
})
t.Run("cluster gone but member PVC remains -> gone=false", func(t *testing.T) {
// The etcd-operator's StatefulSet-style volumeClaimTemplate names
// PVCs etcd-data-etcd-<i> and labels them with
// app.kubernetes.io/instance=etcd. Pin the label match: if the
// operator changes the label the restore will silently race the
// still-draining PVC.
pvc := &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
Namespace: ns,
Name: "etcd-data-etcd-0",
Labels: map[string]string{"app.kubernetes.io/instance": etcdClusterName},
},
}
c := newEtcdTestClient(t, pvc)
r := &RestoreJobReconciler{Client: c}
gone, err := r.etcdClusterFullyGone(context.Background(), ns)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gone {
t.Fatalf("expected gone=false while member PVC still exists")
}
})
t.Run("terminating PVC (DeletionTimestamp set, finalizers draining) still blocks", func(t *testing.T) {
// The etcd-operator drains PVCs via finalizers / ownerRef cascade;
// a PVC mid-deletion has DeletionTimestamp set but stays in the
// apiserver until finalizers clear. If etcdClusterFullyGone
// returned gone=true on a terminating PVC, the recreate step
// would race the still-draining storage and the new
// EtcdCluster's volumeClaimTemplate Create would collide on PVC
// name. Pin that gone=false survives this state.
//
// Seeding pattern: the fake client preserves DeletionTimestamp
// when the seeded object carries both DeletionTimestamp AND a
// finalizer (without a finalizer the fake client treats the
// Delete as terminal). We use an etcd-operator-shaped finalizer
// name; the value is opaque to etcdClusterFullyGone.
now := metav1.Now()
pvc := &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
Namespace: ns,
Name: "etcd-data-etcd-0",
Labels: map[string]string{"app.kubernetes.io/instance": etcdClusterName},
DeletionTimestamp: &now,
Finalizers: []string{"kubernetes.io/pvc-protection"},
},
}
c := newEtcdTestClient(t, pvc)
r := &RestoreJobReconciler{Client: c}
gone, err := r.etcdClusterFullyGone(context.Background(), ns)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gone {
t.Fatalf("expected gone=false while member PVC is still terminating (DeletionTimestamp set)")
}
})
}
// ---------------------------------------------------------------------------
// setEtcdRestoreHRSuspended toggles spec.suspend on the HelmRelease
// ---------------------------------------------------------------------------
// TestSetEtcdRestoreHRSuspended mirrors the CNPG driver's tests on the
// same helper. The destructive purge must suspend helm-controller before
// deleting the live EtcdCluster (otherwise Helm re-renders a
// bootstrap-less cluster on its next sync and races the restore) and
// resume it after the new cluster is Ready. A missing HR is treated as a
// no-op.
func TestSetEtcdRestoreHRSuspended(t *testing.T) {
const (
ns = "tenant"
name = "etcd"
)
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]any{}
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(testEtcdDynamicScheme(t), mkHR(&falsePtr))
r := &RestoreJobReconciler{Interface: dyn}
if err := r.setEtcdRestoreHRSuspended(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(testEtcdDynamicScheme(t), mkHR(&truePtr))
r := &RestoreJobReconciler{Interface: dyn}
if err := r.setEtcdRestoreHRSuspended(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) {
truePtr := true
dyn := dynamicfake.NewSimpleDynamicClient(testEtcdDynamicScheme(t), mkHR(&truePtr))
r := &RestoreJobReconciler{Interface: dyn}
if err := r.setEtcdRestoreHRSuspended(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 returns nil (no-op)", func(t *testing.T) {
// A tenant deleting the Etcd app mid-restore must not strand the
// driver on a NotFound; the destructive flow should proceed and
// the next reconcile will note the missing HR.
dyn := dynamicfake.NewSimpleDynamicClient(testEtcdDynamicScheme(t))
r := &RestoreJobReconciler{Interface: dyn}
if err := r.setEtcdRestoreHRSuspended(context.Background(), ns, name, true); err != nil {
t.Fatalf("missing HR should be no-op: %v", err)
}
})
}
// ---------------------------------------------------------------------------
// EtcdRestoreOptions: negative override falls back to default deadline
// ---------------------------------------------------------------------------
// TestEffectiveRestoreDeadline_NegativeFallsBack pins the contract on
// negative inputs. A tenant submitting restoreTimeoutSeconds=-1 (or any
// non-positive value) must get the default deadline rather than a
// zero/negative deadline that would mark the RestoreJob Failed on its
// very next poll.
func TestEffectiveRestoreDeadline_NegativeFallsBack(t *testing.T) {
for _, in := range []int64{-1, -3600, 0} {
o := EtcdRestoreOptions{RestoreTimeoutSeconds: in}
if got := o.effectiveRestoreDeadline(); got != etcdDefaultRestoreDeadline {
t.Errorf("RestoreTimeoutSeconds=%d: deadline=%v want %v", in, got, etcdDefaultRestoreDeadline)
}
}
}