diff --git a/dist/urbm-2026.06.15.r017-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r017-x86_64-1.txz.sha256 deleted file mode 100644 index 29a023e..0000000 --- a/dist/urbm-2026.06.15.r017-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -c4a3955c26ca3e380ae25c93d9d3e5706c65f4457ec6d1418cc1ed72b485cb9b urbm-2026.06.15.r017-x86_64-1.txz diff --git a/dist/urbm-2026.06.15.r017-x86_64-1.txz b/dist/urbm-2026.06.15.r018-x86_64-1.txz similarity index 56% rename from dist/urbm-2026.06.15.r017-x86_64-1.txz rename to dist/urbm-2026.06.15.r018-x86_64-1.txz index 0624917..a1f9b46 100644 Binary files a/dist/urbm-2026.06.15.r017-x86_64-1.txz and b/dist/urbm-2026.06.15.r018-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.15.r018-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r018-x86_64-1.txz.sha256 new file mode 100644 index 0000000..d4cf75f --- /dev/null +++ b/dist/urbm-2026.06.15.r018-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +56f07ac0a18bf9067c8cb38ef6e5cbf69d52a2313e37f32f87b619268a47d6e8 urbm-2026.06.15.r018-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index bc501d0..965a834 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,18 @@ - + - + ]> +### 2026.06.15.r018 +- Run a write-free Rsync preflight to estimate the actual transfer size before copying. +- Abort Rsync jobs when the estimated transfer exceeds the free destination capacity. +- Add an explicitly confirmed override for advanced replacement scenarios where existing destination files will release enough space. + ### 2026.06.15.r017 - Add scheduled Rsync copy jobs with separate source and destination folder browsers. - Add explicit controls for overwrite behavior, destination deletion, permissions, ownership, timestamps, links, ACLs, extended attributes, checksums, and dry runs. diff --git a/internal/model/model.go b/internal/model/model.go index 69790a2..8be0eb7 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -80,6 +80,7 @@ type RsyncOptions struct { PreserveXattrs bool `json:"preserveXattrs"` Checksum bool `json:"checksum"` DryRun bool `json:"dryRun"` + SkipSpaceCheck bool `json:"skipSpaceCheck"` } type Source struct { diff --git a/internal/rsync/rsync.go b/internal/rsync/rsync.go index eaa7d57..3bc0a1a 100644 --- a/internal/rsync/rsync.go +++ b/internal/rsync/rsync.go @@ -108,6 +108,68 @@ func (r *Runner) Run(ctx context.Context, runID string, job model.Job, callback return summary, nil } +// Estimate performs rsync's complete comparison without writing destination data. +func (r *Runner) Estimate(ctx context.Context, runID string, job model.Job) (Summary, error) { + job.Rsync.DryRun = true + args := Arguments(job) + for index, arg := range args { + if strings.HasPrefix(arg, "--out-format=") { + args[index] = "--out-format=" + } + } + cmd := exec.CommandContext(ctx, r.Binary, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process == nil { + return os.ErrProcessDone + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + return err + } + return nil + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return Summary{}, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return Summary{}, err + } + if err := cmd.Start(); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return Summary{}, fmt.Errorf("environment: rsync binary not found: %w", err) + } + return Summary{}, err + } + unregister := r.register(runID, cmd.Process) + defer unregister() + var summary Summary + done := make(chan struct{}) + go func() { + scanOutput(stdout, func(line string) { + if value, ok := parseCount(filesPattern, line); ok { + summary.Files = value + } + if value, ok := parseCount(bytesPattern, line); ok { + summary.Bytes = value + } + }) + close(done) + }() + errBytes, _ := io.ReadAll(io.LimitReader(stderr, 1024*1024)) + err = cmd.Wait() + <-done + if err != nil { + if errors.Is(ctx.Err(), context.Canceled) { + return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err()) + } + return Summary{}, fmt.Errorf("rsync preflight: %s", strings.TrimSpace(string(errBytes))) + } + return summary, nil +} + func Arguments(job model.Job) []string { options := job.Rsync args := []string{"--recursive", "--human-readable", "--info=progress2", "--stats", "--partial", "--out-format=FILE|%n"} diff --git a/internal/service/service.go b/internal/service/service.go index 8042077..c4aaf1a 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" "sync" + "syscall" "time" "git.casaderoll.de/michael/urbm/internal/model" @@ -402,6 +403,23 @@ func (s *Service) executeRsync(ctx context.Context, run model.Run) (result model } return failed(run, "environment", "rsync target unavailable: "+err.Error()) } + if !job.Rsync.DryRun && !job.Rsync.SkipSpaceCheck { + appendLiveLog(&live, "Rsync-Vorabprüfung ermittelt den tatsächlich zu übertragenden Datenumfang") + s.updateActive(live) + estimate, err := s.rsync.Estimate(ctx, run.ID, job) + if err != nil { + return failedError(run, err) + } + available, err := availableBytes(job.Rsync.Target) + if err != nil { + return failed(run, "environment", "read rsync target capacity: "+err.Error()) + } + appendLiveLog(&live, fmt.Sprintf("Rsync-Vorabprüfung: %s zu übertragen, %s am Ziel frei", formatBytes(estimate.Bytes), formatBytes(available))) + s.updateActive(live) + if estimate.Bytes > available { + return failed(run, "environment", fmt.Sprintf("Rsync-Ziel hat nicht genügend freien Speicher: ungefähr %s für %d Dateien erforderlich, aber nur %s frei unter %s", formatBytes(estimate.Bytes), estimate.Files, formatBytes(available), job.Rsync.Target)) + } + } started := time.Now() lastLoggedPercent := -10 summary, err := s.rsync.Run(ctx, run.ID, job, func(progress rsync.Progress) { @@ -442,6 +460,14 @@ func (s *Service) executeRsync(ctx context.Context, run model.Run) (result model return run } +func availableBytes(path string) (int64, error) { + var stats syscall.Statfs_t + if err := syscall.Statfs(path, &stats); err != nil { + return 0, err + } + return int64(stats.Bavail) * int64(stats.Bsize), nil +} + func (s *Service) executeBackup(ctx context.Context, run model.Run) (result model.Run) { job, ok := s.job(run.JobID) if !ok { diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 4dd6fb8..fb30532 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,18 @@ - + ]> +### 2026.06.15.r018 +- Run a write-free Rsync preflight to estimate the actual transfer size before copying. +- Abort Rsync jobs when the estimated transfer exceeds the free destination capacity. +- Add an explicitly confirmed override for advanced replacement scenarios where existing destination files will release enough space. + ### 2026.06.15.r017 - Add scheduled Rsync copy jobs with separate source and destination folder browsers. - Add explicit controls for overwrite behavior, destination deletion, permissions, ownership, timestamps, links, ACLs, extended attributes, checksums, and dry runs. diff --git a/webgui/URBM.page b/webgui/URBM.page index 264cac3..f4d847e 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.r017

Restic Backup Manager für Unraid

+ +

URBM 2026.06.15.r018

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 99917bb..dba8a5a 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -409,6 +409,7 @@ } else if (type === 'rsync') { const o=job.rsync||{}; host.innerHTML = `

Direkte Rsync-Kopie

Rsync erstellt keine verschlüsselten Snapshots. Die ausgewählten Ordner werden direkt in das Ziel kopiert. Die Option „Am Ziel löschen“ spiegelt Löschungen und sollte bewusst aktiviert werden.

Zielordner

Rsync-Optionen
`; + host.querySelector('.bu-event-options').insertAdjacentHTML('beforeend', ``); state.sourceBrowserPath='/'; state.sourceDirectories=sourceRoots; state.sourceBrowserError=''; renderSourceBrowser(); renderRsyncTargetBrowser(); } else { state.backupSelections = ['/boot']; @@ -668,9 +669,10 @@ 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(type!=='rsync'&&!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Die Aufbewahrung benötigt mindestens eine Alters- oder Kalenderregel.'); - const rsync=type==='rsync'?{target:String(f.get('rsyncTarget')||'').trim(),overwrite:f.get('rsyncOverwrite')==='on',delete:f.get('rsyncDelete')==='on',preservePermissions:f.get('rsyncPermissions')==='on',preserveOwner:f.get('rsyncOwner')==='on',preserveGroup:f.get('rsyncGroup')==='on',preserveTimes:f.get('rsyncTimes')==='on',preserveLinks:f.get('rsyncLinks')==='on',preserveAcls:f.get('rsyncACLs')==='on',preserveXattrs:f.get('rsyncXattrs')==='on',checksum:f.get('rsyncChecksum')==='on',dryRun:f.get('rsyncDryRun')==='on'}:{}; + const rsync=type==='rsync'?{target:String(f.get('rsyncTarget')||'').trim(),overwrite:f.get('rsyncOverwrite')==='on',delete:f.get('rsyncDelete')==='on',preservePermissions:f.get('rsyncPermissions')==='on',preserveOwner:f.get('rsyncOwner')==='on',preserveGroup:f.get('rsyncGroup')==='on',preserveTimes:f.get('rsyncTimes')==='on',preserveLinks:f.get('rsyncLinks')==='on',preserveAcls:f.get('rsyncACLs')==='on',preserveXattrs:f.get('rsyncXattrs')==='on',checksum:f.get('rsyncChecksum')==='on',dryRun:f.get('rsyncDryRun')==='on',skipSpaceCheck:f.get('rsyncSkipSpaceCheck')==='on'}:{}; if(type==='rsync'&&!rsync.target) throw new Error('Wähle einen Zielordner für die Rsync-Kopie.'); if(type==='rsync'&&rsync.delete&&!confirm('Rsync darf Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind. Diese Option kann Daten am Ziel entfernen. Wirklich speichern?')) return; + if(type==='rsync'&&rsync.skipSpaceCheck&&!confirm('Die Kapazitätsprüfung wirklich abschalten? Der Rsync-Lauf kann dann das Ziel vollständig füllen und mit einem Schreibfehler abbrechen.')) return; const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:type==='rsync'?'':f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:type==='rsync'?'':f.get('compression'),cpuCores:type==='rsync'?0:Number(f.get('cpuCores')||0),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention:type==='rsync'?{}:retention,rsync,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(); }