diff --git a/dist/urbm-2026.06.15.r008-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r008-x86_64-1.txz.sha256 deleted file mode 100644 index 0988a2a..0000000 --- a/dist/urbm-2026.06.15.r008-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -aa07788cc61822162b8caccd91b5b83afabf6dd770e4800c180dc6ce2b94abe5 urbm-2026.06.15.r008-x86_64-1.txz diff --git a/dist/urbm-2026.06.15.r008-x86_64-1.txz b/dist/urbm-2026.06.15.r009-x86_64-1.txz similarity index 55% rename from dist/urbm-2026.06.15.r008-x86_64-1.txz rename to dist/urbm-2026.06.15.r009-x86_64-1.txz index 17aecee..f8738bf 100644 Binary files a/dist/urbm-2026.06.15.r008-x86_64-1.txz and b/dist/urbm-2026.06.15.r009-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.15.r009-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r009-x86_64-1.txz.sha256 new file mode 100644 index 0000000..eba8ef8 --- /dev/null +++ b/dist/urbm-2026.06.15.r009-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +648afa0c0b1348302f882422c5a199e56ecdcdc0f817bc9a402c1a73549bbeea urbm-2026.06.15.r009-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index 84fe6e1..6d801ba 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,18 @@ - + - + ]> +### 2026.06.15.r009 +- Add a per-backup-job CPU core limit using Restic's GOMAXPROCS setting. +- Apply the configured limit to normal backups and optional USB raw-image backups without affecting restore or maintenance tasks. +- Preserve existing behavior with 0 meaning all available CPU cores. + ### 2026.06.15.r008 - Replace backup-run history graphs with actual per-repository Restic statistics. - Show deduplicated stored repository data and the total files referenced by all snapshots. diff --git a/internal/model/model.go b/internal/model/model.go index 4e00920..6ed3510 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -52,6 +52,7 @@ type Job struct { Schedule Schedule `json:"schedule"` Consistency Consistency `json:"consistency"` Compression string `json:"compression"` + CPUCores int `json:"cpuCores,omitempty"` Excludes []string `json:"excludes,omitempty"` Tags []string `json:"tags,omitempty"` FlashImage bool `json:"flashImage,omitempty"` diff --git a/internal/model/validate.go b/internal/model/validate.go index 1cbc9c1..2e36906 100644 --- a/internal/model/validate.go +++ b/internal/model/validate.go @@ -127,6 +127,9 @@ func ValidateJob(j Job) error { if j.Compression != "auto" && j.Compression != "off" && j.Compression != "max" { return errors.New("compression must be auto, off, or max") } + if j.CPUCores < 0 || j.CPUCores > 256 { + return errors.New("cpuCores must be between 0 and 256") + } if j.Consistency.Mode != "live" && j.Consistency.Mode != "stop" { return errors.New("consistency mode must be live or stop") } diff --git a/internal/model/validate_test.go b/internal/model/validate_test.go index 31ebcda..075a80f 100644 --- a/internal/model/validate_test.go +++ b/internal/model/validate_test.go @@ -79,3 +79,15 @@ func TestRetentionRequiresAtLeastOneRule(t *testing.T) { t.Fatalf("age retention rejected: %v", err) } } + +func TestBackupCPUCoresRange(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", Retention: Retention{KeepWithinDays: 30}} + job.CPUCores = 2 + if err := ValidateJob(job); err != nil { + t.Fatalf("valid CPU limit rejected: %v", err) + } + job.CPUCores = 257 + if err := ValidateJob(job); err == nil { + t.Fatal("excessive CPU limit accepted") + } +} diff --git a/internal/restic/restic.go b/internal/restic/restic.go index 83a9209..d50fe33 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -115,7 +115,7 @@ func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Jo } args = append(args, sources...) var summary Summary - err := r.run(ctx, repo, args, func(line []byte) { + err := r.runWithEnv(ctx, repo, args, func(line []byte) { var status Progress if json.Unmarshal(line, &status) == nil && status.MessageType == "status" { if progress != nil { @@ -127,7 +127,7 @@ func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Jo if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" { summary = candidate } - }, nil) + }, nil, backupEnvironment(job)) return summary, err } @@ -137,7 +137,7 @@ func (r *Runner) BackupImage(ctx context.Context, repo model.Repository, job mod args = append(args, "--tag", tag) } var summary Summary - err := r.runWithInput(ctx, repo, args, func(line []byte) { + err := r.runWithInputAndEnv(ctx, repo, args, func(line []byte) { var status Progress if json.Unmarshal(line, &status) == nil && status.MessageType == "status" { if progress != nil { @@ -149,7 +149,7 @@ func (r *Runner) BackupImage(ctx context.Context, repo model.Repository, job mod if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" { summary = candidate } - }, nil, image) + }, nil, image, backupEnvironment(job)) return summary, err } @@ -245,10 +245,14 @@ func (r *Runner) Restore(ctx context.Context, repo model.Repository, task model. } func (r *Runner) run(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte) error { - return r.runWithInput(ctx, repo, args, onLine, capture, nil) + return r.runWithInputAndEnv(ctx, repo, args, onLine, capture, nil, nil) } -func (r *Runner) runWithInput(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte, input io.Reader) error { +func (r *Runner) runWithEnv(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte, environment []string) error { + return r.runWithInputAndEnv(ctx, repo, args, onLine, capture, nil, environment) +} + +func (r *Runner) runWithInputAndEnv(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte, input io.Reader, environment []string) error { password, err := r.Secrets.Get(repo.PasswordRef) if err != nil { return fmt.Errorf("authentication: load repository password: %w", err) @@ -319,7 +323,7 @@ func (r *Runner) runWithInput(ctx context.Context, repo model.Repository, args [ } return nil } - cmd.Env = []string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME=/root", "RESTIC_PASSWORD_FILE=" + passwordPath} + cmd.Env = append([]string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME=/root", "RESTIC_PASSWORD_FILE=" + passwordPath}, environment...) stdout, err := cmd.StdoutPipe() if err != nil { return err @@ -367,6 +371,13 @@ func (r *Runner) runWithInput(ctx context.Context, repo model.Repository, args [ return nil } +func backupEnvironment(job model.Job) []string { + if job.CPUCores <= 0 { + return nil + } + return []string{"GOMAXPROCS=" + fmt.Sprint(job.CPUCores)} +} + func resticError(message string, commandErr error) error { lower := strings.ToLower(message) switch { diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index b069864..883dc11 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -51,6 +51,29 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) { } } +func TestBackupLimitsCPUForThisJob(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, "gomaxprocs") + script := filepath.Join(dir, "restic") + body := fmt.Sprintf("#!/bin/sh\nprintf '%%s' \"$GOMAXPROCS\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"cpu123\"}'\n", envPath) + 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", Compression: "auto", CPUCores: 2} + if _, err := runner.Backup(context.Background(), repo, job, []string{"/source"}, nil); err != nil { + t.Fatal(err) + } + value, err := os.ReadFile(envPath) + if err != nil { + t.Fatal(err) + } + if string(value) != "2" { + t.Fatalf("GOMAXPROCS = %q", value) + } +} + func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 13b0cdb..5c228f8 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,18 @@ - + ]> +### 2026.06.15.r009 +- Add a per-backup-job CPU core limit using Restic's GOMAXPROCS setting. +- Apply the configured limit to normal backups and optional USB raw-image backups without affecting restore or maintenance tasks. +- Preserve existing behavior with 0 meaning all available CPU cores. + ### 2026.06.15.r008 - Replace backup-run history graphs with actual per-repository Restic statistics. - Show deduplicated stored repository data and the total files referenced by all snapshots. diff --git a/webgui/URBM.page b/webgui/URBM.page index f784d7f..9a93926 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" - +
- -

URBM 2026.06.15.r008

Restic Backup Manager für Unraid

+ +

URBM 2026.06.15.r009

Restic Backup Manager für Unraid

Verbindung wird hergestellt...
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
- + diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index cfd0091..9a44c60 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -19,6 +19,7 @@ scheduleDays: 'Wähle mindestens einen Wochentag. Beispiel: Montag, Mittwoch und Sonntag jeweils zur eingestellten Uhrzeit.', consistency: 'Live sichert ohne Unterbrechung, kann aber nur crash-konsistent sein. Controlled stop beendet Container oder VM vor dem Backup und startet sie danach wieder.', compression: 'Restic-Kompression. auto ist empfohlen; off spart CPU; max spart eher Speicher, benötigt aber mehr CPU-Zeit.', + cpuCores: 'Begrenzt diesen Backup-Lauf über GOMAXPROCS auf die angegebene Anzahl CPU-Kerne. 0 verwendet wie bisher alle verfügbaren Kerne.', 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.', @@ -365,6 +366,7 @@ + ${scheduleFields(job)}
@@ -610,7 +612,7 @@ 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 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('Die Aufbewahrung benötigt mindestens eine Alters- oder Kalenderregel.'); - 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:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)}; + 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:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),cpuCores:Number(f.get('cpuCores')||0),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention,notifyOn:existing?.notifyOn||['success','warning','failed'],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') {