Release URBM 2026.06.15.r007
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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-- {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user