init soteria service
This commit is contained in:
commit
ddb41711fd
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
|
/tmp/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@ -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"]
|
||||||
98
README.md
Normal file
98
README.md
Normal file
@ -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`).
|
||||||
70
cmd/soteria/main.go
Normal file
70
cmd/soteria/main.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
deploy/clusterrole.yaml
Normal file
13
deploy/clusterrole.yaml
Normal file
@ -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"]
|
||||||
14
deploy/clusterrolebinding.yaml
Normal file
14
deploy/clusterrolebinding.yaml
Normal file
@ -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
|
||||||
13
deploy/configmap.yaml
Normal file
13
deploy/configmap.yaml
Normal file
@ -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: ""
|
||||||
64
deploy/deployment.yaml
Normal file
64
deploy/deployment.yaml
Normal file
@ -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"]
|
||||||
11
deploy/kustomization.yaml
Normal file
11
deploy/kustomization.yaml
Normal file
@ -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
|
||||||
6
deploy/namespace.yaml
Normal file
6
deploy/namespace.yaml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: soteria
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: soteria
|
||||||
12
deploy/secret-example.yaml
Normal file
12
deploy/secret-example.yaml
Normal file
@ -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"
|
||||||
16
deploy/service.yaml
Normal file
16
deploy/service.yaml
Normal file
@ -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
|
||||||
7
deploy/serviceaccount.yaml
Normal file
7
deploy/serviceaccount.yaml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: soteria
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: soteria
|
||||||
|
app.kubernetes.io/component: api
|
||||||
48
go.mod
Normal file
48
go.mod
Normal file
@ -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
|
||||||
|
)
|
||||||
129
go.sum
Normal file
129
go.sum
Normal file
@ -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=
|
||||||
30
internal/api/types.go
Normal file
30
internal/api/types.go
Normal file
@ -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"`
|
||||||
|
}
|
||||||
101
internal/config/config.go
Normal file
101
internal/config/config.go
Normal file
@ -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
|
||||||
|
}
|
||||||
35
internal/k8s/client.go
Normal file
35
internal/k8s/client.go
Normal file
@ -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
|
||||||
|
}
|
||||||
372
internal/k8s/jobs.go
Normal file
372
internal/k8s/jobs.go
Normal file
@ -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
|
||||||
|
}
|
||||||
110
internal/server/server.go
Normal file
110
internal/server/server.go
Normal file
@ -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})
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user