Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/internal/models/service_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down Expand Up @@ -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"`

Expand Down
5 changes: 4 additions & 1 deletion apps/api/internal/services/service/backups_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
2 changes: 1 addition & 1 deletion apps/api/pkg/databases/embed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions apps/api/pkg/databases/renderer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
32 changes: 31 additions & 1 deletion apps/docs/content/(platform)/services/databases.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout title="Restoring">
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.
</Callout>

### Restore Redis

Redis backups are snapshot files in the bucket, at `<service id>/backups/<name>/<time>.rdb`, named by the UTC time they were taken. Writes made after a backup ran are not in it.

To put one back on its Redis, you need [access to the server](/operations/cluster-access).

1. Download the backup from your bucket to the server as `backup.rdb`.
2. Copy it onto the Redis volume. `<name>` is the folder name from the bucket path. The first command shows the `<namespace>`.

```bash
sudo kubectl get pods -A | grep <name>-0
sudo kubectl cp backup.rdb <namespace>/<name>-0:/data/restore.rdb -c redis
```

3. Swap it in and restart Redis:

```bash
sudo kubectl exec -n <namespace> <name>-0 -c redis -- sh -c '
export REDISCLI_AUTH=$REDIS_PASSWORD
redis-cli CONFIG SET appendonly no
redis-cli CONFIG SET save ""
rm -rf /data/appendonlydir
mv /data/restore.rdb /data/dump.rdb'
sudo kubectl delete pod -n <namespace> <name>-0
```

Redis starts with the data from the backup, and the **Append-Only File** setting applies again. Everything in Redis before the restore is replaced, including writes that arrive during these steps.

## PostgreSQL replication settings

PostgreSQL has a **Database** section in its settings with **WAL Level**. Leave it on `replica` unless a tool asks for logical replication, such as change data capture. With `logical` you can also set **Replication Slots**, **WAL Senders** and **Slot WAL keep size**.
Expand All @@ -66,3 +94,5 @@ Redis keeps its data in memory and writes it to the volume. Its **Database** sec
New Redis databases have it on. Redis databases created before the setting keep snapshots only until you turn it on. Turn it on for anything that keeps state you cannot rebuild, such as job queues or balances.

Changing it restarts the database. Before Redis starts, Unbind converts the data on the volume to the new format, so nothing is lost either way. A large dataset takes longer to start while it converts.

The append-only file protects against a crash, not against losing the volume. For an off-site copy, turn on [backups](#backups).
4 changes: 2 additions & 2 deletions apps/docs/generated/openapi.gen.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ components:
run_command:
type: string
s3_backup_bucket_id:
description: S3 bucket to store database backups in. Only for databases that support backups, Redis does not
description: S3 bucket to store database backups in. Only for database services
format: uuid
type: string
session_affinity:
Expand Down Expand Up @@ -4279,7 +4279,7 @@ components:
run_command:
type: string
s3_backup_bucket_id:
description: S3 bucket to store database backups in. Only for databases that support backups, Redis does not
description: S3 bucket to store database backups in. Only for database services
format: uuid
type: string
service_id:
Expand Down
4 changes: 0 additions & 4 deletions apps/web/src/components/service/backups/backup-config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import type { TAvailableDatabase } from "@/lib/server/client.gen";

export const customScheduleValue = "custom";

export const databaseTypesWithoutBackups: string[] = ["redis"] satisfies TAvailableDatabase[];

export const backupSchedulePresets = [
{ value: "0 * * * *", label: "Every hour" },
{ value: "0 */6 * * *", label: "Every 6 hours" },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { databaseTypesWithoutBackups } from "@/components/service/backups/backup-config";
import { TServiceShallow } from "@/lib/queries/services";

export function shouldServiceSettingsHaveDeploySection(service: TServiceShallow) {
Expand All @@ -22,9 +21,7 @@ export function shouldServiceSettingsHaveCronSchedule(service: TServiceShallow)
}

export function shouldServiceSettingsHaveBackupsSection(service: TServiceShallow) {
if (service.type !== "database") return false;
if (!service.database_type) return false;
return !databaseTypesWithoutBackups.includes(service.database_type);
return service.type === "database";
}

export function shouldServiceSettingsHaveBuildSection(service: TServiceShallow) {
Expand Down
Loading