backup: add configurable keep-last retention
This commit is contained in:
parent
9a26274242
commit
4b5e4f9e31
@ -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"`
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user