Add scheduled rsync copy jobs

This commit is contained in:
Mikei386
2026-06-15 17:15:27 +02:00
parent 2abfc8eb26
commit 6c795fd97d
15 changed files with 524 additions and 59 deletions
+43 -18
View File
@@ -1,6 +1,9 @@
package model
import "time"
import (
"strings"
"time"
)
const SchemaVersion = 1
@@ -12,6 +15,7 @@ const (
JobDocker JobType = "docker"
JobVM JobType = "vm"
JobFlash JobType = "flash"
JobRsync JobType = "rsync"
)
type RepositoryType string
@@ -33,6 +37,7 @@ type Config struct {
type Settings struct {
ResticPath string `json:"resticPath"`
RsyncPath string `json:"rsyncPath"`
RestoreRoot string `json:"restoreRoot"`
PersistentLogDir string `json:"persistentLogDir,omitempty"`
ShowLiveLog bool `json:"showLiveLog"`
@@ -42,23 +47,39 @@ type Settings struct {
}
type Job struct {
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
Name string `json:"name"`
Type JobType `json:"type"`
Enabled bool `json:"enabled"`
RepositoryID string `json:"repositoryId"`
Sources []Source `json:"sources"`
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"`
Retention Retention `json:"retention"`
NotifyOn []string `json:"notifyOn,omitempty"`
ShutdownSecs int `json:"shutdownTimeoutSeconds"`
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
Name string `json:"name"`
Type JobType `json:"type"`
Enabled bool `json:"enabled"`
RepositoryID string `json:"repositoryId"`
Sources []Source `json:"sources"`
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"`
Retention Retention `json:"retention"`
NotifyOn []string `json:"notifyOn,omitempty"`
ShutdownSecs int `json:"shutdownTimeoutSeconds"`
Rsync RsyncOptions `json:"rsync,omitempty"`
}
type RsyncOptions struct {
Target string `json:"target"`
Overwrite bool `json:"overwrite"`
Delete bool `json:"delete"`
PreservePermissions bool `json:"preservePermissions"`
PreserveOwner bool `json:"preserveOwner"`
PreserveGroup bool `json:"preserveGroup"`
PreserveTimes bool `json:"preserveTimes"`
PreserveLinks bool `json:"preserveLinks"`
PreserveACLs bool `json:"preserveAcls"`
PreserveXattrs bool `json:"preserveXattrs"`
Checksum bool `json:"checksum"`
DryRun bool `json:"dryRun"`
}
type Source struct {
@@ -180,6 +201,7 @@ func DefaultConfig() Config {
Notifications: []NotificationTarget{{SchemaVersion: SchemaVersion, ID: "unraid", Name: "Unraid", Type: "unraid", Enabled: true, Events: []string{"success", "warning", "failed"}}},
Settings: Settings{
ResticPath: "/usr/local/libexec/urbm/restic",
RsyncPath: "/usr/bin/rsync",
RestoreRoot: "/mnt/user/urbm-restores",
CatchUpWindowHrs: 24,
CheckCron: "0 3 1 * *",
@@ -189,6 +211,9 @@ func DefaultConfig() Config {
}
func NormalizeConfig(c Config) Config {
if strings.TrimSpace(c.Settings.RsyncPath) == "" {
c.Settings.RsyncPath = "/usr/bin/rsync"
}
notifications := make([]NotificationTarget, 0, len(c.Notifications)+1)
hasUnraid := false
for _, target := range c.Notifications {
+41 -8
View File
@@ -34,8 +34,10 @@ func ValidateConfig(c Config) error {
return fmt.Errorf("duplicate job id %q", job.ID)
}
jobs[job.ID] = struct{}{}
if _, exists := repos[job.RepositoryID]; !exists {
return fmt.Errorf("job %q references unknown repository %q", job.ID, job.RepositoryID)
if job.Type != JobRsync {
if _, exists := repos[job.RepositoryID]; !exists {
return fmt.Errorf("job %q references unknown repository %q", job.ID, job.RepositoryID)
}
}
}
notifications := make(map[string]struct{}, len(c.Notifications))
@@ -90,11 +92,11 @@ func ValidateJob(j Job) error {
if j.SchemaVersion != SchemaVersion || !idPattern.MatchString(j.ID) {
return errors.New("invalid schemaVersion or id")
}
if strings.TrimSpace(j.Name) == "" || strings.TrimSpace(j.RepositoryID) == "" {
return errors.New("name and repositoryId are required")
if strings.TrimSpace(j.Name) == "" {
return errors.New("name is required")
}
switch j.Type {
case JobShare, JobAppdata, JobDocker, JobVM, JobFlash:
case JobShare, JobAppdata, JobDocker, JobVM, JobFlash, JobRsync:
default:
return fmt.Errorf("unsupported job type %q", j.Type)
}
@@ -119,6 +121,17 @@ func ValidateJob(j Job) error {
}
}
}
for _, exclude := range j.Excludes {
if strings.ContainsAny(exclude, "\x00\r\n") {
return errors.New("exclude contains forbidden control characters")
}
}
if j.Type == JobRsync {
return validateRsyncJob(j)
}
if strings.TrimSpace(j.RepositoryID) == "" {
return errors.New("repositoryId is required")
}
if j.Compression != "auto" && j.Compression != "off" && j.Compression != "max" {
return errors.New("compression must be auto, off, or max")
}
@@ -134,14 +147,34 @@ func ValidateJob(j Job) error {
if j.Retention.KeepWithinDays == 0 && j.Retention.Daily == 0 && j.Retention.Weekly == 0 && j.Retention.Monthly == 0 {
return errors.New("retention must keep at least one age or calendar policy")
}
for _, exclude := range j.Excludes {
if strings.ContainsAny(exclude, "\x00\r\n") {
return errors.New("exclude contains forbidden control characters")
return nil
}
func validateRsyncJob(j Job) error {
target := filepath.Clean(strings.TrimSpace(j.Rsync.Target))
if !filepath.IsAbs(target) {
return errors.New("rsync target must be absolute")
}
if !pathWithin(target, "/mnt/user") && !pathWithin(target, "/mnt/disks") && !pathWithin(target, "/mnt/remotes") {
return errors.New("rsync target must be below /mnt/user, /mnt/disks, or /mnt/remotes")
}
for _, source := range j.Sources {
if source.WorkloadID != "" || source.Path == "" {
return errors.New("rsync jobs require filesystem paths")
}
cleanSource := filepath.Clean(source.Path)
if pathWithin(target, cleanSource) || pathWithin(cleanSource, target) {
return fmt.Errorf("rsync source %q and target %q must not contain each other", cleanSource, target)
}
}
return nil
}
func pathWithin(path, root string) bool {
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func ValidateRepository(r Repository) error {
if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) {
return errors.New("invalid schemaVersion or id")
+15
View File
@@ -91,3 +91,18 @@ func TestBackupCPUCoresRange(t *testing.T) {
t.Fatal("excessive CPU limit accepted")
}
}
func TestRsyncJobDoesNotRequireRepository(t *testing.T) {
c := DefaultConfig()
c.Jobs = []Job{{SchemaVersion: 1, ID: "rsync-job", Name: "Mirror", Type: JobRsync, Enabled: true, Sources: []Source{{Path: "/mnt/user/data"}}, Rsync: RsyncOptions{Target: "/mnt/remotes/backup", Overwrite: true}}}
if err := ValidateConfig(c); err != nil {
t.Fatalf("valid rsync job rejected: %v", err)
}
}
func TestRsyncJobRejectsOverlappingPaths(t *testing.T) {
job := Job{SchemaVersion: 1, ID: "rsync-job", Name: "Mirror", Type: JobRsync, Sources: []Source{{Path: "/mnt/user/data"}}, Rsync: RsyncOptions{Target: "/mnt/user/data/copy"}}
if err := ValidateJob(job); err == nil {
t.Fatal("rsync target inside source accepted")
}
}