diff --git a/dist/backupper-2026.06.14.6-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.6-x86_64-1.txz.sha256 deleted file mode 100644 index 98f4c21..0000000 --- a/dist/backupper-2026.06.14.6-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -72cbb82fd82f26c3099130bb618ac29077e315693b12a9e77585d05ed355c851 backupper-2026.06.14.6-x86_64-1.txz diff --git a/dist/backupper-2026.06.14.6-x86_64-1.txz b/dist/backupper-2026.06.14.7-x86_64-1.txz similarity index 55% rename from dist/backupper-2026.06.14.6-x86_64-1.txz rename to dist/backupper-2026.06.14.7-x86_64-1.txz index 63dd831..48a39ef 100644 Binary files a/dist/backupper-2026.06.14.6-x86_64-1.txz and b/dist/backupper-2026.06.14.7-x86_64-1.txz differ diff --git a/dist/backupper-2026.06.14.7-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.7-x86_64-1.txz.sha256 new file mode 100644 index 0000000..a8a656b --- /dev/null +++ b/dist/backupper-2026.06.14.7-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +2abdcf42779c90cc3eb03e84b3e9c51ca1b2f8075353d3d76e0f825234bd534d backupper-2026.06.14.7-x86_64-1.txz diff --git a/dist/backupper.plg b/dist/backupper.plg index 858d247..6aac4de 100644 --- a/dist/backupper.plg +++ b/dist/backupper.plg @@ -2,13 +2,16 @@ - + - + ]> +### 2026.06.14.7 +- Add per-job age-based retention and optional daily, weekly, and monthly rotation settings. + ### 2026.06.14.6 - Show clear inactive-state messages when Unraid VM Manager or Docker is disabled. diff --git a/internal/model/model.go b/internal/model/model.go index a985e43..fa4674a 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -74,9 +74,10 @@ type Consistency struct { } type Retention struct { - Daily int `json:"daily"` - Weekly int `json:"weekly"` - Monthly int `json:"monthly"` + KeepWithinDays int `json:"keepWithinDays,omitempty"` + Daily int `json:"daily"` + Weekly int `json:"weekly"` + Monthly int `json:"monthly"` } type Repository struct { diff --git a/internal/model/validate.go b/internal/model/validate.go index df2e543..ba3dc0d 100644 --- a/internal/model/validate.go +++ b/internal/model/validate.go @@ -87,9 +87,12 @@ func ValidateJob(j Job) error { if j.Consistency.Mode != "live" && j.Consistency.Mode != "stop" { return errors.New("consistency mode must be live or stop") } - if j.Retention.Daily < 0 || j.Retention.Weekly < 0 || j.Retention.Monthly < 0 { + if j.Retention.KeepWithinDays < 0 || j.Retention.Daily < 0 || j.Retention.Weekly < 0 || j.Retention.Monthly < 0 { return errors.New("retention values cannot be negative") } + if j.Retention.KeepWithinDays == 0 && j.Retention.Daily == 0 && j.Retention.Weekly == 0 && j.Retention.Monthly == 0 { + return errors.New("retention must keep at least one age or calendar policy") + } for _, exclude := range j.Excludes { if strings.ContainsAny(exclude, "\x00\r\n") { return errors.New("exclude contains forbidden control characters") diff --git a/internal/model/validate_test.go b/internal/model/validate_test.go index 71b3e4d..55ecbab 100644 --- a/internal/model/validate_test.go +++ b/internal/model/validate_test.go @@ -36,7 +36,7 @@ func TestRepositoryCannotBeSharedByJobs(t *testing.T) { } func TestDockerJobRequiresWorkloadSources(t *testing.T) { - job := Job{SchemaVersion: 1, ID: "docker-job", Name: "Docker", Type: JobDocker, RepositoryID: "repo", Sources: []Source{{Path: "/mnt/user/appdata"}}, Consistency: Consistency{Mode: "live"}, Compression: "auto"} + job := Job{SchemaVersion: 1, ID: "docker-job", Name: "Docker", Type: JobDocker, RepositoryID: "repo", Sources: []Source{{Path: "/mnt/user/appdata"}}, Consistency: Consistency{Mode: "live"}, Compression: "auto", Retention: Retention{KeepWithinDays: 30}} if err := ValidateJob(job); err == nil { t.Fatal("docker path source accepted without workload selection") } @@ -45,3 +45,14 @@ func TestDockerJobRequiresWorkloadSources(t *testing.T) { t.Fatalf("valid docker workload rejected: %v", err) } } + +func TestRetentionRequiresAtLeastOneRule(t *testing.T) { + job := Job{SchemaVersion: 1, ID: "job", Name: "Share", Type: JobShare, RepositoryID: "repo", Sources: []Source{{Path: "/mnt/user/data"}}, Consistency: Consistency{Mode: "live"}, Compression: "auto"} + if err := ValidateJob(job); err == nil { + t.Fatal("empty retention policy accepted") + } + job.Retention.KeepWithinDays = 30 + if err := ValidateJob(job); err != nil { + t.Fatalf("age retention rejected: %v", err) + } +} diff --git a/internal/restic/restic.go b/internal/restic/restic.go index 52cf513..91c4da8 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -58,7 +58,19 @@ func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Jo } func (r *Runner) Forget(ctx context.Context, repo model.Repository, job model.Job) error { - args := []string{"forget", "--tag", "job:" + job.ID, "--keep-daily", fmt.Sprint(job.Retention.Daily), "--keep-weekly", fmt.Sprint(job.Retention.Weekly), "--keep-monthly", fmt.Sprint(job.Retention.Monthly)} + args := []string{"forget", "--tag", "job:" + job.ID} + if job.Retention.KeepWithinDays > 0 { + args = append(args, "--keep-within", fmt.Sprintf("%dd", job.Retention.KeepWithinDays)) + } + if job.Retention.Daily > 0 { + args = append(args, "--keep-daily", fmt.Sprint(job.Retention.Daily)) + } + if job.Retention.Weekly > 0 { + args = append(args, "--keep-weekly", fmt.Sprint(job.Retention.Weekly)) + } + if job.Retention.Monthly > 0 { + args = append(args, "--keep-monthly", fmt.Sprint(job.Retention.Monthly)) + } return r.run(ctx, repo, args, nil, nil) } diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index 038eeb0..a72359b 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -45,3 +45,28 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) { t.Fatal("password file was not provided") } } + +func TestForgetUsesAgeAndCalendarRetention(t *testing.T) { + dir := t.TempDir() + argsPath := filepath.Join(dir, "args") + script := filepath.Join(dir, "restic") + body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\n", argsPath) + if err := os.WriteFile(script, []byte(body), 0700); err != nil { + t.Fatal(err) + } + runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} + repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} + job := model.Job{ID: "job", Retention: model.Retention{KeepWithinDays: 30, Weekly: 4, Monthly: 12}} + if err := runner.Forget(context.Background(), repo, job); err != nil { + t.Fatal(err) + } + args, _ := os.ReadFile(argsPath) + for _, expected := range []string{"--keep-within", "30d", "--keep-weekly", "4", "--keep-monthly", "12"} { + if !strings.Contains(string(args), expected) { + t.Fatalf("retention arguments missing %q: %s", expected, args) + } + } + if strings.Contains(string(args), "--keep-daily") { + t.Fatalf("disabled daily retention was included: %s", args) + } +} diff --git a/plugin/backupper.plg b/plugin/backupper.plg index ef79ad8..5bb5715 100644 --- a/plugin/backupper.plg +++ b/plugin/backupper.plg @@ -2,13 +2,16 @@ - + ]> +### 2026.06.14.7 +- Add per-job age-based retention and optional daily, weekly, and monthly rotation settings. + ### 2026.06.14.6 - Show clear inactive-state messages when Unraid VM Manager or Docker is disabled. diff --git a/webgui/Backupper.page b/webgui/Backupper.page index 02e42df..69dee4d 100644 --- a/webgui/Backupper.page +++ b/webgui/Backupper.page @@ -9,10 +9,10 @@ Tag="backup restic snapshots restore" - +
-

Backupper 2026.06.14.6

Encrypted Restic backups for Unraid

+

Backupper 2026.06.14.7

Encrypted Restic backups for Unraid

Connecting...
- + diff --git a/webgui/assets/backupper.css b/webgui/assets/backupper.css index 6ce4d4a..7af6ade 100644 --- a/webgui/assets/backupper.css +++ b/webgui/assets/backupper.css @@ -82,6 +82,9 @@ .bu-workload-options { display: grid; gap: 15px; grid-template-columns: repeat(2, minmax(220px, 1fr)); margin-top: 15px; } .bu-manual-source { display: grid; gap: 7px; margin-top: 14px; } .bu-info-card { background: var(--bu-panel-alt); border: 1px solid var(--bu-border); border-radius: 6px; padding: 16px; } +.bu-fieldset { border: 1px solid var(--bu-border); border-radius: 6px; padding: 14px; } +.bu-fieldset legend { color: var(--bu-text); font-size: 17px; font-weight: 700; padding: 0 7px; } +.bu-retention-grid { display: grid; gap: 15px; grid-template-columns: repeat(4, minmax(150px, 1fr)); } #backupper-app .bu-button { appearance: none; @@ -186,6 +189,7 @@ #backupper-app { padding-left: 8px; padding-right: 8px; } .bu-form { grid-template-columns: 1fr; } .bu-workload-options { grid-template-columns: 1fr; } + .bu-retention-grid { grid-template-columns: 1fr; } .bu-chart-grid-layout { grid-template-columns: 1fr; } .bu-header { align-items: flex-start; flex-direction: column; gap: 12px; } .bu-table { display: block; overflow-x: auto; } diff --git a/webgui/assets/backupper.js b/webgui/assets/backupper.js index d4e1f77..669f149 100644 --- a/webgui/assets/backupper.js +++ b/webgui/assets/backupper.js @@ -19,6 +19,10 @@ sources: 'Optionale manuelle Quellen, eine pro Zeile. Ordner wählst du einfacher über die Checkbox-Liste. Docker- oder VM-ID mit @ voranstellen, z. B. @HomeAssistant.', excludes: 'Muster für Dateien, die nicht gesichert werden. Ein Muster pro Zeile. Beispiele: *.tmp, cache/** oder /mnt/user/appdata/plex/Cache/**.', shutdownSecs: 'Maximale Wartezeit für einen kontrollierten Stopp. Beispiel: 120 wartet bis zu zwei Minuten; danach wird das Backup ohne Force-Stop abgebrochen.', + keepWithinDays: 'Behält alle Snapshots, die innerhalb dieser Anzahl Tage erstellt wurden. Beispiel: 30 entfernt ältere Snapshots beim nächsten erfolgreichen Backup.', + keepDaily: 'Zusätzliche tägliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.', + keepWeekly: 'Zusätzliche wöchentliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.', + keepMonthly: 'Zusätzliche monatliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.', enabled: 'Aktiviert automatische geplante Läufe. Deaktivierte Jobs können weiterhin gespeichert, aber nicht automatisch ausgeführt werden.', location: 'Restic-Ziel. Lokal: /mnt/user/backups/job1. SFTP: user@server:/backup/job1. Bei SMB/NFS mit Mount wird hier der Mount-Pfad verwendet.', passwordRef: 'Interne ID des verschlüsselt gespeicherten Restic-Passworts. Beispiel: repo-password-appdata. Keine Leerzeichen verwenden.', @@ -254,6 +258,7 @@
+
Backup retention

Snapshots matching any enabled rule are kept. Set daily, weekly and monthly to 0 for a strict age limit.

${button('Save','submit-job','primary')}${button('Cancel','cancel-form')}
`; enhanceTooltips(content); @@ -420,7 +425,9 @@ if(type==='docker'||type==='vm') sources=state.workloadSelections.map(workloadId=>({workloadId})); else if(type==='flash') sources=[]; else sources=[...state.backupSelections.map(path=>({path})),...lines(f.get('sources')||'').map(path=>({path}))].filter((item,index,all)=>all.findIndex(other=>other.path===item.path)===index); - const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources,schedule:{cron:f.get('cron'),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention:existing?.retention||{daily:7,weekly:4,monthly:12},notifyOn:existing?.notifyOn||['failed','warning'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)}; + const retention={keepWithinDays:Number(f.get('keepWithinDays')),daily:Number(f.get('keepDaily')),weekly:Number(f.get('keepWeekly')),monthly:Number(f.get('keepMonthly'))}; + if(!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Retention must keep at least one age or calendar policy'); + const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources,schedule:{cron:f.get('cron'),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention,notifyOn:existing?.notifyOn||['failed','warning'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)}; state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render(); } if (kind === 'submit-repo') {