soteria/internal/k8s/job_manifests.go

256 lines
7.1 KiB
Go

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
}