Release URBM 2026.06.15.r009

This commit is contained in:
Mikei386
2026-06-15 07:21:48 +02:00
parent 5e68a50373
commit 772358578e
12 changed files with 78 additions and 16 deletions
-1
View File
@@ -1 +0,0 @@
aa07788cc61822162b8caccd91b5b83afabf6dd770e4800c180dc6ce2b94abe5 urbm-2026.06.15.r008-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
648afa0c0b1348302f882422c5a199e56ecdcdc0f817bc9a402c1a73549bbeea urbm-2026.06.15.r009-x86_64-1.txz
+7 -2
View File
@@ -2,13 +2,18 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r008"> <!ENTITY version "2026.06.15.r009">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "aa07788cc61822162b8caccd91b5b83afabf6dd770e4800c180dc6ce2b94abe5"> <!ENTITY packageSHA256 "648afa0c0b1348302f882422c5a199e56ecdcdc0f817bc9a402c1a73549bbeea">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 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 ### 2026.06.15.r008
- Replace backup-run history graphs with actual per-repository Restic statistics. - Replace backup-run history graphs with actual per-repository Restic statistics.
- Show deduplicated stored repository data and the total files referenced by all snapshots. - Show deduplicated stored repository data and the total files referenced by all snapshots.
+1
View File
@@ -52,6 +52,7 @@ type Job struct {
Schedule Schedule `json:"schedule"` Schedule Schedule `json:"schedule"`
Consistency Consistency `json:"consistency"` Consistency Consistency `json:"consistency"`
Compression string `json:"compression"` Compression string `json:"compression"`
CPUCores int `json:"cpuCores,omitempty"`
Excludes []string `json:"excludes,omitempty"` Excludes []string `json:"excludes,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
FlashImage bool `json:"flashImage,omitempty"` FlashImage bool `json:"flashImage,omitempty"`
+3
View File
@@ -127,6 +127,9 @@ func ValidateJob(j Job) error {
if j.Compression != "auto" && j.Compression != "off" && j.Compression != "max" { if j.Compression != "auto" && j.Compression != "off" && j.Compression != "max" {
return errors.New("compression must be auto, off, or 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" { if j.Consistency.Mode != "live" && j.Consistency.Mode != "stop" {
return errors.New("consistency mode must be live or stop") return errors.New("consistency mode must be live or stop")
} }
+12
View File
@@ -79,3 +79,15 @@ func TestRetentionRequiresAtLeastOneRule(t *testing.T) {
t.Fatalf("age retention rejected: %v", err) 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")
}
}
+18 -7
View File
@@ -115,7 +115,7 @@ func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Jo
} }
args = append(args, sources...) args = append(args, sources...)
var summary Summary var summary Summary
err := r.run(ctx, repo, args, func(line []byte) { err := r.runWithEnv(ctx, repo, args, func(line []byte) {
var status Progress var status Progress
if json.Unmarshal(line, &status) == nil && status.MessageType == "status" { if json.Unmarshal(line, &status) == nil && status.MessageType == "status" {
if progress != nil { 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" { if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" {
summary = candidate summary = candidate
} }
}, nil) }, nil, backupEnvironment(job))
return summary, err return summary, err
} }
@@ -137,7 +137,7 @@ func (r *Runner) BackupImage(ctx context.Context, repo model.Repository, job mod
args = append(args, "--tag", tag) args = append(args, "--tag", tag)
} }
var summary Summary var summary Summary
err := r.runWithInput(ctx, repo, args, func(line []byte) { err := r.runWithInputAndEnv(ctx, repo, args, func(line []byte) {
var status Progress var status Progress
if json.Unmarshal(line, &status) == nil && status.MessageType == "status" { if json.Unmarshal(line, &status) == nil && status.MessageType == "status" {
if progress != nil { 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" { if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" {
summary = candidate summary = candidate
} }
}, nil, image) }, nil, image, backupEnvironment(job))
return summary, err 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 { 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) password, err := r.Secrets.Get(repo.PasswordRef)
if err != nil { if err != nil {
return fmt.Errorf("authentication: load repository password: %w", err) 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 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() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
return err return err
@@ -367,6 +371,13 @@ func (r *Runner) runWithInput(ctx context.Context, repo model.Repository, args [
return nil 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 { func resticError(message string, commandErr error) error {
lower := strings.ToLower(message) lower := strings.ToLower(message)
switch { switch {
+23
View File
@@ -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) { func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
argsPath := filepath.Join(dir, "args") argsPath := filepath.Join(dir, "args")
+6 -1
View File
@@ -2,13 +2,18 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r008"> <!ENTITY version "2026.06.15.r009">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE"> <!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 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 ### 2026.06.15.r008
- Replace backup-run history graphs with actual per-repository Restic statistics. - Replace backup-run history graphs with actual per-repository Restic statistics.
- Show deduplicated stored repository data and the total files referenced by all snapshots. - Show deduplicated stored repository data and the total files referenced by all snapshots.
+4 -4
View File
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
<?php <?php
$pluginRoot = '/plugins/urbm'; $pluginRoot = '/plugins/urbm';
?> ?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r008"> <link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r009">
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php"> <div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header"> <header class="bu-header">
<div class="bu-brand"> <div class="bu-brand">
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r008" alt="URBM-Logo"> <img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r009" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.15.r008</span></h1><p>Restic Backup Manager für Unraid</p></div> <div><h1>URBM <span class="bu-version">2026.06.15.r009</span></h1><p>Restic Backup Manager für Unraid</p></div>
</div> </div>
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div> <div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
</header> </header>
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div> <div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div> </div>
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script> <script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r008.js"></script> <script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r009.js"></script>
+3 -1
View File
@@ -19,6 +19,7 @@
scheduleDays: 'Wähle mindestens einen Wochentag. Beispiel: Montag, Mittwoch und Sonntag jeweils zur eingestellten Uhrzeit.', 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.', 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.', 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.', 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/**.', 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.', 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 @@
<input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label> <input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
<label>Typ<select name="type">${['share','appdata','docker','vm','flash'].map(v=>`<option value="${v}" ${v===job.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label> <label>Typ<select name="type">${['share','appdata','docker','vm','flash'].map(v=>`<option value="${v}" ${v===job.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label>
<label>Repository<select name="repositoryId" required>${options}</select></label><label>Kompression<select name="compression"><option value="auto" ${(job.compression||'auto')==='auto'?'selected':''}>Automatisch</option><option value="off" ${job.compression==='off'?'selected':''}>Aus</option><option value="max" ${job.compression==='max'?'selected':''}>Maximal</option></select></label> <label>Repository<select name="repositoryId" required>${options}</select></label><label>Kompression<select name="compression"><option value="auto" ${(job.compression||'auto')==='auto'?'selected':''}>Automatisch</option><option value="off" ${job.compression==='off'?'selected':''}>Aus</option><option value="max" ${job.compression==='max'?'selected':''}>Maximal</option></select></label>
<label>CPU-Kerne für dieses Backup<input name="cpuCores" type="number" min="0" max="256" value="${Number(job.cpuCores||0)}"><span class="bu-muted">0 = alle verfügbaren Kerne</span></label>
${scheduleFields(job)} ${scheduleFields(job)}
<div id="bu-job-specific" class="wide"></div> <div id="bu-job-specific" class="wide"></div>
<label class="wide">Ausschlussmuster, eines pro Zeile<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label> <label class="wide">Ausschlussmuster, eines pro Zeile<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label>
@@ -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); 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'))}; 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.'); 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(); state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render();
} }
if (kind === 'submit-repo') { if (kind === 'submit-repo') {