diff --git a/apps/api/internal/models/service_input.go b/apps/api/internal/models/service_input.go index c48f15b5a..80b96a141 100644 --- a/apps/api/internal/models/service_input.go +++ b/apps/api/internal/models/service_input.go @@ -41,7 +41,7 @@ type CreateServiceInput struct { // Databases (special case) DatabaseType *string `json:"database_type,omitempty"` DatabaseConfig *schema.DatabaseConfig `json:"database_config,omitempty"` - S3BackupBucketID *uuid.UUID `json:"s3_backup_bucket_id,omitempty" format:"uuid" doc:"S3 bucket to store database backups in. Only for databases that support backups, Redis does not"` + S3BackupBucketID *uuid.UUID `json:"s3_backup_bucket_id,omitempty" format:"uuid" doc:"S3 bucket to store database backups in. Only for database services"` BackupSchedule *string `json:"backup_schedule,omitempty" required:"false" doc:"Cron expression for the backup schedule, e.g. '0 0 * * *'"` BackupRetentionCount *int `json:"backup_retention_count,omitempty" required:"false" minimum:"1" doc:"Number of base backups to retain, e.g. 3"` @@ -104,7 +104,7 @@ type UpdateServiceInput struct { // Databases DatabaseConfig *schema.DatabaseConfig `json:"database_config,omitempty"` - S3BackupBucketID *uuid.UUID `json:"s3_backup_bucket_id,omitempty" format:"uuid" doc:"S3 bucket to store database backups in. Only for databases that support backups, Redis does not"` + S3BackupBucketID *uuid.UUID `json:"s3_backup_bucket_id,omitempty" format:"uuid" doc:"S3 bucket to store database backups in. Only for database services"` BackupSchedule *string `json:"backup_schedule,omitempty" required:"false" doc:"Cron expression for the backup schedule, e.g. '0 0 * * *'"` BackupRetentionCount *int `json:"backup_retention_count,omitempty" required:"false" minimum:"1" doc:"Number of base backups to retain, e.g. 3"` diff --git a/apps/api/internal/services/service/backups_test.go b/apps/api/internal/services/service/backups_test.go index 6f480a8d3..5a61d1819 100644 --- a/apps/api/internal/services/service/backups_test.go +++ b/apps/api/internal/services/service/backups_test.go @@ -29,8 +29,11 @@ func TestValidateBackupsSupported(t *testing.T) { redis, err := provider.FetchDatabaseDefinition(context.Background(), "", "redis") require.NoError(t, err) + withoutS3 := &databases.Definition{Name: "Example"} + assert.NoError(t, validateBackupsSupported(schema.ServiceTypeDatabase, postgres)) - assert.ErrorContains(t, validateBackupsSupported(schema.ServiceTypeDatabase, redis), "Redis does not support backups") + assert.NoError(t, validateBackupsSupported(schema.ServiceTypeDatabase, redis)) + assert.ErrorContains(t, validateBackupsSupported(schema.ServiceTypeDatabase, withoutS3), "Example does not support backups") assert.ErrorContains(t, validateBackupsSupported(schema.ServiceTypeGithub, nil), "only apply to database services") assert.ErrorContains(t, validateBackupsSupported(schema.ServiceTypeDockerimage, postgres), "only apply to database services") } diff --git a/apps/api/pkg/databases/assets/definitions/databases/redis/definition.yaml b/apps/api/pkg/databases/assets/definitions/databases/redis/definition.yaml index 0dcd25862..9fe043590 100644 --- a/apps/api/pkg/databases/assets/definitions/databases/redis/definition.yaml +++ b/apps/api/pkg/databases/assets/definitions/databases/redis/definition.yaml @@ -186,3 +186,112 @@ extraInitContainers: # Disable metrics metrics: enabled: false + +{{- if .Parameters.s3.enabled }} +{{- $s3 := .Parameters.s3 }} +{{- $backupPath := printf "s3://%s/%s/backups/%s/" $s3.bucket ($s3.backupPrefix | default "redis") .Name }} +extraObjects: + - apiVersion: batch/v1 + kind: CronJob + metadata: + name: {{ .Name }}-s3-backup + namespace: {{ .Namespace }} + labels: + unbind/usd-type: {{ .Definition.Type | quote }} + unbind/usd-version: {{ .Definition.Version | quote }} + unbind/usd-category: databases + app.kubernetes.io/instance: {{ .Name }} + app.kubernetes.io/managed-by: Helm + {{- range $key, $value := .Parameters.labels }} + {{ $key }}: {{ $value | quote }} + {{- end }} + spec: + schedule: {{ $s3.backupSchedule | default "0 0 * * *" | quote }} + concurrencyPolicy: Forbid + jobTemplate: + spec: + ttlSecondsAfterFinished: 300 + backoffLimit: 2 + template: + spec: + restartPolicy: Never + # Pod 0 is the primary in both standalone and replication mode + initContainers: + - name: dump + image: "docker.io/redis:{{ .Parameters.version | default "8.10" }}" + env: + - name: REDISCLI_AUTH + valueFrom: + secretKeyRef: + name: {{ .Parameters.secretName }} + key: {{ .Parameters.secretKey }} + command: + - /bin/sh + - -ec + - | + redis-cli -h {{ .Name }}-0.{{ .Name }}-headless.{{ .Namespace }}.svc.cluster.local --rdb /backup/dump.rdb + redis-check-rdb /backup/dump.rdb + securityContext: + runAsUser: 999 + runAsGroup: 999 + runAsNonRoot: true + privileged: false + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: backup + mountPath: /backup + containers: + - name: upload + image: amazon/aws-cli:2.37.11 + env: + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: {{ $s3.secretName }} + key: {{ $s3.accessKey | default "access_key_id" }} + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ $s3.secretName }} + key: {{ $s3.secretKey | default "secret_key" }} + {{- if $s3.region }} + - name: AWS_DEFAULT_REGION + value: {{ $s3.region | quote }} + {{- end }} + command: + - /bin/sh + - -ec + - | + BACKUP_PATH={{ $backupPath | quote }} + RETENTION={{ $s3.backupRetention | default 2 }} + + aws s3 cp /backup/dump.rdb "${BACKUP_PATH}$(date -u +%Y%m%d_%H%M%S).rdb" \ + --endpoint-url={{ $s3.endpoint | quote }} --checksum-algorithm CRC32 + + BACKUPS=$(aws s3 ls "$BACKUP_PATH" --endpoint-url={{ $s3.endpoint | quote }} \ + | awk '{print $4}' \ + | grep -E '^[0-9]{8}_[0-9]{6}\.rdb$' \ + | sort || true) + BACKUP_COUNT=$(echo "$BACKUPS" | grep -c . || true) + + if [ "$BACKUP_COUNT" -le "$RETENTION" ]; then + echo "Have $BACKUP_COUNT backups, retention is $RETENTION. Nothing to delete." + exit 0 + fi + + echo "$BACKUPS" | head -n "$((BACKUP_COUNT - RETENTION))" | while read -r file; do + echo "Deleting old backup: $file" + aws s3 rm "${BACKUP_PATH}${file}" --endpoint-url={{ $s3.endpoint | quote }} + done + volumeMounts: + - name: backup + mountPath: /backup + volumes: + - name: backup + emptyDir: {} +{{- end }} diff --git a/apps/api/pkg/databases/assets/definitions/databases/redis/metadata.yaml b/apps/api/pkg/databases/assets/definitions/databases/redis/metadata.yaml index cc1e34988..436de8e90 100644 --- a/apps/api/pkg/databases/assets/definitions/databases/redis/metadata.yaml +++ b/apps/api/pkg/databases/assets/definitions/databases/redis/metadata.yaml @@ -11,6 +11,8 @@ port: 6379 imports: - path: "../../common/base.yaml" as: base + - path: "../../common/s3-schema.yaml" + as: s3Schema - path: "../../common/labels.yaml" as: labelsSchema schema: @@ -38,5 +40,7 @@ schema: description: "Log every write to an append-only file. Unset leaves the chart default and skips the conversion init container" common: $ref: "#/imports/base" + s3: + $ref: "#/imports/s3Schema" labels: $ref: "#/imports/labelsSchema" diff --git a/apps/api/pkg/databases/embed_test.go b/apps/api/pkg/databases/embed_test.go index 10a81fdfa..6f4c4b131 100644 --- a/apps/api/pkg/databases/embed_test.go +++ b/apps/api/pkg/databases/embed_test.go @@ -26,7 +26,7 @@ func TestEmbeddedDefinitionsSupportsBackups(t *testing.T) { "mysql": true, "mongodb": true, "clickhouse": true, - "redis": false, + "redis": true, } for dbType, supportsBackups := range expected { def, err := provider.FetchDatabaseDefinition(context.Background(), "", dbType) diff --git a/apps/api/pkg/databases/renderer_test.go b/apps/api/pkg/databases/renderer_test.go index e97e8eff9..c295b1e4a 100644 --- a/apps/api/pkg/databases/renderer_test.go +++ b/apps/api/pkg/databases/renderer_test.go @@ -2223,3 +2223,58 @@ func TestRedisDefinitionRendersAppendOnly(t *testing.T) { }) } } + +func TestRedisDefinitionRendersS3Backups(t *testing.T) { + provider := NewDatabaseProvider() + renderer := NewDatabaseRenderer() + + render := func(t *testing.T, s3 map[string]any) string { + def, err := provider.FetchDatabaseDefinition(context.Background(), "", "redis") + require.NoError(t, err) + + params := map[string]any{ + "secretName": "test-secret", + "secretKey": "DATABASE_PASSWORD", + "common": map[string]any{"storage": "1Gi", "replicas": int32(1)}, + "s3": s3, + } + result, err := renderer.Render(def, &RenderContext{Name: "test-redis", Namespace: "team-ns", Parameters: params}) + require.NoError(t, err) + return result + } + + t.Run("disabled renders no backup job", func(t *testing.T) { + result := render(t, map[string]any{}) + assert.NotContains(t, result, "extraObjects") + assert.NotContains(t, result, "CronJob") + }) + + t.Run("enabled dumps the primary and uploads to the bucket", func(t *testing.T) { + result := render(t, map[string]any{ + "enabled": true, + "bucket": "backups", + "endpoint": "https://s3.example.com", + "region": "eu-central-1", + "secretName": "bucket-secret", + "backupPrefix": "service-id", + "backupSchedule": "0 */6 * * *", + "backupRetention": 5, + }) + for _, want := range []string{ + "kind: CronJob", + "name: test-redis-s3-backup", + "schedule: 0 */6 * * *", + "redis-cli -h test-redis-0.test-redis-headless.team-ns.svc.cluster.local --rdb /backup/dump.rdb", + "redis-check-rdb /backup/dump.rdb", + "name: REDISCLI_AUTH", + "key: DATABASE_PASSWORD", + "BACKUP_PATH=\"s3://backups/service-id/backups/test-redis/\"", + "RETENTION=5", + "--endpoint-url=\"https://s3.example.com\"", + "name: bucket-secret", + "value: eu-central-1", + } { + assert.Contains(t, result, want) + } + }) +} diff --git a/apps/docs/content/(platform)/services/databases.mdx b/apps/docs/content/(platform)/services/databases.mdx index 6e95b93ca..3a0196680 100644 --- a/apps/docs/content/(platform)/services/databases.mdx +++ b/apps/docs/content/(platform)/services/databases.mdx @@ -46,13 +46,41 @@ Backups go to an S3 compatible bucket. First [add a bucket](/teams/s3-storage) t - **Backup Schedule**: every hour, every 6 or 12 hours, daily, weekly, or a custom cron expression like `0 3 * * *`. - **Backup Retention**: how many backups to keep. Older ones are deleted. -Backups are available for PostgreSQL, MySQL, MongoDB and ClickHouse. Redis has none. +Backups are available for every database. There is no restore button in the UI yet. The backups are standard files in your bucket, written by the backup tools of each database. Test a restore before you need one. +### Restore Redis + +Redis backups are snapshot files in the bucket, at `/backups//