commit ddb41711fdd19c2acc7f5f96a561c8147e3dccef Author: Brad Stein Date: Sat Jan 31 03:34:34 2026 -0300 init soteria service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60e2dd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/bin/ +/dist/ +/tmp/ +*.log +.env +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0550aac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# syntax=docker/dockerfile:1 +FROM --platform=$BUILDPLATFORM golang:1.25.0 AS builder + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ + go build -trimpath -ldflags="-s -w" -o /out/soteria ./cmd/soteria + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=builder /out/soteria /soteria +USER 65532:65532 +EXPOSE 8080 +ENTRYPOINT ["/soteria"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5695b1e --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# soteria + +Soteria is a small in-cluster service that launches restic Jobs to back up PVCs. It is intended to be called by Ariadne (or another controller) and focuses on: + +- Encrypted restic backups to an S3-compatible backend (Backblaze B2 by default). +- On-demand restore tests into an emptyDir or a target PVC. +- Minimal long-running footprint (the backup work happens in Jobs). + +Snapshots are not implemented yet; backups are crash-consistent for the PVC as mounted. + +## API + +### POST /v1/backup + +```json +{ + "namespace": "ai", + "pvc": "llm-cache", + "tags": ["namespace=ai", "service=llm"], + "dry_run": false +} +``` + +Response: + +```json +{ + "job_name": "soteria-backup-llm-cache-20260131-013001", + "namespace": "ai", + "secret": "soteria-soteria-backup-llm-cache-20260131-013001-restic", + "dry_run": false +} +``` + +### POST /v1/restore-test + +```json +{ + "namespace": "ai", + "snapshot": "latest", + "target_pvc": "restore-sandbox", + "dry_run": false +} +``` + +## Configuration + +Environment variables: + +- `SOTERIA_RESTIC_REPOSITORY` (required) Example: `s3:s3.us-west-004.backblazeb2.com/atlas-backups` +- `SOTERIA_RESTIC_SECRET_NAME` (default: `soteria-restic`) +- `SOTERIA_SECRET_NAMESPACE` (default: service namespace) +- `SOTERIA_RESTIC_IMAGE` (default: `restic/restic:0.16.4`) +- `SOTERIA_RESTIC_BACKUP_ARGS` (optional) Extra args for `restic backup` +- `SOTERIA_RESTIC_FORGET_ARGS` (optional) Extra args for `restic forget` (include `--prune` if desired) +- `SOTERIA_S3_ENDPOINT` (optional) Example: `s3.us-west-004.backblazeb2.com` +- `SOTERIA_S3_REGION` (optional) Example: `us-west-004` +- `SOTERIA_JOB_TTL_SECONDS` (default: 86400) +- `SOTERIA_JOB_SERVICE_ACCOUNT` (optional) ServiceAccount for backup Jobs +- `SOTERIA_LISTEN_ADDR` (default: `:8080`) + +The restic repository is encrypted with `RESTIC_PASSWORD` from the secret below. + +## Secrets + +Create a secret named `soteria-restic` in the Soteria namespace (or set `SOTERIA_RESTIC_SECRET_NAME`). Keys required: + +- `AWS_ACCESS_KEY_ID` +- `AWS_SECRET_ACCESS_KEY` +- `RESTIC_PASSWORD` + +The service copies this secret into the target namespace per job and attaches an owner reference so it gets cleaned up with the Job. + +A template is in `deploy/secret-example.yaml` (do not commit real credentials). + +## Deployment + +The `deploy/` folder includes Kustomize-ready manifests: + +- `namespace.yaml` +- `configmap.yaml` (set your repository and endpoint) +- `serviceaccount.yaml` +- `clusterrole.yaml` +- `clusterrolebinding.yaml` +- `deployment.yaml` +- `service.yaml` + +Apply with: + +```sh +kubectl apply -k deploy +``` + +## Notes + +- Backups mount the PVC read-only at `/data` and run `restic backup /data`. +- Restore tests write into `/restore` (either an emptyDir or a target PVC). +- For Backblaze B2, use the S3 endpoint and region for your bucket (example: `s3.us-west-004.backblazeb2.com`). diff --git a/cmd/soteria/main.go b/cmd/soteria/main.go new file mode 100644 index 0000000..e32d34a --- /dev/null +++ b/cmd/soteria/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "scm.bstein.dev/bstein/soteria/internal/config" + "scm.bstein.dev/bstein/soteria/internal/k8s" + "scm.bstein.dev/bstein/soteria/internal/server" +) + +func main() { + log.SetFlags(log.LstdFlags | log.LUTC) + + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + client, err := k8s.New() + if err != nil { + log.Fatalf("k8s client: %v", err) + } + + srv := server.New(cfg, client) + httpServer := &http.Server{ + Addr: cfg.ListenAddr, + Handler: srv.Handler(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 30 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + log.Printf("soteria listening on %s", cfg.ListenAddr) + errCh <- httpServer.ListenAndServe() + }() + + signalCh := make(chan os.Signal, 1) + signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) + + select { + case sig := <-signalCh: + log.Printf("shutdown signal: %s", sig) + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server error: %v", err) + } + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(ctx); err != nil { + log.Printf("shutdown error: %v", err) + } + + err = <-errCh + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("server error: %v", err) + } +} diff --git a/deploy/clusterrole.yaml b/deploy/clusterrole.yaml new file mode 100644 index 0000000..57ed475 --- /dev/null +++ b/deploy/clusterrole.yaml @@ -0,0 +1,13 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: soteria + labels: + app.kubernetes.io/name: soteria +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "create", "update"] + - apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["get", "list", "create"] diff --git a/deploy/clusterrolebinding.yaml b/deploy/clusterrolebinding.yaml new file mode 100644 index 0000000..9d97441 --- /dev/null +++ b/deploy/clusterrolebinding.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: soteria + labels: + app.kubernetes.io/name: soteria +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: soteria +subjects: + - kind: ServiceAccount + name: soteria + namespace: soteria diff --git a/deploy/configmap.yaml b/deploy/configmap.yaml new file mode 100644 index 0000000..507947c --- /dev/null +++ b/deploy/configmap.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: soteria + labels: + app.kubernetes.io/name: soteria + app.kubernetes.io/component: config +data: + SOTERIA_RESTIC_REPOSITORY: "s3:s3.us-west-004.backblazeb2.com/your-bucket" + SOTERIA_S3_ENDPOINT: "s3.us-west-004.backblazeb2.com" + SOTERIA_S3_REGION: "us-west-004" + SOTERIA_RESTIC_BACKUP_ARGS: "" + SOTERIA_RESTIC_FORGET_ARGS: "" diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml new file mode 100644 index 0000000..4ea3a82 --- /dev/null +++ b/deploy/deployment.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: soteria + labels: + app.kubernetes.io/name: soteria + app.kubernetes.io/component: api +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: soteria + app.kubernetes.io/component: api + template: + metadata: + labels: + app.kubernetes.io/name: soteria + app.kubernetes.io/component: api + spec: + serviceAccountName: soteria + containers: + - name: soteria + image: registry.bstein.dev/infra/soteria:0.1.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + envFrom: + - configMapRef: + name: soteria + env: + - name: SOTERIA_SECRET_NAMESPACE + value: "soteria" + - name: SOTERIA_RESTIC_SECRET_NAME + value: "soteria-restic" + - name: SOTERIA_JOB_TTL_SECONDS + value: "86400" + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + readinessProbe: + httpGet: + path: /readyz + port: http + initialDelaySeconds: 2 + periodSeconds: 5 + timeoutSeconds: 2 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 65532 + capabilities: + drop: ["ALL"] diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml new file mode 100644 index 0000000..2dc9c38 --- /dev/null +++ b/deploy/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: soteria +resources: + - namespace.yaml + - configmap.yaml + - serviceaccount.yaml + - clusterrole.yaml + - clusterrolebinding.yaml + - deployment.yaml + - service.yaml diff --git a/deploy/namespace.yaml b/deploy/namespace.yaml new file mode 100644 index 0000000..82683f9 --- /dev/null +++ b/deploy/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: soteria + labels: + app.kubernetes.io/name: soteria diff --git a/deploy/secret-example.yaml b/deploy/secret-example.yaml new file mode 100644 index 0000000..07cfe8c --- /dev/null +++ b/deploy/secret-example.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: soteria-restic + namespace: soteria + labels: + app.kubernetes.io/name: soteria + app.kubernetes.io/component: restic +stringData: + AWS_ACCESS_KEY_ID: "your-b2-key-id" + AWS_SECRET_ACCESS_KEY: "your-b2-application-key" + RESTIC_PASSWORD: "choose-a-strong-password" diff --git a/deploy/service.yaml b/deploy/service.yaml new file mode 100644 index 0000000..a73dbb2 --- /dev/null +++ b/deploy/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: soteria + labels: + app.kubernetes.io/name: soteria + app.kubernetes.io/component: api +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: soteria + app.kubernetes.io/component: api + ports: + - name: http + port: 80 + targetPort: http diff --git a/deploy/serviceaccount.yaml b/deploy/serviceaccount.yaml new file mode 100644 index 0000000..a7798e2 --- /dev/null +++ b/deploy/serviceaccount.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: soteria + labels: + app.kubernetes.io/name: soteria + app.kubernetes.io/component: api diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..90e8f6e --- /dev/null +++ b/go.mod @@ -0,0 +1,48 @@ +module scm.bstein.dev/bstein/soteria + +go 1.25.0 + +require ( + k8s.io/api v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b5f9dbe --- /dev/null +++ b/go.sum @@ -0,0 +1,129 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/api/types.go b/internal/api/types.go new file mode 100644 index 0000000..a9bf558 --- /dev/null +++ b/internal/api/types.go @@ -0,0 +1,30 @@ +package api + +type BackupRequest struct { + Namespace string `json:"namespace"` + PVC string `json:"pvc"` + Tags []string `json:"tags,omitempty"` + Snapshot bool `json:"snapshot"` + DryRun bool `json:"dry_run"` +} + +type BackupResponse struct { + JobName string `json:"job_name"` + Namespace string `json:"namespace"` + Secret string `json:"secret"` + DryRun bool `json:"dry_run"` +} + +type RestoreTestRequest struct { + Namespace string `json:"namespace"` + Snapshot string `json:"snapshot,omitempty"` + TargetPVC string `json:"target_pvc,omitempty"` + DryRun bool `json:"dry_run"` +} + +type RestoreTestResponse struct { + JobName string `json:"job_name"` + Namespace string `json:"namespace"` + Secret string `json:"secret"` + DryRun bool `json:"dry_run"` +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3e4debc --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,101 @@ +package config + +import ( + "errors" + "os" + "strconv" + "strings" +) + +const ( + defaultResticImage = "restic/restic:0.16.4" + defaultJobTTLSeconds = 86400 + defaultListenAddr = ":8080" + defaultResticSecret = "soteria-restic" + serviceNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" +) + +type Config struct { + Namespace string + SecretNamespace string + ResticImage string + ResticRepository string + ResticSecretName string + ResticBackupArgs []string + ResticForgetArgs []string + S3Endpoint string + S3Region string + JobTTLSeconds int32 + WorkerServiceAccount string + ListenAddr string +} + +func Load() (*Config, error) { + cfg := &Config{} + + cfg.Namespace = getenv("SOTERIA_NAMESPACE") + if cfg.Namespace == "" { + ns, _ := os.ReadFile(serviceNamespacePath) + cfg.Namespace = strings.TrimSpace(string(ns)) + } + if cfg.Namespace == "" { + cfg.Namespace = "soteria" + } + + cfg.SecretNamespace = getenv("SOTERIA_SECRET_NAMESPACE") + if cfg.SecretNamespace == "" { + cfg.SecretNamespace = cfg.Namespace + } + + cfg.ResticImage = getenvDefault("SOTERIA_RESTIC_IMAGE", defaultResticImage) + cfg.ResticRepository = getenv("SOTERIA_RESTIC_REPOSITORY") + cfg.ResticSecretName = getenvDefault("SOTERIA_RESTIC_SECRET_NAME", defaultResticSecret) + cfg.ResticBackupArgs = strings.Fields(getenv("SOTERIA_RESTIC_BACKUP_ARGS")) + cfg.ResticForgetArgs = strings.Fields(getenv("SOTERIA_RESTIC_FORGET_ARGS")) + cfg.S3Endpoint = getenv("SOTERIA_S3_ENDPOINT") + cfg.S3Region = getenv("SOTERIA_S3_REGION") + cfg.WorkerServiceAccount = getenv("SOTERIA_JOB_SERVICE_ACCOUNT") + cfg.ListenAddr = getenvDefault("SOTERIA_LISTEN_ADDR", defaultListenAddr) + + if ttl, ok := getenvInt("SOTERIA_JOB_TTL_SECONDS"); ok { + cfg.JobTTLSeconds = int32(ttl) + } else { + cfg.JobTTLSeconds = defaultJobTTLSeconds + } + + if cfg.ResticRepository == "" { + return nil, errors.New("SOTERIA_RESTIC_REPOSITORY is required") + } + if cfg.ResticSecretName == "" { + return nil, errors.New("SOTERIA_RESTIC_SECRET_NAME is required") + } + + if strings.Contains(cfg.ResticRepository, "..") { + return nil, errors.New("SOTERIA_RESTIC_REPOSITORY contains invalid path segments") + } + + return cfg, nil +} + +func getenv(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} + +func getenvDefault(key, value string) string { + if v := getenv(key); v != "" { + return v + } + return value +} + +func getenvInt(key string) (int, bool) { + val := getenv(key) + if val == "" { + return 0, false + } + parsed, err := strconv.Atoi(val) + if err != nil { + return 0, false + } + return parsed, true +} diff --git a/internal/k8s/client.go b/internal/k8s/client.go new file mode 100644 index 0000000..bc99ef4 --- /dev/null +++ b/internal/k8s/client.go @@ -0,0 +1,35 @@ +package k8s + +import ( + "fmt" + "os" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +type Client struct { + Clientset *kubernetes.Clientset +} + +func New() (*Client, error) { + cfg, err := rest.InClusterConfig() + if err != nil { + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + return nil, fmt.Errorf("load in-cluster config: %w", err) + } + cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, fmt.Errorf("load kubeconfig: %w", err) + } + } + + clientset, err := kubernetes.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("build clientset: %w", err) + } + + return &Client{Clientset: clientset}, nil +} diff --git a/internal/k8s/jobs.go b/internal/k8s/jobs.go new file mode 100644 index 0000000..38002c2 --- /dev/null +++ b/internal/k8s/jobs.go @@ -0,0 +1,372 @@ +package k8s + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "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" +) + +const ( + labelAppName = "app.kubernetes.io/name" + labelComponent = "app.kubernetes.io/component" + labelAction = "soteria.bstein.dev/action" + labelPVC = "soteria.bstein.dev/pvc" +) + +func (c *Client) CreateBackupJob(ctx context.Context, cfg *config.Config, req api.BackupRequest) (string, string, error) { + if req.Namespace == "" { + return "", "", errors.New("namespace is required") + } + if req.PVC == "" { + return "", "", errors.New("pvc is required") + } + if req.Snapshot { + return "", "", errors.New("snapshot support is not implemented yet") + } + + jobName := jobName("backup", req.PVC) + secretName := fmt.Sprintf("soteria-%s-restic", jobName) + + if req.DryRun { + return jobName, secretName, nil + } + + secret, err := c.copySecret(ctx, cfg.SecretNamespace, cfg.ResticSecretName, req.Namespace, secretName, map[string]string{ + labelAppName: "soteria", + labelComponent: "restic", + labelAction: "backup", + labelPVC: req.PVC, + }) + if err != nil { + return "", "", err + } + + job := buildBackupJob(cfg, req, jobName, secretName) + created, err := c.Clientset.BatchV1().Jobs(req.Namespace).Create(ctx, job, metav1.CreateOptions{}) + if err != nil { + _ = c.Clientset.CoreV1().Secrets(req.Namespace).Delete(ctx, secretName, metav1.DeleteOptions{}) + return "", "", err + } + + if err := c.bindSecretToJob(ctx, req.Namespace, secret.Name, created); err != nil { + return jobName, secretName, err + } + + return jobName, secretName, nil +} + +func (c *Client) CreateRestoreJob(ctx context.Context, cfg *config.Config, req api.RestoreTestRequest) (string, string, error) { + if req.Namespace == "" { + return "", "", errors.New("namespace is required") + } + + snapshot := req.Snapshot + if snapshot == "" { + snapshot = "latest" + } + + jobName := jobName("restore", snapshot) + secretName := fmt.Sprintf("soteria-%s-restic", jobName) + + if req.DryRun { + return jobName, secretName, nil + } + + secret, err := c.copySecret(ctx, cfg.SecretNamespace, cfg.ResticSecretName, req.Namespace, secretName, map[string]string{ + labelAppName: "soteria", + labelComponent: "restic", + labelAction: "restore", + }) + if err != nil { + return "", "", err + } + + job := buildRestoreJob(cfg, req, jobName, secretName, snapshot) + created, err := c.Clientset.BatchV1().Jobs(req.Namespace).Create(ctx, job, metav1.CreateOptions{}) + if err != nil { + _ = c.Clientset.CoreV1().Secrets(req.Namespace).Delete(ctx, secretName, metav1.DeleteOptions{}) + return "", "", err + } + + if err := c.bindSecretToJob(ctx, req.Namespace, secret.Name, created); err != nil { + return jobName, secretName, err + } + + return jobName, secretName, nil +} + +func buildBackupJob(cfg *config.Config, req api.BackupRequest, jobName, secretName string) *batchv1.Job { + labels := map[string]string{ + labelAppName: "soteria", + labelComponent: "backup", + labelAction: "backup", + labelPVC: req.PVC, + } + + 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), + 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 cfg.WorkerServiceAccount != "" { + pod.ServiceAccountName = cfg.WorkerServiceAccount + } + + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: jobName, + Namespace: req.Namespace, + Labels: labels, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: int32Ptr(0), + TTLSecondsAfterFinished: int32Ptr(cfg.JobTTLSeconds), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: labels}, + Spec: pod, + }, + }, + } +} + +func buildRestoreJob(cfg *config.Config, req api.RestoreTestRequest, jobName, secretName, snapshot string) *batchv1.Job { + labels := map[string]string{ + labelAppName: "soteria", + labelComponent: "restore", + labelAction: "restore", + } + + 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), + 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 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, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: int32Ptr(0), + TTLSecondsAfterFinished: int32Ptr(cfg.JobTTLSeconds), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: labels}, + Spec: pod, + }, + }, + } +} + +func backupCommand(cfg *config.Config, req api.BackupRequest) string { + args := []string{"restic", "backup", "/data", "--tag", "soteria", "--tag", fmt.Sprintf("pvc=%s", req.PVC)} + 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 len(cfg.ResticForgetArgs) > 0 { + forget := strings.Join(append([]string{"restic", "forget"}, cfg.ResticForgetArgs...), " ") + cmd = fmt.Sprintf("%s && %s", cmd, forget) + } + + return "set -euo pipefail; " + cmd +} + +func restoreCommand(snapshot string) string { + return fmt.Sprintf("set -euo pipefail; restic restore %s --target /restore", snapshot) +} + +func resticEnv(cfg *config.Config, secretName string) []corev1.EnvVar { + env := []corev1.EnvVar{ + {Name: "RESTIC_REPOSITORY", Value: cfg.ResticRepository}, + {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 (c *Client) copySecret(ctx context.Context, srcNS, srcName, dstNS, dstName string, labels map[string]string) (*corev1.Secret, error) { + secret, err := c.Clientset.CoreV1().Secrets(srcNS).Get(ctx, srcName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("read secret %s/%s: %w", srcNS, srcName, err) + } + + copy := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: dstName, + Namespace: dstNS, + Labels: labels, + }, + Type: secret.Type, + Data: secret.Data, + } + + created, err := c.Clientset.CoreV1().Secrets(dstNS).Create(ctx, copy, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("create secret %s/%s: %w", dstNS, dstName, err) + } + + return created, nil +} + +func (c *Client) bindSecretToJob(ctx context.Context, namespace, secretName string, job *batchv1.Job) error { + secret, err := c.Clientset.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{}) + if err != nil { + return err + } + + controller := true + secret.OwnerReferences = append(secret.OwnerReferences, metav1.OwnerReference{ + APIVersion: "batch/v1", + Kind: "Job", + Name: job.Name, + UID: job.UID, + Controller: &controller, + }) + + _, err = c.Clientset.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{}) + return err +} + +func jobName(action, suffix string) string { + base := sanitizeName(fmt.Sprintf("soteria-%s-%s", action, suffix)) + timestamp := time.Now().UTC().Format("20060102-150405") + name := fmt.Sprintf("%s-%s", base, timestamp) + if len(name) <= 63 { + return name + } + trimmed := base + maxBase := 63 - len(timestamp) - 1 + if maxBase < 1 { + maxBase = 1 + } + if len(trimmed) > maxBase { + trimmed = trimmed[:maxBase] + } + return fmt.Sprintf("%s-%s", trimmed, timestamp) +} + +func sanitizeName(value string) string { + value = strings.ToLower(value) + value = strings.ReplaceAll(value, "_", "-") + value = strings.ReplaceAll(value, ".", "-") + value = strings.ReplaceAll(value, " ", "-") + value = strings.Trim(value, "-") + return value +} + +func int32Ptr(val int32) *int32 { + return &val +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..a8d6c5b --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,110 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + + "scm.bstein.dev/bstein/soteria/internal/api" + "scm.bstein.dev/bstein/soteria/internal/config" + "scm.bstein.dev/bstein/soteria/internal/k8s" +) + +type Server struct { + cfg *config.Config + client *k8s.Client + mux *http.ServeMux +} + +func New(cfg *config.Config, client *k8s.Client) *Server { + s := &Server{ + cfg: cfg, + client: client, + mux: http.NewServeMux(), + } + + s.mux.HandleFunc("/healthz", s.handleHealth) + s.mux.HandleFunc("/readyz", s.handleReady) + s.mux.HandleFunc("/v1/backup", s.handleBackup) + s.mux.HandleFunc("/v1/restore-test", s.handleRestore) + + return s +} + +func (s *Server) Handler() http.Handler { + return s.mux +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) +} + +func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + var req api.BackupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON: %v", err)) + return + } + + jobName, secretName, err := s.client.CreateBackupJob(r.Context(), s.cfg, req) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + resp := api.BackupResponse{ + JobName: jobName, + Namespace: req.Namespace, + Secret: secretName, + DryRun: req.DryRun, + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + var req api.RestoreTestRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON: %v", err)) + return + } + + jobName, secretName, err := s.client.CreateRestoreJob(r.Context(), s.cfg, req) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + resp := api.RestoreTestResponse{ + JobName: jobName, + Namespace: req.Namespace, + Secret: secretName, + DryRun: req.DryRun, + } + writeJSON(w, http.StatusOK, resp) +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +}