Add scheduled rsync copy jobs
This commit is contained in:
+43
-18
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user