Initial Backupper Unraid plugin

This commit is contained in:
Mikei386
2026-06-13 22:07:31 +02:00
commit 5352a9da22
39 changed files with 3167 additions and 0 deletions
+164
View File
@@ -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",
},
}
}
+124
View File
@@ -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
}
+36
View File
@@ -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")
}
}