From 7dab7b69deb573bf913cd4cc0a4b899b6cf8e8e6 Mon Sep 17 00:00:00 2001 From: nimishgj Date: Mon, 16 Feb 2026 10:30:16 +0530 Subject: [PATCH] use rclone image for snapshot backups --- api/v1alpha1/memgraphcluster_types.go | 29 ++ api/v1alpha1/zz_generated.deepcopy.go | 24 + .../memgraph.base14.io_memgraphclusters.yaml | 24 + internal/controller/snapshot.go | 150 ++++-- internal/controller/snapshot_test.go | 474 ++++++++++++++---- 5 files changed, 550 insertions(+), 151 deletions(-) diff --git a/api/v1alpha1/memgraphcluster_types.go b/api/v1alpha1/memgraphcluster_types.go index 6219479..be2f42a 100644 --- a/api/v1alpha1/memgraphcluster_types.go +++ b/api/v1alpha1/memgraphcluster_types.go @@ -179,9 +179,18 @@ type SnapshotSpec struct { // +optional RetentionCount int32 `json:"retentionCount,omitempty"` + // ServiceAccountName is the Kubernetes service account to use for the snapshot CronJob pod. + // Required for both S3 (IRSA on EKS) and GCS (Workload Identity on GKE). + // +optional + ServiceAccountName string `json:"serviceAccountName,omitempty"` + // S3 defines optional S3 backup configuration // +optional S3 *S3BackupSpec `json:"s3,omitempty"` + + // GCS defines optional GCS backup configuration + // +optional + GCS *GCSBackupSpec `json:"gcs,omitempty"` } // S3BackupSpec defines S3 backup configuration @@ -219,6 +228,22 @@ type S3BackupSpec struct { RetentionDays int32 `json:"retentionDays,omitempty"` } +// GCSBackupSpec defines GCS backup configuration +type GCSBackupSpec struct { + // Enabled enables GCS backups + // +optional + Enabled bool `json:"enabled,omitempty"` + + // Bucket is the GCS bucket name + // +optional + Bucket string `json:"bucket,omitempty"` + + // Prefix is the path prefix within the bucket + // +kubebuilder:default="memgraph/snapshots" + // +optional + Prefix string `json:"prefix,omitempty"` +} + // MemgraphClusterStatus defines the observed state of MemgraphCluster type MemgraphClusterStatus struct { // Phase is the current phase of the cluster @@ -249,6 +274,10 @@ type MemgraphClusterStatus struct { // +optional LastS3BackupTime *metav1.Time `json:"lastS3BackupTime,omitempty"` + // LastGCSBackupTime is the time of the last successful GCS backup + // +optional + LastGCSBackupTime *metav1.Time `json:"lastGCSBackupTime,omitempty"` + // Validation contains real-time validation test results // +optional Validation *ValidationStatus `json:"validation,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index cfac49f..dc7eb1c 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -12,6 +12,21 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GCSBackupSpec) DeepCopyInto(out *GCSBackupSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GCSBackupSpec. +func (in *GCSBackupSpec) DeepCopy() *GCSBackupSpec { + if in == nil { + return nil + } + out := new(GCSBackupSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HighAvailabilitySpec) DeepCopyInto(out *HighAvailabilitySpec) { *out = *in @@ -170,6 +185,10 @@ func (in *MemgraphClusterStatus) DeepCopyInto(out *MemgraphClusterStatus) { in, out := &in.LastS3BackupTime, &out.LastS3BackupTime *out = (*in).DeepCopy() } + if in.LastGCSBackupTime != nil { + in, out := &in.LastGCSBackupTime, &out.LastGCSBackupTime + *out = (*in).DeepCopy() + } if in.Validation != nil { in, out := &in.Validation, &out.Validation *out = new(ValidationStatus) @@ -282,6 +301,11 @@ func (in *SnapshotSpec) DeepCopyInto(out *SnapshotSpec) { *out = new(S3BackupSpec) (*in).DeepCopyInto(*out) } + if in.GCS != nil { + in, out := &in.GCS, &out.GCS + *out = new(GCSBackupSpec) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SnapshotSpec. diff --git a/config/crd/bases/memgraph.base14.io_memgraphclusters.yaml b/config/crd/bases/memgraph.base14.io_memgraphclusters.yaml index fa94111..98e31a1 100644 --- a/config/crd/bases/memgraph.base14.io_memgraphclusters.yaml +++ b/config/crd/bases/memgraph.base14.io_memgraphclusters.yaml @@ -1129,6 +1129,20 @@ spec: default: true description: Enabled enables periodic snapshots type: boolean + gcs: + description: GCS defines optional GCS backup configuration + properties: + bucket: + description: Bucket is the GCS bucket name + type: string + enabled: + description: Enabled enables GCS backups + type: boolean + prefix: + default: memgraph/snapshots + description: Prefix is the path prefix within the bucket + type: string + type: object retentionCount: default: 5 description: RetentionCount is the number of snapshots to retain @@ -1183,6 +1197,11 @@ spec: default: '*/15 * * * *' description: Schedule is a cron expression for snapshot frequency type: string + serviceAccountName: + description: |- + ServiceAccountName is the Kubernetes service account to use for the snapshot CronJob pod. + Required for both S3 (IRSA on EKS) and GCS (Workload Identity on GKE). + type: string type: object storage: description: Storage defines the persistent storage configuration @@ -1302,6 +1321,11 @@ spec: - type type: object type: array + lastGCSBackupTime: + description: LastGCSBackupTime is the time of the last successful + GCS backup + format: date-time + type: string lastS3BackupTime: description: LastS3BackupTime is the time of the last successful S3 backup diff --git a/internal/controller/snapshot.go b/internal/controller/snapshot.go index 112f23f..4613187 100644 --- a/internal/controller/snapshot.go +++ b/internal/controller/snapshot.go @@ -21,8 +21,8 @@ import ( ) const ( - // Default AWS CLI image for S3 uploads - defaultAWSCLIImage = "amazon/aws-cli:latest" + // Default rclone image for S3/GCS uploads + defaultRcloneImage = "rclone/rclone:1.73.0" // Shared volume name for snapshot data between containers snapshotDataVolume = "snapshot-data" @@ -104,7 +104,8 @@ func buildSnapshotCronJob(cluster *memgraphv1alpha1.MemgraphCluster) *batchv1.Cr Labels: labelsForCluster(cluster), }, Spec: corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyOnFailure, + RestartPolicy: corev1.RestartPolicyOnFailure, + ServiceAccountName: cluster.Spec.Snapshot.ServiceAccountName, SecurityContext: &corev1.PodSecurityContext{ RunAsUser: &runAsUser, RunAsGroup: &runAsGroup, @@ -151,8 +152,8 @@ echo "Snapshot created successfully at $(date)" }, } - // Init container 2: Copy snapshot files to shared volume (if S3 enabled) - if cluster.Spec.Snapshot.S3 != nil && cluster.Spec.Snapshot.S3.Enabled { + // Init container 2: Copy snapshot files to shared volume (if S3 or GCS enabled) + if isRemoteBackupEnabled(cluster) { // Use bitnami/kubectl for copying files from the main pod copyCmd := fmt.Sprintf(` set -e @@ -202,12 +203,14 @@ ls -la /snapshot-data/snapshots/ // buildSnapshotMainContainers builds the main containers for the snapshot job func buildSnapshotMainContainers(cluster *memgraphv1alpha1.MemgraphCluster) []corev1.Container { - // If S3 is enabled, main container uploads to S3 if cluster.Spec.Snapshot.S3 != nil && cluster.Spec.Snapshot.S3.Enabled { - return []corev1.Container{buildS3UploadContainer(cluster)} + return []corev1.Container{buildRcloneUploadContainer(cluster, "s3")} + } + + if cluster.Spec.Snapshot.GCS != nil && cluster.Spec.Snapshot.GCS.Enabled { + return []corev1.Container{buildRcloneUploadContainer(cluster, "gcs")} } - // Otherwise, just a completion container return []corev1.Container{ { Name: "complete", @@ -224,50 +227,42 @@ func buildSnapshotMainContainers(cluster *memgraphv1alpha1.MemgraphCluster) []co } } -// buildS3UploadContainer builds the S3 upload container -func buildS3UploadContainer(cluster *memgraphv1alpha1.MemgraphCluster) corev1.Container { - s3 := cluster.Spec.Snapshot.S3 - prefix := s3.Prefix - if prefix == "" { - prefix = "memgraph/snapshots" +// isRemoteBackupEnabled returns true if either S3 or GCS backup is enabled +func isRemoteBackupEnabled(cluster *memgraphv1alpha1.MemgraphCluster) bool { + if cluster.Spec.Snapshot.S3 != nil && cluster.Spec.Snapshot.S3.Enabled { + return true } + if cluster.Spec.Snapshot.GCS != nil && cluster.Spec.Snapshot.GCS.Enabled { + return true + } + return false +} - // Build S3 upload command - s3Cmd := fmt.Sprintf(` -set -e - -TIMESTAMP=$(cat /snapshot-data/timestamp) -BACKUP_PATH="s3://%s/%s/%s/${TIMESTAMP}" - -echo "Uploading snapshot to ${BACKUP_PATH}..." - -# Configure endpoint if specified -%s - -# Upload to S3 -if [ -d "/snapshot-data/snapshots" ] && [ "$(ls -A /snapshot-data/snapshots 2>/dev/null)" ]; then - aws s3 cp /snapshot-data/snapshots/ ${BACKUP_PATH}/snapshots/ --recursive - echo "Snapshot uploaded successfully to ${BACKUP_PATH}" -else - echo "No snapshot files found to upload" - exit 1 -fi +// buildRcloneUploadContainer builds the rclone upload container for S3 or GCS +func buildRcloneUploadContainer(cluster *memgraphv1alpha1.MemgraphCluster, backend string) corev1.Container { + var rcloneCmd string + var envVars []corev1.EnvVar -echo "S3 backup completed at $(date)" -`, s3.Bucket, prefix, cluster.Name, buildS3EndpointConfig(s3)) + switch backend { + case "s3": + rcloneCmd = buildRcloneS3Command(cluster) + envVars = buildS3Env(cluster) + case "gcs": + rcloneCmd = buildRcloneGCSCommand(cluster) + } return corev1.Container{ - Name: "s3-upload", - Image: defaultAWSCLIImage, + Name: "rclone-upload", + Image: defaultRcloneImage, Command: []string{"/bin/sh", "-c"}, - Args: []string{s3Cmd}, + Args: []string{rcloneCmd}, SecurityContext: &corev1.SecurityContext{ AllowPrivilegeEscalation: ptr(false), Capabilities: &corev1.Capabilities{ Drop: []corev1.Capability{"ALL"}, }, }, - Env: buildS3Env(cluster), + Env: envVars, VolumeMounts: []corev1.VolumeMount{ { Name: snapshotDataVolume, @@ -277,12 +272,69 @@ echo "S3 backup completed at $(date)" } } +// buildRcloneS3Command builds the rclone command for S3 uploads +func buildRcloneS3Command(cluster *memgraphv1alpha1.MemgraphCluster) string { + s3 := cluster.Spec.Snapshot.S3 + prefix := s3.Prefix + if prefix == "" { + prefix = "memgraph/snapshots" + } + + endpointFlag := "" + if s3.Endpoint != "" { + endpointFlag = fmt.Sprintf("--s3-endpoint %s", s3.Endpoint) + } + + regionFlag := "" + if s3.Region != "" { + regionFlag = fmt.Sprintf("--s3-region %s", s3.Region) + } + + return fmt.Sprintf(` +set -e +TIMESTAMP=$(cat /snapshot-data/timestamp) +DEST=":s3:%s/%s/%s/${TIMESTAMP}/snapshots" +echo "Uploading snapshot to ${DEST}..." +if [ -d "/snapshot-data/snapshots" ] && [ "$(ls -A /snapshot-data/snapshots 2>/dev/null)" ]; then + rclone copy /snapshot-data/snapshots/ "${DEST}" --s3-provider AWS --s3-env-auth %s %s -v + echo "Snapshot uploaded successfully" +else + echo "No snapshot files found to upload" + exit 1 +fi +echo "S3 backup completed at $(date)" +`, s3.Bucket, prefix, cluster.Name, regionFlag, endpointFlag) +} + +// buildRcloneGCSCommand builds the rclone command for GCS uploads +func buildRcloneGCSCommand(cluster *memgraphv1alpha1.MemgraphCluster) string { + gcs := cluster.Spec.Snapshot.GCS + prefix := gcs.Prefix + if prefix == "" { + prefix = "memgraph/snapshots" + } + + return fmt.Sprintf(` +set -e +TIMESTAMP=$(cat /snapshot-data/timestamp) +DEST=":gcs:%s/%s/%s/${TIMESTAMP}/snapshots" +echo "Uploading snapshot to ${DEST}..." +if [ -d "/snapshot-data/snapshots" ] && [ "$(ls -A /snapshot-data/snapshots 2>/dev/null)" ]; then + rclone copy /snapshot-data/snapshots/ "${DEST}" --gcs-env-auth -v + echo "Snapshot uploaded successfully" +else + echo "No snapshot files found to upload" + exit 1 +fi +echo "GCS backup completed at $(date)" +`, gcs.Bucket, prefix, cluster.Name) +} + // buildSnapshotVolumes builds the volumes for the snapshot job func buildSnapshotVolumes(cluster *memgraphv1alpha1.MemgraphCluster) []corev1.Volume { var volumes []corev1.Volume - // Add shared volume if S3 is enabled - if cluster.Spec.Snapshot.S3 != nil && cluster.Spec.Snapshot.S3.Enabled { + if isRemoteBackupEnabled(cluster) { volumes = append(volumes, corev1.Volume{ Name: snapshotDataVolume, VolumeSource: corev1.VolumeSource{ @@ -294,14 +346,6 @@ func buildSnapshotVolumes(cluster *memgraphv1alpha1.MemgraphCluster) []corev1.Vo return volumes } -// buildS3EndpointConfig builds AWS CLI endpoint configuration -func buildS3EndpointConfig(s3 *memgraphv1alpha1.S3BackupSpec) string { - if s3.Endpoint == "" { - return "" - } - return fmt.Sprintf(`export AWS_ENDPOINT_URL="%s"`, s3.Endpoint) -} - // buildS3Env builds environment variables for the S3 upload container func buildS3Env(cluster *memgraphv1alpha1.MemgraphCluster) []corev1.EnvVar { var envVars []corev1.EnvVar @@ -312,7 +356,6 @@ func buildS3Env(cluster *memgraphv1alpha1.MemgraphCluster) []corev1.EnvVar { s3 := cluster.Spec.Snapshot.S3 - // Add S3 credentials if configured if s3.SecretRef != nil { envVars = append(envVars, corev1.EnvVar{ @@ -375,9 +418,10 @@ func (r *MemgraphClusterReconciler) reconcileSnapshotCronJob(ctx context.Context return err } - // Update if schedule or S3 config changed + // Update if schedule, backup config, or service account changed needsUpdate := existing.Spec.Schedule != desired.Spec.Schedule || - len(existing.Spec.JobTemplate.Spec.Template.Spec.Containers) != len(desired.Spec.JobTemplate.Spec.Template.Spec.Containers) + len(existing.Spec.JobTemplate.Spec.Template.Spec.Containers) != len(desired.Spec.JobTemplate.Spec.Template.Spec.Containers) || + existing.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName != desired.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName if needsUpdate { log.Info("updating snapshot CronJob", diff --git a/internal/controller/snapshot_test.go b/internal/controller/snapshot_test.go index 04fd539..d4d20f9 100644 --- a/internal/controller/snapshot_test.go +++ b/internal/controller/snapshot_test.go @@ -13,6 +13,11 @@ import ( memgraphv1alpha1 "github.com/base14/memgraph-operator/api/v1alpha1" ) +const ( + testContainerNameCopySnapshots = "copy-snapshots" + testContainerNameRcloneUpload = "rclone-upload" +) + func TestBuildSnapshotCronJob(t *testing.T) { cluster := &memgraphv1alpha1.MemgraphCluster{ ObjectMeta: metav1.ObjectMeta{ @@ -29,14 +34,13 @@ func TestBuildSnapshotCronJob(t *testing.T) { }, Snapshot: memgraphv1alpha1.SnapshotSpec{ Enabled: true, - Schedule: "0 */6 * * *", // Every 6 hours + Schedule: "0 */6 * * *", }, }, } cronJob := buildSnapshotCronJob(cluster) - // Verify basic properties if cronJob.Name != "test-cluster-snapshot" { t.Errorf("expected name test-cluster-snapshot, got %s", cronJob.Name) } @@ -49,10 +53,9 @@ func TestBuildSnapshotCronJob(t *testing.T) { t.Errorf("expected schedule '0 */6 * * *', got %s", cronJob.Spec.Schedule) } - // Verify init containers (create-snapshot) initContainers := cronJob.Spec.JobTemplate.Spec.Template.Spec.InitContainers if len(initContainers) != 1 { - t.Fatalf("expected 1 init container without S3, got %d", len(initContainers)) + t.Fatalf("expected 1 init container without remote backup, got %d", len(initContainers)) } if initContainers[0].Name != "create-snapshot" { @@ -63,7 +66,6 @@ func TestBuildSnapshotCronJob(t *testing.T) { t.Errorf("expected image 'memgraph/memgraph:2.21.0', got %s", initContainers[0].Image) } - // Verify main container (complete) containers := cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers if len(containers) != 1 { t.Fatalf("expected 1 container, got %d", len(containers)) @@ -85,8 +87,9 @@ func TestBuildSnapshotCronJobWithS3(t *testing.T) { Replicas: 3, Image: "memgraph/memgraph:2.21.0", Snapshot: memgraphv1alpha1.SnapshotSpec{ - Enabled: true, - Schedule: "*/15 * * * *", + Enabled: true, + Schedule: "*/15 * * * *", + ServiceAccountName: "memgraph-sa", S3: &memgraphv1alpha1.S3BackupSpec{ Enabled: true, Bucket: "my-backup-bucket", @@ -100,7 +103,6 @@ func TestBuildSnapshotCronJobWithS3(t *testing.T) { cronJob := buildSnapshotCronJob(cluster) - // Verify init containers (should have 2: create-snapshot and copy-snapshots) initContainers := cronJob.Spec.JobTemplate.Spec.Template.Spec.InitContainers if len(initContainers) != 2 { t.Fatalf("expected 2 init containers with S3, got %d", len(initContainers)) @@ -110,28 +112,26 @@ func TestBuildSnapshotCronJobWithS3(t *testing.T) { t.Errorf("expected first init container name 'create-snapshot', got %s", initContainers[0].Name) } - if initContainers[1].Name != "copy-snapshots" { + if initContainers[1].Name != testContainerNameCopySnapshots { t.Errorf("expected second init container name 'copy-snapshots', got %s", initContainers[1].Name) } - // Verify main container is s3-upload + // Verify main container is rclone-upload containers := cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers if len(containers) != 1 { t.Fatalf("expected 1 container, got %d", len(containers)) } - if containers[0].Name != "s3-upload" { - t.Errorf("expected container name 's3-upload', got %s", containers[0].Name) + if containers[0].Name != testContainerNameRcloneUpload { + t.Errorf("expected container name 'rclone-upload', got %s", containers[0].Name) } - if containers[0].Image != defaultAWSCLIImage { - t.Errorf("expected image '%s', got %s", defaultAWSCLIImage, containers[0].Image) + if containers[0].Image != defaultRcloneImage { + t.Errorf("expected image '%s', got %s", defaultRcloneImage, containers[0].Image) } // Verify S3 environment variables are set envVars := containers[0].Env - - // Check for AWS credentials env vars var hasAccessKey, hasSecretKey, hasRegion bool for _, env := range envVars { if env.Name == "AWS_ACCESS_KEY_ID" { @@ -161,6 +161,12 @@ func TestBuildSnapshotCronJobWithS3(t *testing.T) { t.Error("expected AWS_REGION env var with value us-west-2") } + // Verify service account name + saName := cronJob.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName + if saName != "memgraph-sa" { + t.Errorf("expected serviceAccountName 'memgraph-sa', got %s", saName) + } + // Verify shared volume exists volumes := cronJob.Spec.JobTemplate.Spec.Template.Spec.Volumes var hasSnapshotDataVolume bool @@ -177,6 +183,91 @@ func TestBuildSnapshotCronJobWithS3(t *testing.T) { } } +func TestBuildSnapshotCronJobWithGCS(t *testing.T) { + cluster := &memgraphv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "default", + }, + Spec: memgraphv1alpha1.MemgraphClusterSpec{ + Replicas: 2, + Image: "memgraph/memgraph:3.7.2", + Snapshot: memgraphv1alpha1.SnapshotSpec{ + Enabled: true, + Schedule: "0 * * * *", + ServiceAccountName: "memgraph-sa", + GCS: &memgraphv1alpha1.GCSBackupSpec{ + Enabled: true, + Bucket: "my-gcs-bucket", + Prefix: "kb/snapshots", + }, + }, + }, + } + + cronJob := buildSnapshotCronJob(cluster) + + // Verify init containers (should have 2: create-snapshot and copy-snapshots) + initContainers := cronJob.Spec.JobTemplate.Spec.Template.Spec.InitContainers + if len(initContainers) != 2 { + t.Fatalf("expected 2 init containers with GCS, got %d", len(initContainers)) + } + + if initContainers[1].Name != testContainerNameCopySnapshots { + t.Errorf("expected second init container name 'copy-snapshots', got %s", initContainers[1].Name) + } + + // Verify main container is rclone-upload + containers := cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers + if len(containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(containers)) + } + + if containers[0].Name != testContainerNameRcloneUpload { + t.Errorf("expected container name 'rclone-upload', got %s", containers[0].Name) + } + + if containers[0].Image != defaultRcloneImage { + t.Errorf("expected image '%s', got %s", defaultRcloneImage, containers[0].Image) + } + + // GCS uses Workload Identity - no env vars needed + if len(containers[0].Env) != 0 { + t.Errorf("expected 0 env vars for GCS (uses Workload Identity), got %d", len(containers[0].Env)) + } + + // Verify rclone command uses GCS backend + if !strings.Contains(containers[0].Args[0], ":gcs:my-gcs-bucket") { + t.Error("expected command to contain ':gcs:my-gcs-bucket'") + } + + if !strings.Contains(containers[0].Args[0], "--gcs-env-auth") { + t.Error("expected command to contain '--gcs-env-auth'") + } + + if !strings.Contains(containers[0].Args[0], "kb/snapshots") { + t.Error("expected command to contain 'kb/snapshots' prefix") + } + + // Verify service account name + saName := cronJob.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName + if saName != "memgraph-sa" { + t.Errorf("expected serviceAccountName 'memgraph-sa', got %s", saName) + } + + // Verify shared volume + volumes := cronJob.Spec.JobTemplate.Spec.Template.Spec.Volumes + var hasSnapshotDataVolume bool + for _, vol := range volumes { + if vol.Name == snapshotDataVolume { + hasSnapshotDataVolume = true + } + } + if !hasSnapshotDataVolume { + t.Error("expected snapshot-data volume") + } +} + func TestBuildSnapshotCronJobDefaults(t *testing.T) { cluster := &memgraphv1alpha1.MemgraphCluster{ ObjectMeta: metav1.ObjectMeta{ @@ -186,19 +277,16 @@ func TestBuildSnapshotCronJobDefaults(t *testing.T) { Spec: memgraphv1alpha1.MemgraphClusterSpec{ Snapshot: memgraphv1alpha1.SnapshotSpec{ Enabled: true, - // No schedule specified - should use default }, }, } cronJob := buildSnapshotCronJob(cluster) - // Verify default schedule if cronJob.Spec.Schedule != "*/15 * * * *" { t.Errorf("expected default schedule '*/15 * * * *', got %s", cronJob.Spec.Schedule) } - // Verify default image for init container initContainers := cronJob.Spec.JobTemplate.Spec.Template.Spec.InitContainers if len(initContainers) != 1 { t.Fatalf("expected 1 init container, got %d", len(initContainers)) @@ -209,6 +297,49 @@ func TestBuildSnapshotCronJobDefaults(t *testing.T) { } } +func TestBuildSnapshotCronJobServiceAccountName(t *testing.T) { + cluster := &memgraphv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "default", + }, + Spec: memgraphv1alpha1.MemgraphClusterSpec{ + Snapshot: memgraphv1alpha1.SnapshotSpec{ + Enabled: true, + ServiceAccountName: "custom-sa", + }, + }, + } + + cronJob := buildSnapshotCronJob(cluster) + + saName := cronJob.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName + if saName != "custom-sa" { + t.Errorf("expected serviceAccountName 'custom-sa', got %s", saName) + } +} + +func TestBuildSnapshotCronJobNoServiceAccountName(t *testing.T) { + cluster := &memgraphv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "default", + }, + Spec: memgraphv1alpha1.MemgraphClusterSpec{ + Snapshot: memgraphv1alpha1.SnapshotSpec{ + Enabled: true, + }, + }, + } + + cronJob := buildSnapshotCronJob(cluster) + + saName := cronJob.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName + if saName != "" { + t.Errorf("expected empty serviceAccountName, got %s", saName) + } +} + func TestBuildSnapshotInitContainers(t *testing.T) { cluster := &memgraphv1alpha1.MemgraphCluster{ ObjectMeta: metav1.ObjectMeta{ @@ -225,21 +356,18 @@ func TestBuildSnapshotInitContainers(t *testing.T) { initContainers := buildSnapshotInitContainers(cluster, "memgraph/memgraph:2.21.0") if len(initContainers) != 1 { - t.Fatalf("expected 1 init container without S3, got %d", len(initContainers)) + t.Fatalf("expected 1 init container without remote backup, got %d", len(initContainers)) } - // Verify the create-snapshot container command references write service args := initContainers[0].Args if len(args) != 1 { t.Fatalf("expected 1 arg, got %d", len(args)) } - // Command should contain the write service name if !strings.Contains(args[0], "my-cluster-write") { t.Error("expected command to contain 'my-cluster-write'") } - // Command should contain CREATE SNAPSHOT if !strings.Contains(args[0], "CREATE SNAPSHOT") { t.Error("expected command to contain 'CREATE SNAPSHOT'") } @@ -268,8 +396,7 @@ func TestBuildSnapshotInitContainersWithS3(t *testing.T) { t.Fatalf("expected 2 init containers with S3, got %d", len(initContainers)) } - // Second container should be copy-snapshots using bitnami/kubectl - if initContainers[1].Name != "copy-snapshots" { + if initContainers[1].Name != testContainerNameCopySnapshots { t.Errorf("expected second init container name 'copy-snapshots', got %s", initContainers[1].Name) } @@ -277,7 +404,6 @@ func TestBuildSnapshotInitContainersWithS3(t *testing.T) { t.Errorf("expected image 'bitnami/kubectl:latest', got %s", initContainers[1].Image) } - // Verify volume mount if len(initContainers[1].VolumeMounts) != 1 { t.Fatalf("expected 1 volume mount, got %d", len(initContainers[1].VolumeMounts)) } @@ -287,7 +413,35 @@ func TestBuildSnapshotInitContainersWithS3(t *testing.T) { } } -func TestBuildS3UploadContainer(t *testing.T) { +func TestBuildSnapshotInitContainersWithGCS(t *testing.T) { + cluster := &memgraphv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cluster", + Namespace: "default", + }, + Spec: memgraphv1alpha1.MemgraphClusterSpec{ + Snapshot: memgraphv1alpha1.SnapshotSpec{ + Enabled: true, + GCS: &memgraphv1alpha1.GCSBackupSpec{ + Enabled: true, + Bucket: "gcs-bucket", + }, + }, + }, + } + + initContainers := buildSnapshotInitContainers(cluster, "memgraph/memgraph:2.21.0") + + if len(initContainers) != 2 { + t.Fatalf("expected 2 init containers with GCS, got %d", len(initContainers)) + } + + if initContainers[1].Name != testContainerNameCopySnapshots { + t.Errorf("expected second init container name 'copy-snapshots', got %s", initContainers[1].Name) + } +} + +func TestBuildRcloneUploadContainerS3(t *testing.T) { cluster := &memgraphv1alpha1.MemgraphCluster{ ObjectMeta: metav1.ObjectMeta{ Name: "my-cluster", @@ -298,6 +452,7 @@ func TestBuildS3UploadContainer(t *testing.T) { S3: &memgraphv1alpha1.S3BackupSpec{ Enabled: true, Bucket: "backup-bucket", + Region: "us-west-2", Prefix: "memgraph/snapshots", Endpoint: "https://minio.local:9000", }, @@ -305,18 +460,16 @@ func TestBuildS3UploadContainer(t *testing.T) { }, } - container := buildS3UploadContainer(cluster) + container := buildRcloneUploadContainer(cluster, "s3") - // Verify container name and image - if container.Name != "s3-upload" { - t.Errorf("expected container name 's3-upload', got %s", container.Name) + if container.Name != testContainerNameRcloneUpload { + t.Errorf("expected container name 'rclone-upload', got %s", container.Name) } - if container.Image != defaultAWSCLIImage { - t.Errorf("expected image '%s', got %s", defaultAWSCLIImage, container.Image) + if container.Image != defaultRcloneImage { + t.Errorf("expected image '%s', got %s", defaultRcloneImage, container.Image) } - // Verify command contains bucket if len(container.Args) != 1 { t.Fatalf("expected 1 arg, got %d", len(container.Args)) } @@ -325,12 +478,18 @@ func TestBuildS3UploadContainer(t *testing.T) { t.Error("expected command to contain 'backup-bucket'") } - // Verify aws s3 cp command - if !strings.Contains(container.Args[0], "aws s3 cp") { - t.Error("expected command to contain 'aws s3 cp'") + if !strings.Contains(container.Args[0], "rclone copy") { + t.Error("expected command to contain 'rclone copy'") + } + + if !strings.Contains(container.Args[0], "--s3-endpoint https://minio.local:9000") { + t.Error("expected command to contain '--s3-endpoint https://minio.local:9000'") + } + + if !strings.Contains(container.Args[0], "--s3-region us-west-2") { + t.Error("expected command to contain '--s3-region us-west-2'") } - // Verify volume mount if len(container.VolumeMounts) != 1 { t.Fatalf("expected 1 volume mount, got %d", len(container.VolumeMounts)) } @@ -340,33 +499,94 @@ func TestBuildS3UploadContainer(t *testing.T) { } } -func TestBuildS3EndpointConfig(t *testing.T) { - tests := []struct { - name string - s3 *memgraphv1alpha1.S3BackupSpec - expected string - }{ - { - name: "no endpoint", - s3: &memgraphv1alpha1.S3BackupSpec{}, - expected: "", +func TestBuildRcloneUploadContainerGCS(t *testing.T) { + cluster := &memgraphv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cluster", + Namespace: "default", }, - { - name: "with endpoint", - s3: &memgraphv1alpha1.S3BackupSpec{ - Endpoint: "https://minio.local:9000", + Spec: memgraphv1alpha1.MemgraphClusterSpec{ + Snapshot: memgraphv1alpha1.SnapshotSpec{ + GCS: &memgraphv1alpha1.GCSBackupSpec{ + Enabled: true, + Bucket: "gcs-backup-bucket", + Prefix: "kb/snapshots", + }, }, - expected: `export AWS_ENDPOINT_URL="https://minio.local:9000"`, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := buildS3EndpointConfig(tt.s3) - if result != tt.expected { - t.Errorf("expected %q, got %q", tt.expected, result) - } - }) + container := buildRcloneUploadContainer(cluster, "gcs") + + if container.Name != testContainerNameRcloneUpload { + t.Errorf("expected container name 'rclone-upload', got %s", container.Name) + } + + if container.Image != defaultRcloneImage { + t.Errorf("expected image '%s', got %s", defaultRcloneImage, container.Image) + } + + if !strings.Contains(container.Args[0], ":gcs:gcs-backup-bucket") { + t.Error("expected command to contain ':gcs:gcs-backup-bucket'") + } + + if !strings.Contains(container.Args[0], "--gcs-env-auth") { + t.Error("expected command to contain '--gcs-env-auth'") + } + + if !strings.Contains(container.Args[0], "kb/snapshots") { + t.Error("expected command to contain 'kb/snapshots' prefix") + } + + // GCS should have no env vars (uses Workload Identity) + if len(container.Env) != 0 { + t.Errorf("expected 0 env vars for GCS, got %d", len(container.Env)) + } +} + +func TestBuildRcloneS3CommandDefaultPrefix(t *testing.T) { + cluster := &memgraphv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cluster", + Namespace: "default", + }, + Spec: memgraphv1alpha1.MemgraphClusterSpec{ + Snapshot: memgraphv1alpha1.SnapshotSpec{ + S3: &memgraphv1alpha1.S3BackupSpec{ + Enabled: true, + Bucket: "backup-bucket", + }, + }, + }, + } + + cmd := buildRcloneS3Command(cluster) + + if !strings.Contains(cmd, "memgraph/snapshots") { + t.Error("expected default prefix 'memgraph/snapshots' in command") + } +} + +func TestBuildRcloneGCSCommandDefaultPrefix(t *testing.T) { + cluster := &memgraphv1alpha1.MemgraphCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cluster", + Namespace: "default", + }, + Spec: memgraphv1alpha1.MemgraphClusterSpec{ + Snapshot: memgraphv1alpha1.SnapshotSpec{ + GCS: &memgraphv1alpha1.GCSBackupSpec{ + Enabled: true, + Bucket: "gcs-bucket", + }, + }, + }, + } + + cmd := buildRcloneGCSCommand(cluster) + + if !strings.Contains(cmd, "memgraph/snapshots") { + t.Error("expected default prefix 'memgraph/snapshots' in command") } } @@ -390,7 +610,6 @@ func TestBuildS3Env(t *testing.T) { envVars := buildS3Env(cluster) - // Should have 3 env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION if len(envVars) != 3 { t.Fatalf("expected 3 env vars, got %d", len(envVars)) } @@ -400,7 +619,6 @@ func TestBuildS3Env(t *testing.T) { envMap[env.Name] = env } - // Check AWS_ACCESS_KEY_ID if env, ok := envMap["AWS_ACCESS_KEY_ID"]; !ok { t.Error("expected AWS_ACCESS_KEY_ID") } else if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { @@ -409,7 +627,6 @@ func TestBuildS3Env(t *testing.T) { t.Errorf("expected key 'access-key-id', got %s", env.ValueFrom.SecretKeyRef.Key) } - // Check AWS_SECRET_ACCESS_KEY if env, ok := envMap["AWS_SECRET_ACCESS_KEY"]; !ok { t.Error("expected AWS_SECRET_ACCESS_KEY") } else if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { @@ -418,7 +635,6 @@ func TestBuildS3Env(t *testing.T) { t.Errorf("expected key 'secret-access-key', got %s", env.ValueFrom.SecretKeyRef.Key) } - // Check AWS_REGION if env, ok := envMap["AWS_REGION"]; !ok { t.Error("expected AWS_REGION") } else if env.Value != "us-east-1" { @@ -433,7 +649,7 @@ func TestBuildSnapshotVolumes(t *testing.T) { expectedVolumes int }{ { - name: "no S3 - no volumes", + name: "no remote backup - no volumes", cluster: &memgraphv1alpha1.MemgraphCluster{ Spec: memgraphv1alpha1.MemgraphClusterSpec{ Snapshot: memgraphv1alpha1.SnapshotSpec{ @@ -458,6 +674,21 @@ func TestBuildSnapshotVolumes(t *testing.T) { }, expectedVolumes: 1, }, + { + name: "with GCS - has snapshot-data volume", + cluster: &memgraphv1alpha1.MemgraphCluster{ + Spec: memgraphv1alpha1.MemgraphClusterSpec{ + Snapshot: memgraphv1alpha1.SnapshotSpec{ + Enabled: true, + GCS: &memgraphv1alpha1.GCSBackupSpec{ + Enabled: true, + Bucket: "gcs-bucket", + }, + }, + }, + }, + expectedVolumes: 1, + }, } for _, tt := range tests { @@ -486,7 +717,7 @@ func TestBuildSnapshotMainContainers(t *testing.T) { expectedContainer string }{ { - name: "without S3 - complete container", + name: "without remote backup - complete container", cluster: &memgraphv1alpha1.MemgraphCluster{ Spec: memgraphv1alpha1.MemgraphClusterSpec{ Snapshot: memgraphv1alpha1.SnapshotSpec{ @@ -497,7 +728,7 @@ func TestBuildSnapshotMainContainers(t *testing.T) { expectedContainer: "complete", }, { - name: "with S3 - s3-upload container", + name: "with S3 - rclone-upload container", cluster: &memgraphv1alpha1.MemgraphCluster{ Spec: memgraphv1alpha1.MemgraphClusterSpec{ Snapshot: memgraphv1alpha1.SnapshotSpec{ @@ -509,7 +740,22 @@ func TestBuildSnapshotMainContainers(t *testing.T) { }, }, }, - expectedContainer: "s3-upload", + expectedContainer: testContainerNameRcloneUpload, + }, + { + name: "with GCS - rclone-upload container", + cluster: &memgraphv1alpha1.MemgraphCluster{ + Spec: memgraphv1alpha1.MemgraphClusterSpec{ + Snapshot: memgraphv1alpha1.SnapshotSpec{ + Enabled: true, + GCS: &memgraphv1alpha1.GCSBackupSpec{ + Enabled: true, + Bucket: "gcs-bucket", + }, + }, + }, + }, + expectedContainer: testContainerNameRcloneUpload, }, } @@ -526,6 +772,68 @@ func TestBuildSnapshotMainContainers(t *testing.T) { } } +func TestIsRemoteBackupEnabled(t *testing.T) { + tests := []struct { + name string + cluster *memgraphv1alpha1.MemgraphCluster + expected bool + }{ + { + name: "no backup", + cluster: &memgraphv1alpha1.MemgraphCluster{ + Spec: memgraphv1alpha1.MemgraphClusterSpec{ + Snapshot: memgraphv1alpha1.SnapshotSpec{Enabled: true}, + }, + }, + expected: false, + }, + { + name: "S3 enabled", + cluster: &memgraphv1alpha1.MemgraphCluster{ + Spec: memgraphv1alpha1.MemgraphClusterSpec{ + Snapshot: memgraphv1alpha1.SnapshotSpec{ + Enabled: true, + S3: &memgraphv1alpha1.S3BackupSpec{Enabled: true, Bucket: "b"}, + }, + }, + }, + expected: true, + }, + { + name: "GCS enabled", + cluster: &memgraphv1alpha1.MemgraphCluster{ + Spec: memgraphv1alpha1.MemgraphClusterSpec{ + Snapshot: memgraphv1alpha1.SnapshotSpec{ + Enabled: true, + GCS: &memgraphv1alpha1.GCSBackupSpec{Enabled: true, Bucket: "b"}, + }, + }, + }, + expected: true, + }, + { + name: "S3 present but disabled", + cluster: &memgraphv1alpha1.MemgraphCluster{ + Spec: memgraphv1alpha1.MemgraphClusterSpec{ + Snapshot: memgraphv1alpha1.SnapshotSpec{ + Enabled: true, + S3: &memgraphv1alpha1.S3BackupSpec{Enabled: false}, + }, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isRemoteBackupEnabled(tt.cluster); got != tt.expected { + t.Errorf("isRemoteBackupEnabled() = %v, want %v", got, tt.expected) + } + }) + } +} + func TestBuildS3EnvWithoutSecretRef(t *testing.T) { cluster := &memgraphv1alpha1.MemgraphCluster{ Spec: memgraphv1alpha1.MemgraphClusterSpec{ @@ -533,7 +841,6 @@ func TestBuildS3EnvWithoutSecretRef(t *testing.T) { S3: &memgraphv1alpha1.S3BackupSpec{ Enabled: true, Region: "eu-west-1", - // No SecretRef }, }, }, @@ -541,7 +848,6 @@ func TestBuildS3EnvWithoutSecretRef(t *testing.T) { envVars := buildS3Env(cluster) - // Should have 1 env var: AWS_REGION if len(envVars) != 1 { t.Fatalf("expected 1 env var, got %d", len(envVars)) } @@ -572,21 +878,18 @@ func TestBuildS3EnvWithNilS3(t *testing.T) { } func TestPtr(t *testing.T) { - // Test with bool b := true ptrB := ptr(b) if ptrB == nil || *ptrB != true { t.Error("ptr() for bool failed") } - // Test with int i := 42 ptrI := ptr(i) if ptrI == nil || *ptrI != 42 { t.Error("ptr() for int failed") } - // Test with string s := "test" ptrS := ptr(s) if ptrS == nil || *ptrS != "test" { @@ -594,31 +897,6 @@ func TestPtr(t *testing.T) { } } -func TestBuildS3UploadContainerDefaultPrefix(t *testing.T) { - cluster := &memgraphv1alpha1.MemgraphCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-cluster", - Namespace: "default", - }, - Spec: memgraphv1alpha1.MemgraphClusterSpec{ - Snapshot: memgraphv1alpha1.SnapshotSpec{ - S3: &memgraphv1alpha1.S3BackupSpec{ - Enabled: true, - Bucket: "backup-bucket", - // No prefix - should use default - }, - }, - }, - } - - container := buildS3UploadContainer(cluster) - - // Verify default prefix is used - if !strings.Contains(container.Args[0], "memgraph/snapshots") { - t.Error("expected default prefix 'memgraph/snapshots' in command") - } -} - func TestNewSnapshotManager(t *testing.T) { sm := NewSnapshotManager(nil) if sm == nil {