Release URBM 2026.06.15.r007

This commit is contained in:
Mikei386
2026-06-15 07:14:24 +02:00
parent 706f8b997b
commit b0fa11a40a
14 changed files with 214 additions and 13 deletions
+2
View File
@@ -2,6 +2,8 @@
URBM is a native Unraid 7 plugin that manages encrypted, deduplicated and versioned backups through Restic. It supports shares, appdata, Docker metadata and data, VM definitions and disks, and the Unraid boot drive. Destinations can be local, SFTP, SMB or NFS.
USB flash jobs always back up the browsable `/boot` file tree and can optionally stream the complete physical boot device into Restic as `urbm-usb-flash.img` for block-level recovery to an equal-sized or larger replacement device.
URBM is an independent community plugin and is not affiliated with or endorsed by Lime Technology, Inc.
## Architecture
-1
View File
@@ -1 +0,0 @@
b5bcaefc48ac2258d036822d8e735d85f24384ec185720571c5a3a919c71fd70 urbm-2026.06.15.r006-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
61d61cf633884a645c15400665fc352637f641d93bd9a25594fb3e9659d72fe0 urbm-2026.06.15.r007-x86_64-1.txz
+7 -2
View File
@@ -2,13 +2,18 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "urbm">
<!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r006">
<!ENTITY version "2026.06.15.r007">
<!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 packageSHA256 "b5bcaefc48ac2258d036822d8e735d85f24384ec185720571c5a3a919c71fd70">
<!ENTITY packageSHA256 "61d61cf633884a645c15400665fc352637f641d93bd9a25594fb3e9659d72fe0">
]>
<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>
### 2026.06.15.r007
- Add an optional raw USB flash image to flash backup jobs while retaining the normal browsable /boot backup.
- Stream the physical boot device directly into Restic as urbm-usb-flash.img without a temporary full-size image file.
- Detect the whole USB device behind the mounted /boot partition and tag image snapshots for retention and recovery.
### 2026.06.15.r006
- Place running backup controls directly beside progress instead of beyond an empty message column.
- Remove the unnecessary activity-table action column and its horizontal overflow.
+1
View File
@@ -54,6 +54,7 @@ type Job struct {
Compression string `json:"compression"`
Excludes []string `json:"excludes,omitempty"`
Tags []string `json:"tags,omitempty"`
FlashImage bool `json:"flashImage,omitempty"`
Retention Retention `json:"retention"`
NotifyOn []string `json:"notifyOn,omitempty"`
ShutdownSecs int `json:"shutdownTimeoutSeconds"`
+54 -1
View File
@@ -14,7 +14,12 @@ import (
"git.casaderoll.de/michael/urbm/internal/model"
)
type WorkloadManager struct{ RuntimeDir string }
type WorkloadManager struct {
RuntimeDir string
FindmntPath string
LsblkPath string
Stat func(string) (os.FileInfo, error)
}
type Workload struct {
ID string `json:"id"`
@@ -149,6 +154,54 @@ func (w *WorkloadManager) Prepare(ctx context.Context, job model.Job) (Prepared,
return prepared, nil
}
func (w *WorkloadManager) FlashDevice(ctx context.Context) (string, error) {
findmnt := w.FindmntPath
if findmnt == "" {
findmnt = "findmnt"
}
output, err := exec.CommandContext(ctx, findmnt, "--noheadings", "--output", "SOURCE", "--target", "/boot").Output()
if err != nil {
return "", fmt.Errorf("source: determine USB flash partition: %w", err)
}
partition := strings.TrimSpace(string(output))
if index := strings.IndexByte(partition, '['); index >= 0 {
partition = partition[:index]
}
partition = filepath.Clean(partition)
if !strings.HasPrefix(partition, "/dev/") || strings.ContainsAny(partition, "\r\n\x00") {
return "", fmt.Errorf("source: /boot is not backed by a device: %q", partition)
}
lsblk := w.LsblkPath
if lsblk == "" {
lsblk = "lsblk"
}
output, err = exec.CommandContext(ctx, lsblk, "--noheadings", "--output", "PKNAME", "--", partition).Output()
if err != nil {
return "", fmt.Errorf("source: determine USB flash device for %s: %w", partition, err)
}
parent := strings.TrimSpace(string(output))
device := partition
if parent != "" {
if strings.ContainsAny(parent, "/\r\n\x00") {
return "", fmt.Errorf("source: invalid USB flash parent device %q", parent)
}
device = filepath.Join("/dev", parent)
}
stat := w.Stat
if stat == nil {
stat = os.Stat
}
info, err := stat(device)
if err != nil {
return "", fmt.Errorf("source: access USB flash device %s: %w", device, err)
}
if info.Mode()&os.ModeDevice == 0 {
return "", fmt.Errorf("source: USB flash source %s is not a block device", device)
}
return device, nil
}
func (w *WorkloadManager) Cleanup(ctx context.Context, prepared Prepared) error {
var first error
for i := len(prepared.Stopped) - 1; i >= 0; i-- {
+40
View File
@@ -1,11 +1,22 @@
package platform
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
type blockDeviceInfo struct{ name string }
func (i blockDeviceInfo) Name() string { return i.name }
func (blockDeviceInfo) Size() int64 { return 0 }
func (blockDeviceInfo) Mode() os.FileMode { return os.ModeDevice }
func (blockDeviceInfo) ModTime() time.Time { return time.Time{} }
func (blockDeviceInfo) IsDir() bool { return false }
func (blockDeviceInfo) Sys() any { return nil }
func TestUnraidServiceEnabled(t *testing.T) {
path := filepath.Join(t.TempDir(), "domain.cfg")
for _, test := range []struct {
@@ -24,3 +35,32 @@ func TestUnraidServiceEnabled(t *testing.T) {
}
}
}
func TestFlashDeviceResolvesWholeDiskBehindBootPartition(t *testing.T) {
dir := t.TempDir()
findmnt := filepath.Join(dir, "findmnt")
lsblk := filepath.Join(dir, "lsblk")
if err := os.WriteFile(findmnt, []byte("#!/bin/sh\nprintf '/dev/sda1\\n'\n"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(lsblk, []byte("#!/bin/sh\nprintf 'sda\\n'\n"), 0700); err != nil {
t.Fatal(err)
}
w := &WorkloadManager{
FindmntPath: findmnt,
LsblkPath: lsblk,
Stat: func(path string) (os.FileInfo, error) {
if path != "/dev/sda" {
t.Fatalf("stat path = %q", path)
}
return blockDeviceInfo{name: "sda"}, nil
},
}
device, err := w.FlashDevice(context.Background())
if err != nil {
t.Fatal(err)
}
if device != "/dev/sda" {
t.Fatalf("device = %q", device)
}
}
+27
View File
@@ -125,6 +125,28 @@ func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Jo
return summary, err
}
func (r *Runner) BackupImage(ctx context.Context, repo model.Repository, job model.Job, image io.Reader, progress ProgressCallback) (Summary, error) {
args := []string{"backup", "--json", "--stdin", "--stdin-filename", "urbm-usb-flash.img", "--compression", job.Compression}
for _, tag := range append([]string{"urbm", "job:" + job.ID, "usb-image"}, job.Tags...) {
args = append(args, "--tag", tag)
}
var summary Summary
err := r.runWithInput(ctx, repo, args, func(line []byte) {
var status Progress
if json.Unmarshal(line, &status) == nil && status.MessageType == "status" {
if progress != nil {
progress(status)
}
return
}
var candidate Summary
if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" {
summary = candidate
}
}, nil, image)
return summary, err
}
func (r *Runner) Forget(ctx context.Context, repo model.Repository, job model.Job) error {
args := []string{"forget", "--tag", "job:" + job.ID}
if job.Retention.KeepWithinDays > 0 {
@@ -201,6 +223,10 @@ 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)
}
func (r *Runner) runWithInput(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte, input io.Reader) error {
password, err := r.Secrets.Get(repo.PasswordRef)
if err != nil {
return fmt.Errorf("authentication: load repository password: %w", err)
@@ -257,6 +283,7 @@ func (r *Runner) run(ctx context.Context, repo model.Repository, args []string,
}
fullArgs := append(globalArgs, args...)
cmd := exec.CommandContext(ctx, r.Binary, fullArgs...)
cmd.Stdin = input
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process == nil {
+31
View File
@@ -51,6 +51,37 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) {
}
}
func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) {
dir := t.TempDir()
argsPath := filepath.Join(dir, "args")
imagePath := filepath.Join(dir, "image")
script := filepath.Join(dir, "restic")
body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"image123\",\"data_added\":4,\"total_bytes_processed\":4,\"total_files_processed\":1}'\n", argsPath, imagePath)
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: "flash", Compression: "auto"}
summary, err := runner.BackupImage(context.Background(), repo, job, strings.NewReader("disk"), nil)
if err != nil {
t.Fatal(err)
}
if summary.SnapshotID != "image123" || summary.TotalBytesProcessed != 4 {
t.Fatalf("unexpected summary: %+v", summary)
}
args, _ := os.ReadFile(argsPath)
for _, expected := range []string{"--stdin", "--stdin-filename", "urbm-usb-flash.img", "usb-image"} {
if !strings.Contains(string(args), expected) {
t.Fatalf("arguments missing %q: %s", expected, args)
}
}
image, _ := os.ReadFile(imagePath)
if string(image) != "disk" {
t.Fatalf("streamed image = %q", image)
}
}
func TestForgetUsesAgeAndCalendarRetention(t *testing.T) {
dir := t.TempDir()
argsPath := filepath.Join(dir, "args")
+39 -2
View File
@@ -329,6 +329,39 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
return failed(run, "source", "source unavailable: "+path)
}
}
var imageSummary restic.Summary
if job.Type == model.JobFlash && job.FlashImage {
device, err := s.workloads.FlashDevice(ctx)
if err != nil {
return failedError(run, err)
}
image, err := os.Open(device)
if err != nil {
return failed(run, "source", "open USB flash device: "+err.Error())
}
appendLiveLog(&live, "USB-Rohabbild wird direkt aus "+device+" zu Restic übertragen")
s.updateActive(live)
imageSummary, err = s.restic.BackupImage(ctx, mounted.Repository, job, image, func(progress restic.Progress) {
live.ProgressPercent = progress.PercentDone * 100
live.ProgressBytes, live.ProgressTotal = progress.BytesDone, progress.TotalBytes
live.ProgressFiles, live.ProgressFileTotal = progress.FilesDone, progress.TotalFiles
live.SecondsRemaining = progress.SecondsRemaining
live.CurrentFile = "urbm-usb-flash.img"
s.updateActive(live)
})
closeErr := image.Close()
if err != nil {
appendLiveLog(&live, "USB-Rohabbild fehlgeschlagen: "+err.Error())
failedRun := failedError(run, err)
failedRun.LiveLog = live.LiveLog
return failedRun
}
if closeErr != nil {
return failed(run, "source", "close USB flash device: "+closeErr.Error())
}
appendLiveLog(&live, "USB-Rohabbild als urbm-usb-flash.img gesichert")
s.updateActive(live)
}
appendLiveLog(&live, fmt.Sprintf("Restic-Backup gestartet: %d Quelle(n)", len(prepared.Sources)))
s.updateActive(live)
lastLoggedPercent := -5
@@ -364,8 +397,12 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
} else {
run.Status, run.Message = "success", "backup completed"
}
run.SnapshotID, run.BytesAdded, run.FilesNew, run.FilesChanged = summary.SnapshotID, summary.DataAdded, summary.FilesNew, summary.FilesChanged
run.BytesProcessed, run.FilesProcessed = summary.TotalBytesProcessed, summary.TotalFilesProcessed
run.SnapshotID = summary.SnapshotID
run.BytesAdded = summary.DataAdded + imageSummary.DataAdded
run.FilesNew = summary.FilesNew + imageSummary.FilesNew
run.FilesChanged = summary.FilesChanged + imageSummary.FilesChanged
run.BytesProcessed = summary.TotalBytesProcessed + imageSummary.TotalBytesProcessed
run.FilesProcessed = summary.TotalFilesProcessed + imageSummary.TotalFilesProcessed
run.ProgressPercent, run.ProgressBytes, run.ProgressTotal = 100, live.ProgressBytes, live.ProgressTotal
run.ProgressFiles, run.ProgressFileTotal = live.ProgressFiles, live.ProgressFileTotal
run.BytesPerSecond, run.SecondsRemaining, run.CurrentFile = live.BytesPerSecond, 0, live.CurrentFile
+6 -1
View File
@@ -2,13 +2,18 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "urbm">
<!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r006">
<!ENTITY version "2026.06.15.r007">
<!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 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">
<CHANGES>
### 2026.06.15.r007
- Add an optional raw USB flash image to flash backup jobs while retaining the normal browsable /boot backup.
- Stream the physical boot device directly into Restic as urbm-usb-flash.img without a temporary full-size image file.
- Detect the whole USB device behind the mounted /boot partition and tag image snapshots for retention and recovery.
### 2026.06.15.r006
- Place running backup controls directly beside progress instead of beyond an empty message column.
- Remove the unnecessary activity-table action column and its horizontal overflow.
+4 -4
View File
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
<?php
$pluginRoot = '/plugins/urbm';
?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r006">
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r007">
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header">
<div class="bu-brand">
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r006" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.15.r006</span></h1><p>Restic Backup Manager für Unraid</p></div>
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r007" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.15.r007</span></h1><p>Restic Backup Manager für Unraid</p></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>
</header>
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div>
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r006.js"></script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r007.js"></script>
+2 -2
View File
@@ -368,7 +368,7 @@
loadWorkloads(type);
} else {
state.backupSelections = ['/boot'];
host.innerHTML = `<div class="bu-info-card"><h3>Unraid-USB-Flash-Backup</h3><p>Das vollständige Konfigurationslaufwerk <strong>/boot</strong> wird gesichert. Es ist keine zusätzliche Quellenauswahl erforderlich.</p></div>`;
host.innerHTML = `<div class="bu-info-card"><h3>Unraid-USB-Flash-Backup</h3><p>Das vollständige Konfigurationslaufwerk <strong>/boot</strong> wird immer dateibasiert gesichert.</p><label class="bu-inline-check"><input name="flashImage" type="checkbox" ${job.flashImage===true?'checked':''}><span>Zusätzlich vollständiges USB-Rohabbild <strong>urbm-usb-flash.img</strong> sichern</span></label><p class="bu-muted">Das Abbild wird ohne Zwischenkopie direkt zu Restic übertragen und kann nach einer Wiederherstellung mit einem Image-Programm auf einen mindestens gleich großen Stick geschrieben werden. Die an die USB-GUID gebundene Unraid-Lizenz muss bei einem Stickwechsel separat übertragen werden.</p></div>`;
}
enhanceTooltips(host);
}
@@ -590,7 +590,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||[],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'),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') {