Initial Backupper Unraid plugin
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
const SchemaVersion = 1
|
||||
|
||||
type JobType string
|
||||
|
||||
const (
|
||||
JobShare JobType = "share"
|
||||
JobAppdata JobType = "appdata"
|
||||
JobDocker JobType = "docker"
|
||||
JobVM JobType = "vm"
|
||||
JobFlash JobType = "flash"
|
||||
)
|
||||
|
||||
type RepositoryType string
|
||||
|
||||
const (
|
||||
RepositoryLocal RepositoryType = "local"
|
||||
RepositorySMB RepositoryType = "smb"
|
||||
RepositoryNFS RepositoryType = "nfs"
|
||||
RepositorySFTP RepositoryType = "sftp"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Jobs []Job `json:"jobs"`
|
||||
Repositories []Repository `json:"repositories"`
|
||||
Notifications []NotificationTarget `json:"notifications"`
|
||||
Settings Settings `json:"settings"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
ResticPath string `json:"resticPath"`
|
||||
RestoreRoot string `json:"restoreRoot"`
|
||||
PersistentLogDir string `json:"persistentLogDir,omitempty"`
|
||||
CatchUpWindowHrs int `json:"catchUpWindowHours"`
|
||||
CheckCron string `json:"checkCron"`
|
||||
PruneCron string `json:"pruneCron"`
|
||||
}
|
||||
|
||||
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"`
|
||||
Excludes []string `json:"excludes,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Retention Retention `json:"retention"`
|
||||
NotifyOn []string `json:"notifyOn,omitempty"`
|
||||
ShutdownSecs int `json:"shutdownTimeoutSeconds"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
WorkloadID string `json:"workloadId,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type Schedule struct {
|
||||
Cron string `json:"cron"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
type Consistency struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type Retention struct {
|
||||
Daily int `json:"daily"`
|
||||
Weekly int `json:"weekly"`
|
||||
Monthly int `json:"monthly"`
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type RepositoryType `json:"type"`
|
||||
Location string `json:"location"`
|
||||
PasswordRef string `json:"passwordRef"`
|
||||
CredentialRef string `json:"credentialRef,omitempty"`
|
||||
Mount *MountConfig `json:"mount,omitempty"`
|
||||
Options map[string]string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type MountConfig struct {
|
||||
Managed bool `json:"managed"`
|
||||
Remote string `json:"remote,omitempty"`
|
||||
MountPoint string `json:"mountPoint,omitempty"`
|
||||
ReadOnly bool `json:"readOnly,omitempty"`
|
||||
}
|
||||
|
||||
type NotificationTarget struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
TokenRef string `json:"tokenRef,omitempty"`
|
||||
Events []string `json:"events,omitempty"`
|
||||
}
|
||||
|
||||
type Run struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
TaskType string `json:"taskType"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
SnapshotID string `json:"snapshotId,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
BytesAdded int64 `json:"bytesAdded,omitempty"`
|
||||
FilesNew int64 `json:"filesNew,omitempty"`
|
||||
}
|
||||
|
||||
type RestoreTask struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
RepositoryID string `json:"repositoryId"`
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
Includes []string `json:"includes"`
|
||||
Target string `json:"target"`
|
||||
InPlace bool `json:"inPlace"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
ID string `json:"id"`
|
||||
ShortID string `json:"short_id"`
|
||||
Time time.Time `json:"time"`
|
||||
Hostname string `json:"hostname"`
|
||||
Paths []string `json:"paths"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
SchemaVersion: SchemaVersion,
|
||||
Jobs: []Job{},
|
||||
Repositories: []Repository{},
|
||||
Notifications: []NotificationTarget{},
|
||||
Settings: Settings{
|
||||
ResticPath: "/usr/local/libexec/backupper/restic",
|
||||
RestoreRoot: "/mnt/user/backupper-restores",
|
||||
CatchUpWindowHrs: 24,
|
||||
CheckCron: "0 3 1 * *",
|
||||
PruneCron: "0 4 * * 0",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
|
||||
|
||||
func ValidateConfig(c Config) error {
|
||||
if c.SchemaVersion != SchemaVersion {
|
||||
return fmt.Errorf("unsupported schemaVersion %d", c.SchemaVersion)
|
||||
}
|
||||
repos := make(map[string]Repository, len(c.Repositories))
|
||||
for _, repo := range c.Repositories {
|
||||
if err := ValidateRepository(repo); err != nil {
|
||||
return fmt.Errorf("repository %q: %w", repo.ID, err)
|
||||
}
|
||||
if _, exists := repos[repo.ID]; exists {
|
||||
return fmt.Errorf("duplicate repository id %q", repo.ID)
|
||||
}
|
||||
repos[repo.ID] = repo
|
||||
}
|
||||
jobs := make(map[string]struct{}, len(c.Jobs))
|
||||
repositoryOwners := make(map[string]string, len(c.Jobs))
|
||||
for _, job := range c.Jobs {
|
||||
if err := ValidateJob(job); err != nil {
|
||||
return fmt.Errorf("job %q: %w", job.ID, err)
|
||||
}
|
||||
if _, exists := jobs[job.ID]; exists {
|
||||
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 owner, exists := repositoryOwners[job.RepositoryID]; exists {
|
||||
return fmt.Errorf("repository %q is already assigned to job %q", job.RepositoryID, owner)
|
||||
}
|
||||
repositoryOwners[job.RepositoryID] = job.ID
|
||||
}
|
||||
if !filepath.IsAbs(c.Settings.RestoreRoot) {
|
||||
return errors.New("restoreRoot must be absolute")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
switch j.Type {
|
||||
case JobShare, JobAppdata, JobDocker, JobVM, JobFlash:
|
||||
default:
|
||||
return fmt.Errorf("unsupported job type %q", j.Type)
|
||||
}
|
||||
if len(j.Sources) == 0 && j.Type != JobFlash {
|
||||
return errors.New("at least one source is required")
|
||||
}
|
||||
if j.Type == JobFlash && len(j.Sources) == 0 {
|
||||
j.Sources = []Source{{Path: "/boot"}}
|
||||
}
|
||||
for _, source := range j.Sources {
|
||||
if source.Path != "" && !filepath.IsAbs(source.Path) {
|
||||
return fmt.Errorf("source path %q must be absolute", source.Path)
|
||||
}
|
||||
if source.Path == "" && source.WorkloadID == "" {
|
||||
return errors.New("source requires path or workloadId")
|
||||
}
|
||||
}
|
||||
if j.Compression != "auto" && j.Compression != "off" && j.Compression != "max" {
|
||||
return errors.New("compression must be auto, off, or max")
|
||||
}
|
||||
if j.Consistency.Mode != "live" && j.Consistency.Mode != "stop" {
|
||||
return errors.New("consistency mode must be live or stop")
|
||||
}
|
||||
if j.Retention.Daily < 0 || j.Retention.Weekly < 0 || j.Retention.Monthly < 0 {
|
||||
return errors.New("retention values cannot be negative")
|
||||
}
|
||||
for _, exclude := range j.Excludes {
|
||||
if strings.ContainsAny(exclude, "\x00\r\n") {
|
||||
return errors.New("exclude contains forbidden control characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateRepository(r Repository) error {
|
||||
if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) {
|
||||
return errors.New("invalid schemaVersion or id")
|
||||
}
|
||||
if r.Name == "" || r.Location == "" || r.PasswordRef == "" {
|
||||
return errors.New("name, location, and passwordRef are required")
|
||||
}
|
||||
if !idPattern.MatchString(r.PasswordRef) || (r.CredentialRef != "" && !idPattern.MatchString(r.CredentialRef)) {
|
||||
return errors.New("secret references must be valid IDs")
|
||||
}
|
||||
switch r.Type {
|
||||
case RepositoryLocal, RepositorySMB, RepositoryNFS, RepositorySFTP:
|
||||
default:
|
||||
return fmt.Errorf("unsupported repository type %q", r.Type)
|
||||
}
|
||||
if r.Type == RepositoryLocal && !filepath.IsAbs(r.Location) {
|
||||
return errors.New("local repository location must be absolute")
|
||||
}
|
||||
if (r.Type == RepositorySMB || r.Type == RepositoryNFS) && r.Mount != nil && r.Mount.Managed {
|
||||
if r.Mount.Remote == "" || !filepath.IsAbs(r.Mount.MountPoint) {
|
||||
return errors.New("managed mount requires remote and absolute mountPoint")
|
||||
}
|
||||
}
|
||||
if r.Type == RepositorySFTP && r.CredentialRef != "" {
|
||||
knownHosts := r.Options["knownHostsPath"]
|
||||
if !filepath.IsAbs(knownHosts) || strings.ContainsAny(knownHosts, " \t\r\n") {
|
||||
return errors.New("SFTP key authentication requires an absolute knownHostsPath without whitespace")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateConfig(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
c.Repositories = append(c.Repositories, Repository{SchemaVersion: 1, ID: "repo", Name: "Local", Type: RepositoryLocal, Location: "/tmp/repo", PasswordRef: "password"})
|
||||
c.Jobs = append(c.Jobs, Job{SchemaVersion: 1, ID: "job", Name: "Share", Type: JobShare, Enabled: true, RepositoryID: "repo", Sources: []Source{{Path: "/mnt/user/data"}}, Schedule: Schedule{Cron: "0 2 * * *"}, Consistency: Consistency{Mode: "live"}, Compression: "auto", Retention: Retention{Daily: 7, Weekly: 4, Monthly: 12}, ShutdownSecs: 120})
|
||||
if err := ValidateConfig(c); err != nil {
|
||||
t.Fatalf("valid config rejected: %v", err)
|
||||
}
|
||||
c.Jobs[0].Sources[0].Path = "relative"
|
||||
if err := ValidateConfig(c); err == nil {
|
||||
t.Fatal("relative source path accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryIsolation(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
c.Jobs = []Job{{SchemaVersion: 1, ID: "job", Name: "Broken", Type: JobFlash, RepositoryID: "missing", Consistency: Consistency{Mode: "live"}, Compression: "auto"}}
|
||||
if err := ValidateConfig(c); err == nil {
|
||||
t.Fatal("unknown repository reference accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryCannotBeSharedByJobs(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
c.Repositories = []Repository{{SchemaVersion: 1, ID: "repo", Name: "Local", Type: RepositoryLocal, Location: "/tmp/repo", PasswordRef: "password"}}
|
||||
base := Job{SchemaVersion: 1, Name: "Flash", Type: JobFlash, RepositoryID: "repo", Consistency: Consistency{Mode: "live"}, Compression: "auto"}
|
||||
first, second := base, base
|
||||
first.ID, second.ID = "first", "second"
|
||||
c.Jobs = []Job{first, second}
|
||||
if err := ValidateConfig(c); err == nil {
|
||||
t.Fatal("shared repository accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user