package k8s import ( "fmt" "strconv" "strings" "scm.bstein.dev/bstein/soteria/internal/api" "scm.bstein.dev/bstein/soteria/internal/config" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func buildBackupJob(cfg *config.Config, req api.BackupRequest, jobName, secretName, repository string, dedupeEnabled bool, keepLast int) *batchv1.Job { labels := map[string]string{ labelAppName: "soteria", labelComponent: "backup", labelAction: "backup", labelPVC: req.PVC, } annotations := map[string]string{ annotationResticRepository: repository, annotationDedupeEnabled: strconv.FormatBool(dedupeEnabled), annotationKeepLast: strconv.Itoa(keepLast), } command := backupCommand(cfg, req) pod := corev1.PodSpec{ RestartPolicy: corev1.RestartPolicyNever, Containers: []corev1.Container{ { Name: "restic", Image: cfg.ResticImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, Args: []string{command}, Env: resticEnv(cfg, secretName, repository), VolumeMounts: []corev1.VolumeMount{ {Name: "data", MountPath: "/data", ReadOnly: true}, {Name: "cache", MountPath: "/cache"}, }, }, }, Volumes: []corev1.Volume{ { Name: "data", VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ ClaimName: req.PVC, ReadOnly: true, }, }, }, { Name: "cache", VolumeSource: corev1.VolumeSource{ EmptyDir: &corev1.EmptyDirVolumeSource{}, }, }, }, } if len(cfg.JobNodeSelector) > 0 { pod.NodeSelector = cfg.JobNodeSelector } if cfg.WorkerServiceAccount != "" { pod.ServiceAccountName = cfg.WorkerServiceAccount } return &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: jobName, Namespace: req.Namespace, Labels: labels, Annotations: annotations, }, Spec: batchv1.JobSpec{ BackoffLimit: int32Ptr(0), TTLSecondsAfterFinished: int32Ptr(cfg.JobTTLSeconds), Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: annotations}, Spec: pod, }, }, } } func buildRestoreJob(cfg *config.Config, req api.RestoreTestRequest, jobName, secretName, snapshot, repository string) *batchv1.Job { labels := map[string]string{ labelAppName: "soteria", labelComponent: "restore", labelAction: "restore", } annotations := map[string]string{ annotationResticRepository: repository, } command := restoreCommand(snapshot) pod := corev1.PodSpec{ RestartPolicy: corev1.RestartPolicyNever, Containers: []corev1.Container{ { Name: "restic", Image: cfg.ResticImage, ImagePullPolicy: corev1.PullIfNotPresent, Command: []string{"/bin/sh", "-c"}, Args: []string{command}, Env: resticEnv(cfg, secretName, repository), VolumeMounts: []corev1.VolumeMount{ {Name: "restore", MountPath: "/restore"}, {Name: "cache", MountPath: "/cache"}, }, }, }, Volumes: []corev1.Volume{ { Name: "restore", VolumeSource: corev1.VolumeSource{ EmptyDir: &corev1.EmptyDirVolumeSource{}, }, }, { Name: "cache", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, }, }, } if len(cfg.JobNodeSelector) > 0 { pod.NodeSelector = cfg.JobNodeSelector } if req.TargetPVC != "" { pod.Volumes[0] = corev1.Volume{ Name: "restore", VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ ClaimName: req.TargetPVC, ReadOnly: false, }, }, } labels[labelPVC] = req.TargetPVC } if cfg.WorkerServiceAccount != "" { pod.ServiceAccountName = cfg.WorkerServiceAccount } return &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: jobName, Namespace: req.Namespace, Labels: labels, Annotations: annotations, }, Spec: batchv1.JobSpec{ BackoffLimit: int32Ptr(0), TTLSecondsAfterFinished: int32Ptr(cfg.JobTTLSeconds), Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: annotations}, Spec: pod, }, }, } } func backupCommand(cfg *config.Config, req api.BackupRequest) string { mode := "on" if !dedupeEnabled(req.Dedupe) { mode = "off" } keepLast := keepLastWithDefault(req.KeepLast) args := []string{ "restic", "backup", "/data", "--host", "soteria", "--tag", "soteria", "--tag", fmt.Sprintf("pvc=%s", req.PVC), "--tag", fmt.Sprintf("dedupe=%s", mode), } for _, tag := range req.Tags { tag = strings.TrimSpace(tag) if tag == "" { continue } args = append(args, "--tag", tag) } args = append(args, cfg.ResticBackupArgs...) cmd := strings.Join(args, " ") if keepLast > 0 { forget := strings.Join([]string{ "restic", "forget", "--host", "soteria", "--group-by", "host,tags", "--tag", fmt.Sprintf("pvc=%s", req.PVC), "--keep-last", strconv.Itoa(keepLast), "--prune", }, " ") cmd = fmt.Sprintf("%s && %s", cmd, forget) } else if len(cfg.ResticForgetArgs) > 0 { forget := strings.Join(append([]string{"restic", "forget"}, cfg.ResticForgetArgs...), " ") cmd = fmt.Sprintf("%s && %s", cmd, forget) } bootstrap := "restic snapshots >/dev/null 2>&1 || restic init" return "set -euo pipefail; " + bootstrap + "; " + cmd } func restoreCommand(snapshot string) string { return fmt.Sprintf( "set -euo pipefail; rm -rf /cache/restore && mkdir -p /cache/restore; restic restore %s --target /cache/restore; if [ -d /cache/restore/data ]; then cp -a /cache/restore/data/. /restore/; else cp -a /cache/restore/. /restore/; fi", snapshot, ) } func resticEnv(cfg *config.Config, secretName, repository string) []corev1.EnvVar { if strings.TrimSpace(repository) == "" { repository = cfg.ResticRepository } env := []corev1.EnvVar{ {Name: "RESTIC_REPOSITORY", Value: repository}, {Name: "RESTIC_CACHE_DIR", Value: "/cache"}, { Name: "AWS_ACCESS_KEY_ID", ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, Key: "AWS_ACCESS_KEY_ID"}}, }, { Name: "AWS_SECRET_ACCESS_KEY", ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, Key: "AWS_SECRET_ACCESS_KEY"}}, }, { Name: "RESTIC_PASSWORD", ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, Key: "RESTIC_PASSWORD"}}, }, } if cfg.S3Endpoint != "" { env = append(env, corev1.EnvVar{Name: "RESTIC_S3_ENDPOINT", Value: cfg.S3Endpoint}) env = append(env, corev1.EnvVar{Name: "AWS_ENDPOINT", Value: cfg.S3Endpoint}) } if cfg.S3Region != "" { env = append(env, corev1.EnvVar{Name: "AWS_REGION", Value: cfg.S3Region}) env = append(env, corev1.EnvVar{Name: "AWS_DEFAULT_REGION", Value: cfg.S3Region}) } return env } func int32Ptr(val int32) *int32 { return &val }