backup: add configurable keep-last retention

This commit is contained in:
Brad Stein 2026-04-13 13:55:17 -03:00
parent 9a26274242
commit 4b5e4f9e31
5 changed files with 197 additions and 10 deletions

View File

@ -7,6 +7,7 @@ type BackupRequest struct {
Snapshot bool `json:"snapshot"` Snapshot bool `json:"snapshot"`
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
Dedupe *bool `json:"dedupe,omitempty"` Dedupe *bool `json:"dedupe,omitempty"`
KeepLast *int `json:"keep_last,omitempty"`
} }
type BackupResponse struct { type BackupResponse struct {
@ -19,6 +20,7 @@ type BackupResponse struct {
RequestedBy string `json:"requested_by,omitempty"` RequestedBy string `json:"requested_by,omitempty"`
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
Dedupe bool `json:"dedupe"` Dedupe bool `json:"dedupe"`
KeepLast int `json:"keep_last"`
} }
type RestoreTestRequest struct { type RestoreTestRequest struct {
@ -112,6 +114,7 @@ type BackupPolicy struct {
IntervalHours float64 `json:"interval_hours"` IntervalHours float64 `json:"interval_hours"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Dedupe bool `json:"dedupe"` Dedupe bool `json:"dedupe"`
KeepLast int `json:"keep_last"`
CreatedAt string `json:"created_at,omitempty"` CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"`
} }
@ -122,6 +125,7 @@ type BackupPolicyUpsertRequest struct {
IntervalHours float64 `json:"interval_hours"` IntervalHours float64 `json:"interval_hours"`
Enabled *bool `json:"enabled,omitempty"` Enabled *bool `json:"enabled,omitempty"`
Dedupe *bool `json:"dedupe,omitempty"` Dedupe *bool `json:"dedupe,omitempty"`
KeepLast *int `json:"keep_last,omitempty"`
} }
type BackupPolicyListResponse struct { type BackupPolicyListResponse struct {
@ -132,6 +136,7 @@ type NamespaceBackupRequest struct {
Namespace string `json:"namespace"` Namespace string `json:"namespace"`
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
Dedupe *bool `json:"dedupe,omitempty"` Dedupe *bool `json:"dedupe,omitempty"`
KeepLast *int `json:"keep_last,omitempty"`
} }
type NamespaceBackupResult struct { type NamespaceBackupResult struct {
@ -149,6 +154,7 @@ type NamespaceBackupResponse struct {
Driver string `json:"driver"` Driver string `json:"driver"`
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
Dedupe bool `json:"dedupe"` Dedupe bool `json:"dedupe"`
KeepLast int `json:"keep_last"`
Total int `json:"total"` Total int `json:"total"`
Succeeded int `json:"succeeded"` Succeeded int `json:"succeeded"`
Failed int `json:"failed"` Failed int `json:"failed"`

View File

@ -24,6 +24,7 @@ const (
labelPVC = "soteria.bstein.dev/pvc" labelPVC = "soteria.bstein.dev/pvc"
annotationResticRepository = "soteria.bstein.dev/restic-repository" annotationResticRepository = "soteria.bstein.dev/restic-repository"
annotationDedupeEnabled = "soteria.bstein.dev/dedupe-enabled" annotationDedupeEnabled = "soteria.bstein.dev/dedupe-enabled"
annotationKeepLast = "soteria.bstein.dev/keep-last"
) )
type BackupJobSummary struct { type BackupJobSummary struct {
@ -32,6 +33,7 @@ type BackupJobSummary struct {
PVC string PVC string
Repository string Repository string
DedupeEnabled bool DedupeEnabled bool
KeepLast int
CreatedAt time.Time CreatedAt time.Time
CompletionTime time.Time CompletionTime time.Time
State string State string
@ -116,6 +118,7 @@ func summarizeBackupJob(job batchv1.Job, pvc string) BackupJobSummary {
Namespace: job.Namespace, Namespace: job.Namespace,
PVC: pvc, PVC: pvc,
DedupeEnabled: true, DedupeEnabled: true,
KeepLast: 0,
CreatedAt: job.CreationTimestamp.Time, CreatedAt: job.CreationTimestamp.Time,
State: "Pending", State: "Pending",
} }
@ -123,6 +126,7 @@ func summarizeBackupJob(job batchv1.Job, pvc string) BackupJobSummary {
summary.Repository = raw summary.Repository = raw
} }
summary.DedupeEnabled = parseBoolWithDefault(job.Annotations[annotationDedupeEnabled], true) summary.DedupeEnabled = parseBoolWithDefault(job.Annotations[annotationDedupeEnabled], true)
summary.KeepLast = parseIntWithDefault(job.Annotations[annotationKeepLast], 0)
if job.Status.CompletionTime != nil { if job.Status.CompletionTime != nil {
summary.CompletionTime = job.Status.CompletionTime.Time summary.CompletionTime = job.Status.CompletionTime.Time
} }
@ -168,6 +172,7 @@ func (c *Client) CreateBackupJob(ctx context.Context, cfg *config.Config, req ap
jobName := jobName("backup", req.PVC) jobName := jobName("backup", req.PVC)
secretName := fmt.Sprintf("soteria-%s-restic", jobName) secretName := fmt.Sprintf("soteria-%s-restic", jobName)
dedupeEnabled := dedupeEnabled(req.Dedupe) dedupeEnabled := dedupeEnabled(req.Dedupe)
keepLast := keepLastWithDefault(req.KeepLast)
repository := resticRepositoryForBackup(cfg.ResticRepository, req.Namespace, req.PVC, dedupeEnabled) repository := resticRepositoryForBackup(cfg.ResticRepository, req.Namespace, req.PVC, dedupeEnabled)
if req.DryRun { if req.DryRun {
@ -184,7 +189,7 @@ func (c *Client) CreateBackupJob(ctx context.Context, cfg *config.Config, req ap
return "", "", err return "", "", err
} }
job := buildBackupJob(cfg, req, jobName, secretName, repository, dedupeEnabled) job := buildBackupJob(cfg, req, jobName, secretName, repository, dedupeEnabled, keepLast)
if nodeName, err := c.resolvePVCMountedNode(ctx, req.Namespace, req.PVC); err == nil && nodeName != "" { if nodeName, err := c.resolvePVCMountedNode(ctx, req.Namespace, req.PVC); err == nil && nodeName != "" {
job.Spec.Template.Spec.NodeName = nodeName job.Spec.Template.Spec.NodeName = nodeName
} }
@ -272,7 +277,7 @@ func (c *Client) CreateRestoreJob(ctx context.Context, cfg *config.Config, req a
return jobName, secretName, nil return jobName, secretName, nil
} }
func buildBackupJob(cfg *config.Config, req api.BackupRequest, jobName, secretName, repository string, dedupeEnabled bool) *batchv1.Job { func buildBackupJob(cfg *config.Config, req api.BackupRequest, jobName, secretName, repository string, dedupeEnabled bool, keepLast int) *batchv1.Job {
labels := map[string]string{ labels := map[string]string{
labelAppName: "soteria", labelAppName: "soteria",
labelComponent: "backup", labelComponent: "backup",
@ -282,6 +287,7 @@ func buildBackupJob(cfg *config.Config, req api.BackupRequest, jobName, secretNa
annotations := map[string]string{ annotations := map[string]string{
annotationResticRepository: repository, annotationResticRepository: repository,
annotationDedupeEnabled: strconv.FormatBool(dedupeEnabled), annotationDedupeEnabled: strconv.FormatBool(dedupeEnabled),
annotationKeepLast: strconv.Itoa(keepLast),
} }
command := backupCommand(cfg, req) command := backupCommand(cfg, req)
@ -431,8 +437,10 @@ func backupCommand(cfg *config.Config, req api.BackupRequest) string {
if !dedupeEnabled(req.Dedupe) { if !dedupeEnabled(req.Dedupe) {
mode = "off" mode = "off"
} }
keepLast := keepLastWithDefault(req.KeepLast)
args := []string{ args := []string{
"restic", "backup", "/data", "restic", "backup", "/data",
"--host", "soteria",
"--tag", "soteria", "--tag", "soteria",
"--tag", fmt.Sprintf("pvc=%s", req.PVC), "--tag", fmt.Sprintf("pvc=%s", req.PVC),
"--tag", fmt.Sprintf("dedupe=%s", mode), "--tag", fmt.Sprintf("dedupe=%s", mode),
@ -447,7 +455,17 @@ func backupCommand(cfg *config.Config, req api.BackupRequest) string {
args = append(args, cfg.ResticBackupArgs...) args = append(args, cfg.ResticBackupArgs...)
cmd := strings.Join(args, " ") cmd := strings.Join(args, " ")
if len(cfg.ResticForgetArgs) > 0 { if keepLast > 0 {
forget := strings.Join([]string{
"restic", "forget",
"--host", "soteria",
"--group-by", "host,tags",
"--tag", fmt.Sprintf("pvc=%s", req.PVC),
"--keep-last", strconv.Itoa(keepLast),
"--prune",
}, " ")
cmd = fmt.Sprintf("%s && %s", cmd, forget)
} else if len(cfg.ResticForgetArgs) > 0 {
forget := strings.Join(append([]string{"restic", "forget"}, cfg.ResticForgetArgs...), " ") forget := strings.Join(append([]string{"restic", "forget"}, cfg.ResticForgetArgs...), " ")
cmd = fmt.Sprintf("%s && %s", cmd, forget) cmd = fmt.Sprintf("%s && %s", cmd, forget)
} }
@ -573,6 +591,16 @@ func dedupeEnabled(raw *bool) bool {
return *raw return *raw
} }
func keepLastWithDefault(raw *int) int {
if raw == nil {
return 0
}
if *raw < 0 {
return 0
}
return *raw
}
func resticRepositoryForBackup(base, namespace, pvc string, dedupe bool) string { func resticRepositoryForBackup(base, namespace, pvc string, dedupe bool) string {
if dedupe { if dedupe {
return strings.TrimSpace(base) return strings.TrimSpace(base)
@ -626,6 +654,17 @@ func parseBoolWithDefault(raw string, fallback bool) bool {
} }
} }
func parseIntWithDefault(raw string, fallback int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return fallback
}
if parsed < 0 {
return fallback
}
return parsed
}
func int32Ptr(val int32) *int32 { func int32Ptr(val int32) *int32 {
return &val return &val
} }

View File

@ -82,6 +82,7 @@ const (
policySecretKey = "policies.json" policySecretKey = "policies.json"
defaultPolicyHours = 24.0 defaultPolicyHours = 24.0
maxPolicyIntervalHrs = 24 * 365 maxPolicyIntervalHrs = 24 * 365
maxPolicyKeepLast = 1000
maxUsageSampleJobs = 20 maxUsageSampleJobs = 20
resticSelectorPrefix = "restic-latest:" resticSelectorPrefix = "restic-latest:"
) )
@ -331,6 +332,11 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "namespace and pvc are required") writeError(w, http.StatusBadRequest, "namespace and pvc are required")
return return
} }
if err := validateKeepLast(req.KeepLast); err != nil {
s.metrics.RecordBackupRequest(s.cfg.BackupDriver, "validation_error")
writeError(w, http.StatusBadRequest, err.Error())
return
}
req.Namespace = strings.TrimSpace(req.Namespace) req.Namespace = strings.TrimSpace(req.Namespace)
req.PVC = strings.TrimSpace(req.PVC) req.PVC = strings.TrimSpace(req.PVC)
requester := currentRequester(r.Context()) requester := currentRequester(r.Context())
@ -435,6 +441,11 @@ func (s *Server) handleNamespaceBackup(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "namespace is required") writeError(w, http.StatusBadRequest, "namespace is required")
return return
} }
if err := validateKeepLast(req.KeepLast); err != nil {
s.metrics.RecordNamespaceBackupRequest(s.cfg.BackupDriver, "validation_error")
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateKubernetesName("namespace", req.Namespace); err != nil { if err := validateKubernetesName("namespace", req.Namespace); err != nil {
s.metrics.RecordNamespaceBackupRequest(s.cfg.BackupDriver, "validation_error") s.metrics.RecordNamespaceBackupRequest(s.cfg.BackupDriver, "validation_error")
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
@ -450,12 +461,14 @@ func (s *Server) handleNamespaceBackup(w http.ResponseWriter, r *http.Request) {
requester := currentRequester(r.Context()) requester := currentRequester(r.Context())
resolvedDedupe := dedupeDefault(req.Dedupe) resolvedDedupe := dedupeDefault(req.Dedupe)
resolvedKeepLast := keepLastDefault(req.KeepLast)
response := api.NamespaceBackupResponse{ response := api.NamespaceBackupResponse{
Namespace: req.Namespace, Namespace: req.Namespace,
RequestedBy: requester, RequestedBy: requester,
Driver: s.cfg.BackupDriver, Driver: s.cfg.BackupDriver,
DryRun: req.DryRun, DryRun: req.DryRun,
Dedupe: resolvedDedupe, Dedupe: resolvedDedupe,
KeepLast: resolvedKeepLast,
Results: make([]api.NamespaceBackupResult, 0, len(pvcs)), Results: make([]api.NamespaceBackupResult, 0, len(pvcs)),
} }
@ -465,6 +478,7 @@ func (s *Server) handleNamespaceBackup(w http.ResponseWriter, r *http.Request) {
PVC: pvc.Name, PVC: pvc.Name,
DryRun: req.DryRun, DryRun: req.DryRun,
Dedupe: boolPtr(resolvedDedupe), Dedupe: boolPtr(resolvedDedupe),
KeepLast: intPtr(resolvedKeepLast),
} }
result, status, execErr := s.executeBackup(r.Context(), backupReq, requester) result, status, execErr := s.executeBackup(r.Context(), backupReq, requester)
s.metrics.RecordBackupRequest(s.cfg.BackupDriver, status) s.metrics.RecordBackupRequest(s.cfg.BackupDriver, status)
@ -637,7 +651,9 @@ func (s *Server) executeBackup(ctx context.Context, req api.BackupRequest, reque
return api.BackupResponse{}, "validation_error", fmt.Errorf("namespace and pvc are required") return api.BackupResponse{}, "validation_error", fmt.Errorf("namespace and pvc are required")
} }
resolvedDedupe := dedupeDefault(req.Dedupe) resolvedDedupe := dedupeDefault(req.Dedupe)
resolvedKeepLast := keepLastDefault(req.KeepLast)
req.Dedupe = boolPtr(resolvedDedupe) req.Dedupe = boolPtr(resolvedDedupe)
req.KeepLast = intPtr(resolvedKeepLast)
switch s.cfg.BackupDriver { switch s.cfg.BackupDriver {
case "longhorn": case "longhorn":
@ -655,6 +671,7 @@ func (s *Server) executeBackup(ctx context.Context, req api.BackupRequest, reque
RequestedBy: requester, RequestedBy: requester,
DryRun: req.DryRun, DryRun: req.DryRun,
Dedupe: resolvedDedupe, Dedupe: resolvedDedupe,
KeepLast: resolvedKeepLast,
} }
if req.DryRun { if req.DryRun {
return response, "dry_run", nil return response, "dry_run", nil
@ -689,6 +706,7 @@ func (s *Server) executeBackup(ctx context.Context, req api.BackupRequest, reque
RequestedBy: requester, RequestedBy: requester,
DryRun: req.DryRun, DryRun: req.DryRun,
Dedupe: resolvedDedupe, Dedupe: resolvedDedupe,
KeepLast: resolvedKeepLast,
}, result, nil }, result, nil
default: default:
return api.BackupResponse{}, "unsupported_driver", fmt.Errorf("unsupported backup driver") return api.BackupResponse{}, "unsupported_driver", fmt.Errorf("unsupported backup driver")
@ -1250,6 +1268,7 @@ func (s *Server) runPolicyCycle(ctx context.Context) {
type effectivePolicy struct { type effectivePolicy struct {
IntervalHours float64 IntervalHours float64
Dedupe bool Dedupe bool
KeepLast int
} }
effectivePolicies := map[string]effectivePolicy{} effectivePolicies := map[string]effectivePolicy{}
for _, policy := range policies { for _, policy := range policies {
@ -1265,10 +1284,11 @@ func (s *Server) runPolicyCycle(ctx context.Context) {
for _, pvc := range matches { for _, pvc := range matches {
key := pvc.Namespace + "/" + pvc.PVC key := pvc.Namespace + "/" + pvc.PVC
current, exists := effectivePolicies[key] current, exists := effectivePolicies[key]
if !exists || policy.IntervalHours < current.IntervalHours { if !exists || policy.IntervalHours < current.IntervalHours || (policy.IntervalHours == current.IntervalHours && keepLastStricter(policy.KeepLast, current.KeepLast)) {
effectivePolicies[key] = effectivePolicy{ effectivePolicies[key] = effectivePolicy{
IntervalHours: policy.IntervalHours, IntervalHours: policy.IntervalHours,
Dedupe: policy.Dedupe, Dedupe: policy.Dedupe,
KeepLast: policy.KeepLast,
} }
} }
} }
@ -1289,6 +1309,7 @@ func (s *Server) runPolicyCycle(ctx context.Context) {
PVC: pvc.PVC, PVC: pvc.PVC,
DryRun: false, DryRun: false,
Dedupe: boolPtr(effective.Dedupe), Dedupe: boolPtr(effective.Dedupe),
KeepLast: intPtr(effective.KeepLast),
}, "policy-scheduler") }, "policy-scheduler")
s.metrics.RecordBackupRequest(s.cfg.BackupDriver, result) s.metrics.RecordBackupRequest(s.cfg.BackupDriver, result)
if err != nil { if err != nil {
@ -1333,6 +1354,7 @@ func (s *Server) loadPolicies(ctx context.Context) error {
IntervalHours float64 `json:"interval_hours"` IntervalHours float64 `json:"interval_hours"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Dedupe *bool `json:"dedupe,omitempty"` Dedupe *bool `json:"dedupe,omitempty"`
KeepLast *int `json:"keep_last,omitempty"`
CreatedAt string `json:"created_at,omitempty"` CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"`
} `json:"policies"` } `json:"policies"`
@ -1357,6 +1379,7 @@ func (s *Server) loadPolicies(ctx context.Context) error {
if policy.Dedupe != nil { if policy.Dedupe != nil {
dedupe = *policy.Dedupe dedupe = *policy.Dedupe
} }
keepLast := keepLastDefault(policy.KeepLast)
id := policyKey(namespace, pvc) id := policyKey(namespace, pvc)
createdAt := policy.CreatedAt createdAt := policy.CreatedAt
if createdAt == "" { if createdAt == "" {
@ -1373,6 +1396,7 @@ func (s *Server) loadPolicies(ctx context.Context) error {
IntervalHours: interval, IntervalHours: interval,
Enabled: policy.Enabled, Enabled: policy.Enabled,
Dedupe: dedupe, Dedupe: dedupe,
KeepLast: keepLast,
CreatedAt: createdAt, CreatedAt: createdAt,
UpdatedAt: updatedAt, UpdatedAt: updatedAt,
} }
@ -1457,6 +1481,10 @@ func (s *Server) upsertPolicy(ctx context.Context, req api.BackupPolicyUpsertReq
enabled = *req.Enabled enabled = *req.Enabled
} }
dedupe := dedupeDefault(req.Dedupe) dedupe := dedupeDefault(req.Dedupe)
if err := validateKeepLast(req.KeepLast); err != nil {
return api.BackupPolicy{}, err
}
keepLast := keepLastDefault(req.KeepLast)
id := policyKey(namespace, pvc) id := policyKey(namespace, pvc)
now := time.Now().UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339)
@ -1474,6 +1502,7 @@ func (s *Server) upsertPolicy(ctx context.Context, req api.BackupPolicyUpsertReq
IntervalHours: interval, IntervalHours: interval,
Enabled: enabled, Enabled: enabled,
Dedupe: dedupe, Dedupe: dedupe,
KeepLast: keepLast,
CreatedAt: createdAt, CreatedAt: createdAt,
UpdatedAt: now, UpdatedAt: now,
} }
@ -1917,3 +1946,44 @@ func boolPtr(value bool) *bool {
ptr := value ptr := value
return &ptr return &ptr
} }
func keepLastDefault(value *int) int {
if value == nil {
return 0
}
if *value < 0 {
return 0
}
return *value
}
func intPtr(value int) *int {
ptr := value
return &ptr
}
func validateKeepLast(value *int) error {
if value == nil {
return nil
}
if *value < 0 {
return fmt.Errorf("keep_last must be >= 0")
}
if *value > maxPolicyKeepLast {
return fmt.Errorf("keep_last must be <= %d", maxPolicyKeepLast)
}
return nil
}
func keepLastStricter(candidate, current int) bool {
switch {
case candidate > 0 && current == 0:
return true
case candidate == 0:
return false
case current == 0:
return true
default:
return candidate < current
}
}

View File

@ -337,6 +337,9 @@ func TestResticBackupDefaultsDedupeEnabled(t *testing.T) {
if kube.lastBackupReq.Dedupe == nil || !*kube.lastBackupReq.Dedupe { if kube.lastBackupReq.Dedupe == nil || !*kube.lastBackupReq.Dedupe {
t.Fatalf("expected dedupe default true, got %#v", kube.lastBackupReq.Dedupe) t.Fatalf("expected dedupe default true, got %#v", kube.lastBackupReq.Dedupe)
} }
if kube.lastBackupReq.KeepLast == nil || *kube.lastBackupReq.KeepLast != 0 {
t.Fatalf("expected keep_last default 0, got %#v", kube.lastBackupReq.KeepLast)
}
var payload api.BackupResponse var payload api.BackupResponse
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
@ -345,6 +348,9 @@ func TestResticBackupDefaultsDedupeEnabled(t *testing.T) {
if !payload.Dedupe { if !payload.Dedupe {
t.Fatalf("expected response dedupe=true, got %#v", payload) t.Fatalf("expected response dedupe=true, got %#v", payload)
} }
if payload.KeepLast != 0 {
t.Fatalf("expected response keep_last=0, got %#v", payload)
}
} }
func TestResticInventoryUsesCompletedBackupJobs(t *testing.T) { func TestResticInventoryUsesCompletedBackupJobs(t *testing.T) {
@ -672,6 +678,9 @@ func TestPoliciesCRUD(t *testing.T) {
if !created.Dedupe { if !created.Dedupe {
t.Fatalf("expected policy dedupe default true, got %#v", created) t.Fatalf("expected policy dedupe default true, got %#v", created)
} }
if created.KeepLast != 0 {
t.Fatalf("expected policy keep_last default 0, got %#v", created)
}
listReq := httptest.NewRequest(http.MethodGet, "/v1/policies", nil) listReq := httptest.NewRequest(http.MethodGet, "/v1/policies", nil)
listRes := httptest.NewRecorder() listRes := httptest.NewRecorder()
@ -723,6 +732,9 @@ func TestLoadPoliciesDefaultsDedupeEnabledWhenMissing(t *testing.T) {
if !policies[0].Dedupe { if !policies[0].Dedupe {
t.Fatalf("expected dedupe to default true for legacy policy, got %#v", policies[0]) t.Fatalf("expected dedupe to default true for legacy policy, got %#v", policies[0])
} }
if policies[0].KeepLast != 0 {
t.Fatalf("expected keep_last to default 0 for legacy policy, got %#v", policies[0])
}
} }
func TestNamespaceBackupDryRun(t *testing.T) { func TestNamespaceBackupDryRun(t *testing.T) {
@ -760,6 +772,9 @@ func TestNamespaceBackupDryRun(t *testing.T) {
if payload.Total != 2 || payload.Succeeded != 2 || payload.Failed != 0 { if payload.Total != 2 || payload.Succeeded != 2 || payload.Failed != 0 {
t.Fatalf("unexpected namespace backup payload: %#v", payload) t.Fatalf("unexpected namespace backup payload: %#v", payload)
} }
if payload.KeepLast != 0 {
t.Fatalf("expected namespace backup keep_last default 0, got %#v", payload)
}
} }
func TestNamespaceBackupUsesDedupeFlag(t *testing.T) { func TestNamespaceBackupUsesDedupeFlag(t *testing.T) {
@ -780,7 +795,7 @@ func TestNamespaceBackupUsesDedupeFlag(t *testing.T) {
} }
srv.handler = http.HandlerFunc(srv.route) srv.handler = http.HandlerFunc(srv.route)
body := `{"namespace":"apps","dry_run":false,"dedupe":false}` body := `{"namespace":"apps","dry_run":false,"dedupe":false,"keep_last":1}`
req := httptest.NewRequest(http.MethodPost, "/v1/backup/namespace", strings.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/v1/backup/namespace", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder() res := httptest.NewRecorder()
@ -797,9 +812,41 @@ func TestNamespaceBackupUsesDedupeFlag(t *testing.T) {
if payload.Dedupe { if payload.Dedupe {
t.Fatalf("expected response dedupe false, got %#v", payload) t.Fatalf("expected response dedupe false, got %#v", payload)
} }
if payload.KeepLast != 1 {
t.Fatalf("expected keep_last=1, got %#v", payload)
}
if kube.lastBackupReq.Dedupe == nil || *kube.lastBackupReq.Dedupe { if kube.lastBackupReq.Dedupe == nil || *kube.lastBackupReq.Dedupe {
t.Fatalf("expected backup request dedupe false, got %#v", kube.lastBackupReq.Dedupe) t.Fatalf("expected backup request dedupe false, got %#v", kube.lastBackupReq.Dedupe)
} }
if kube.lastBackupReq.KeepLast == nil || *kube.lastBackupReq.KeepLast != 1 {
t.Fatalf("expected backup request keep_last=1, got %#v", kube.lastBackupReq.KeepLast)
}
}
func TestBackupRejectsNegativeKeepLast(t *testing.T) {
srv := &Server{
cfg: &config.Config{
AuthRequired: false,
BackupDriver: "restic",
},
client: &fakeKubeClient{},
longhorn: &fakeLonghornClient{},
metrics: newTelemetry(),
}
srv.handler = http.HandlerFunc(srv.route)
body := `{"namespace":"apps","pvc":"data","keep_last":-1}`
req := httptest.NewRequest(http.MethodPost, "/v1/backup", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
srv.Handler().ServeHTTP(res, req)
if res.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid keep_last, got %d: %s", res.Code, res.Body.String())
}
if !strings.Contains(res.Body.String(), "keep_last must be") {
t.Fatalf("expected keep_last validation message, got %s", res.Body.String())
}
} }
func TestNamespaceRestoreDryRun(t *testing.T) { func TestNamespaceRestoreDryRun(t *testing.T) {

View File

@ -68,6 +68,7 @@ interface BackupPolicy {
interval_hours: number; interval_hours: number;
enabled: boolean; enabled: boolean;
dedupe?: boolean; dedupe?: boolean;
keep_last?: number;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }
@ -270,7 +271,9 @@ function App() {
const [policyIntervalHours, setPolicyIntervalHours] = useState<number>(24); const [policyIntervalHours, setPolicyIntervalHours] = useState<number>(24);
const [policyEnabled, setPolicyEnabled] = useState<boolean>(true); const [policyEnabled, setPolicyEnabled] = useState<boolean>(true);
const [policyDedupe, setPolicyDedupe] = useState<boolean>(true); const [policyDedupe, setPolicyDedupe] = useState<boolean>(true);
const [policyKeepLast, setPolicyKeepLast] = useState<number>(0);
const [manualDedupe, setManualDedupe] = useState<boolean>(true); const [manualDedupe, setManualDedupe] = useState<boolean>(true);
const [manualKeepLast, setManualKeepLast] = useState<number>(0);
const [lastAction, setLastAction] = useState<string>('No action yet.'); const [lastAction, setLastAction] = useState<string>('No action yet.');
const [busy, setBusy] = useState<boolean>(false); const [busy, setBusy] = useState<boolean>(false);
@ -404,7 +407,7 @@ function App() {
const payload = await fetchJSON<unknown>('/v1/backup', { const payload = await fetchJSON<unknown>('/v1/backup', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ namespace, pvc, dry_run: false, dedupe: manualDedupe }) body: JSON.stringify({ namespace, pvc, dry_run: false, dedupe: manualDedupe, keep_last: manualKeepLast })
}); });
writeAction(payload); writeAction(payload);
await Promise.all([loadInventory(), loadB2Usage()]); await Promise.all([loadInventory(), loadB2Usage()]);
@ -421,7 +424,7 @@ function App() {
const payload = await fetchJSON<unknown>('/v1/backup/namespace', { const payload = await fetchJSON<unknown>('/v1/backup/namespace', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ namespace, dry_run: false, dedupe: manualDedupe }) body: JSON.stringify({ namespace, dry_run: false, dedupe: manualDedupe, keep_last: manualKeepLast })
}); });
writeAction(payload); writeAction(payload);
await Promise.all([loadInventory(), loadB2Usage()]); await Promise.all([loadInventory(), loadB2Usage()]);
@ -534,7 +537,8 @@ function App() {
pvc: policyPVC, pvc: policyPVC,
interval_hours: policyIntervalHours, interval_hours: policyIntervalHours,
enabled: policyEnabled, enabled: policyEnabled,
dedupe: policyDedupe dedupe: policyDedupe,
keep_last: policyKeepLast
}) })
}); });
writeAction(payload); writeAction(payload);
@ -599,6 +603,16 @@ function App() {
<input type="checkbox" checked={manualDedupe} onChange={(event) => setManualDedupe(event.target.checked)} /> <input type="checkbox" checked={manualDedupe} onChange={(event) => setManualDedupe(event.target.checked)} />
Dedupe unchanged blocks (default) Dedupe unchanged blocks (default)
</label> </label>
<label>
Keep last snapshots per PVC (0 = keep all)
<input
type="number"
min={0}
max={1000}
value={manualKeepLast}
onChange={(event) => setManualKeepLast(Math.max(0, Math.min(1000, Number(event.target.value || 0))))}
/>
</label>
<p className="subtle tiny">This setting applies to both `Backup now` and `Backup namespace` actions.</p> <p className="subtle tiny">This setting applies to both `Backup now` and `Backup namespace` actions.</p>
{inventoryError && <p className="error">{inventoryError}</p>} {inventoryError && <p className="error">{inventoryError}</p>}
{!inventory && !inventoryError && <p className="subtle">Loading inventory...</p>} {!inventory && !inventoryError && <p className="subtle">Loading inventory...</p>}
@ -820,7 +834,7 @@ function App() {
<section className="panel scroll-panel"> <section className="panel scroll-panel">
<h2>Backup Policies</h2> <h2>Backup Policies</h2>
<p className="subtle tiny">Policy backups create new restic snapshots. With dedupe on, unchanged blocks are reused in the shared repository. With dedupe off, Soteria isolates each PVC to its own repository path.</p> <p className="subtle tiny">Policy backups create new restic snapshots. `Keep last` controls version retention per PVC: 1 means only newest copy remains after each run. With dedupe on, unchanged blocks are reused in the shared repository. With dedupe off, Soteria isolates each PVC to its own repository path.</p>
<div className="stack"> <div className="stack">
<label> <label>
Namespace Namespace
@ -849,6 +863,16 @@ function App() {
<input type="checkbox" checked={policyDedupe} onChange={(event) => setPolicyDedupe(event.target.checked)} /> <input type="checkbox" checked={policyDedupe} onChange={(event) => setPolicyDedupe(event.target.checked)} />
Dedupe unchanged blocks Dedupe unchanged blocks
</label> </label>
<label>
Keep last snapshots per PVC (0 = keep all)
<input
type="number"
min={0}
max={1000}
value={policyKeepLast}
onChange={(event) => setPolicyKeepLast(Math.max(0, Math.min(1000, Number(event.target.value || 0))))}
/>
</label>
<button type="button" onClick={() => void savePolicy()} disabled={busy || !policyNamespace}>Save policy</button> <button type="button" onClick={() => void savePolicy()} disabled={busy || !policyNamespace}>Save policy</button>
</div> </div>
@ -861,7 +885,7 @@ function App() {
<strong>{policy.namespace}/{policy.pvc || '*'}</strong> <strong>{policy.namespace}/{policy.pvc || '*'}</strong>
<span className={`chip ${policy.enabled ? 'good' : 'bad'}`}>{policy.enabled ? 'Enabled' : 'Disabled'}</span> <span className={`chip ${policy.enabled ? 'good' : 'bad'}`}>{policy.enabled ? 'Enabled' : 'Disabled'}</span>
</div> </div>
<p className="subtle tiny">Every {policy.interval_hours}h | Dedupe: {policy.dedupe === false ? 'off' : 'on'} | Updated {formatTimestamp(policy.updated_at || policy.created_at)}</p> <p className="subtle tiny">Every {policy.interval_hours}h | Dedupe: {policy.dedupe === false ? 'off' : 'on'} | Keep last: {policy.keep_last ?? 0} | Updated {formatTimestamp(policy.updated_at || policy.created_at)}</p>
<div className="actions"> <div className="actions">
<button <button
type="button" type="button"
@ -872,6 +896,7 @@ function App() {
setPolicyIntervalHours(policy.interval_hours); setPolicyIntervalHours(policy.interval_hours);
setPolicyEnabled(policy.enabled); setPolicyEnabled(policy.enabled);
setPolicyDedupe(policy.dedupe !== false); setPolicyDedupe(policy.dedupe !== false);
setPolicyKeepLast(policy.keep_last ?? 0);
}} }}
> >
Load Load