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
+232
View File
@@ -0,0 +1,232 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/backupper-unraid/backupper/internal/model"
"github.com/backupper-unraid/backupper/internal/service"
)
type Server struct {
service *service.Service
log *slog.Logger
http *http.Server
socket string
}
var Version = "dev"
func New(socket string, svc *service.Service, log *slog.Logger) *Server {
s := &Server{service: svc, log: log, socket: socket}
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/health", s.health)
mux.HandleFunc("GET /v1/config", s.getConfig)
mux.HandleFunc("PUT /v1/config", s.putConfig)
mux.HandleFunc("PUT /v1/secrets/{id}", s.putSecret)
mux.HandleFunc("DELETE /v1/secrets/{id}", s.deleteSecret)
mux.HandleFunc("GET /v1/runs", s.runs)
mux.HandleFunc("POST /v1/jobs/{id}/run", s.runJob)
mux.HandleFunc("POST /v1/runs/{id}/cancel", s.cancelRun)
mux.HandleFunc("POST /v1/repositories/{id}/test", s.testRepository)
mux.HandleFunc("POST /v1/repositories/{id}/init", s.initRepository)
mux.HandleFunc("POST /v1/repositories/{id}/{action}", s.maintenance)
mux.HandleFunc("GET /v1/repositories/{id}/snapshots", s.snapshots)
mux.HandleFunc("GET /v1/repositories/{id}/snapshots/{snapshot}/files", s.snapshotFiles)
mux.HandleFunc("POST /v1/restores", s.restore)
s.http = &http.Server{Handler: requestLog(log, mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 5 * time.Minute, IdleTimeout: 30 * time.Second}
return s
}
func (s *Server) ListenAndServe() error {
if err := os.MkdirAll(filepath.Dir(s.socket), 0750); err != nil {
return err
}
_ = os.Remove(s.socket)
listener, err := net.Listen("unix", s.socket)
if err != nil {
return err
}
if err := os.Chmod(s.socket, 0660); err != nil {
listener.Close()
return err
}
err = s.http.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
func (s *Server) Shutdown(ctx context.Context) error {
defer os.Remove(s.socket)
return s.http.Shutdown(ctx)
}
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, map[string]any{"status": "ok", "version": Version, "schemaVersion": model.SchemaVersion})
}
func (s *Server) getConfig(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, s.service.Config())
}
func (s *Server) putConfig(w http.ResponseWriter, r *http.Request) {
var config model.Config
if err := decode(r, &config); err != nil {
writeError(w, err)
return
}
if err := s.service.SaveConfig(config); err != nil {
writeError(w, err)
return
}
writeJSON(w, 200, config)
}
func (s *Server) putSecret(w http.ResponseWriter, r *http.Request) {
var body struct {
Type string `json:"type"`
Value string `json:"value"`
}
if err := decode(r, &body); err != nil {
writeError(w, err)
return
}
if err := s.service.PutSecret(r.PathValue("id"), body.Type, body.Value); err != nil {
writeError(w, err)
return
}
writeJSON(w, 204, nil)
}
func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
if err := s.service.DeleteSecret(r.PathValue("id")); err != nil {
writeError(w, err)
return
}
w.WriteHeader(204)
}
func (s *Server) runs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Runs()) }
func (s *Server) runJob(w http.ResponseWriter, r *http.Request) {
run, err := s.service.EnqueueJob(r.PathValue("id"), 10)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, 202, run)
}
func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) {
if !s.service.Cancel(r.PathValue("id")) {
writeJSON(w, 404, map[string]string{"error": "run not found"})
return
}
w.WriteHeader(204)
}
func (s *Server) testRepository(w http.ResponseWriter, r *http.Request) {
s.repositoryAction(w, r, false)
}
func (s *Server) initRepository(w http.ResponseWriter, r *http.Request) {
s.repositoryAction(w, r, true)
}
func (s *Server) repositoryAction(w http.ResponseWriter, r *http.Request, initialize bool) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
if err := s.service.TestRepository(ctx, r.PathValue("id"), initialize); err != nil {
writeError(w, err)
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
func (s *Server) maintenance(w http.ResponseWriter, r *http.Request) {
run, err := s.service.EnqueueMaintenance(r.PathValue("id"), r.PathValue("action"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, 202, run)
}
func (s *Server) snapshots(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
items, err := s.service.Snapshots(ctx, r.PathValue("id"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, 200, items)
}
func (s *Server) snapshotFiles(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
items, err := s.service.SnapshotFiles(ctx, r.PathValue("id"), r.PathValue("snapshot"), r.URL.Query().Get("path"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, 200, items)
}
func (s *Server) restore(w http.ResponseWriter, r *http.Request) {
var task model.RestoreTask
if err := decode(r, &task); err != nil {
writeError(w, err)
return
}
run, err := s.service.EnqueueRestore(task)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, 202, run)
}
func decode(r *http.Request, target any) error {
decoder := json.NewDecoder(io.LimitReader(r.Body, 2*1024*1024))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("validation: invalid JSON: %w", err)
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if status != 204 && value != nil {
_ = json.NewEncoder(w).Encode(value)
}
}
func writeError(w http.ResponseWriter, err error) {
status := 500
message := err.Error()
if strings.HasPrefix(message, "validation:") {
status = 400
}
if strings.Contains(message, "unknown") {
status = 404
}
if strings.Contains(message, "already queued") {
status = 409
}
writeJSON(w, status, map[string]string{"error": message})
}
func requestLog(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
next.ServeHTTP(w, r)
log.Info("api request", "method", r.Method, "path", r.URL.Path, "durationMs", time.Since(started).Milliseconds())
})
}
+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")
}
}
+65
View File
@@ -0,0 +1,65 @@
package notify
import (
"bytes"
"context"
"fmt"
"net/http"
"net/url"
"os/exec"
"strings"
"time"
"github.com/backupper-unraid/backupper/internal/model"
)
type SecretGetter interface{ Get(string) (string, error) }
type Sender struct {
Secrets SecretGetter
Client *http.Client
}
func (s *Sender) Send(ctx context.Context, target model.NotificationTarget, title, message, severity string) error {
if !target.Enabled {
return nil
}
switch target.Type {
case "unraid":
importance := "normal"
if severity == "error" {
importance = "alert"
}
return exec.CommandContext(ctx, "/usr/local/emhttp/webGui/scripts/notify", "-e", "Backupper", "-s", title, "-d", message, "-i", importance).Run()
case "ntfy":
endpoint := strings.TrimRight(target.URL, "/") + "/" + url.PathEscape(target.Topic)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(message))
if err != nil {
return err
}
req.Header.Set("Title", title)
req.Header.Set("Tags", "floppy_disk")
if target.TokenRef != "" {
token, err := s.Secrets.Get(target.TokenRef)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
}
client := s.Client
if client == nil {
client = &http.Client{Timeout: 15 * time.Second}
}
response, err := client.Do(req)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("ntfy returned %s", response.Status)
}
return nil
default:
return fmt.Errorf("unsupported notification type %q", target.Type)
}
}
+101
View File
@@ -0,0 +1,101 @@
package platform
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/backupper-unraid/backupper/internal/model"
)
type CredentialGetter interface{ Get(string) (string, error) }
type MountManager struct {
RuntimeDir string
Secrets CredentialGetter
}
type MountedRepository struct {
Repository model.Repository
Mounted bool
CredentialFile string
}
func (m *MountManager) Prepare(ctx context.Context, repo model.Repository) (MountedRepository, error) {
result := MountedRepository{Repository: repo}
if repo.Type != model.RepositorySMB && repo.Type != model.RepositoryNFS {
return result, nil
}
if repo.Mount == nil || !repo.Mount.Managed {
if _, err := os.Stat(repo.Location); err != nil {
return result, fmt.Errorf("connectivity: external mount unavailable: %w", err)
}
return result, nil
}
point := filepath.Clean(repo.Mount.MountPoint)
if !strings.HasPrefix(point, "/mnt/remotes/") && !strings.HasPrefix(point, "/mnt/disks/") {
return result, errors.New("validation: managed mountPoint must be below /mnt/remotes or /mnt/disks")
}
if err := os.MkdirAll(point, 0700); err != nil {
return result, err
}
mountType := string(repo.Type)
if repo.Type == model.RepositorySMB {
mountType = "cifs"
}
args := []string{"-t", mountType, repo.Mount.Remote, point}
if repo.Type == model.RepositorySMB {
options := []string{"nosuid", "nodev"}
if repo.CredentialRef != "" {
credential, err := m.Secrets.Get(repo.CredentialRef)
if err != nil {
return result, fmt.Errorf("authentication: load mount credential: %w", err)
}
dir := filepath.Join(m.RuntimeDir, "secrets")
if err := os.MkdirAll(dir, 0700); err != nil {
return result, err
}
f, err := os.CreateTemp(dir, "smb-credentials-*")
if err != nil {
return result, err
}
result.CredentialFile = f.Name()
if err := f.Chmod(0600); err != nil {
f.Close()
return result, err
}
if _, err := f.WriteString(credential); err != nil {
f.Close()
return result, err
}
if err := f.Close(); err != nil {
return result, err
}
options = append(options, "credentials="+result.CredentialFile)
}
args = append(args, "-o", strings.Join(options, ","))
} else {
args = append(args, "-o", "nosuid,nodev")
}
if err := exec.CommandContext(ctx, "mount", args...).Run(); err != nil {
m.Cleanup(context.Background(), result)
return result, fmt.Errorf("connectivity: mount repository: %w", err)
}
result.Mounted = true
result.Repository.Location = point
return result, nil
}
func (m *MountManager) Cleanup(ctx context.Context, mounted MountedRepository) error {
if mounted.CredentialFile != "" {
defer os.Remove(mounted.CredentialFile)
}
if !mounted.Mounted {
return nil
}
return exec.CommandContext(ctx, "umount", mounted.Repository.Location).Run()
}
+61
View File
@@ -0,0 +1,61 @@
package platform
import (
"errors"
"os"
"path/filepath"
"strings"
)
func ValidateRestoreTarget(target, restoreRoot string, inPlace, confirmed bool) (string, error) {
if !filepath.IsAbs(target) {
return "", errors.New("validation: restore target must be absolute")
}
clean := filepath.Clean(target)
if clean == "/" || clean == "/boot" || clean == "/etc" || clean == "/usr" || clean == "/var" {
return "", errors.New("validation: protected system restore target")
}
if !inPlace {
root := filepath.Clean(restoreRoot)
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
return "", errors.New("validation: staging restore must remain below restoreRoot")
}
if err := rejectSymlinks(root, clean); err != nil {
return "", err
}
} else if !confirmed {
return "", errors.New("validation: in-place restore requires explicit confirmation")
}
if inPlace {
anchor := string(os.PathSeparator) + strings.Split(strings.TrimPrefix(clean, string(os.PathSeparator)), string(os.PathSeparator))[0]
if err := rejectSymlinks(anchor, clean); err != nil {
return "", err
}
}
return clean, nil
}
func rejectSymlinks(root, target string) error {
relative, err := filepath.Rel(root, target)
if err != nil {
return err
}
current := root
for _, component := range strings.Split(relative, string(os.PathSeparator)) {
if component == "." || component == "" {
continue
}
current = filepath.Join(current, component)
info, err := os.Lstat(current)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return errors.New("validation: restore target traverses a symlink")
}
}
return nil
}
+36
View File
@@ -0,0 +1,36 @@
package platform
import (
"os"
"path/filepath"
"testing"
)
func TestValidateRestoreTarget(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "task")
if got, err := ValidateRestoreTarget(target, root, false, false); err != nil || got != target {
t.Fatalf("staging target rejected: %q %v", got, err)
}
if _, err := ValidateRestoreTarget("/etc", root, true, true); err == nil {
t.Fatal("protected target accepted")
}
if _, err := ValidateRestoreTarget("/mnt/user/data", root, true, false); err == nil {
t.Fatal("unconfirmed in-place restore accepted")
}
}
func TestValidateRestoreTargetRejectsSymlink(t *testing.T) {
root := t.TempDir()
real := filepath.Join(root, "real")
link := filepath.Join(root, "link")
if err := os.Mkdir(real, 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(real, link); err != nil {
t.Fatal(err)
}
if _, err := ValidateRestoreTarget(filepath.Join(link, "task"), root, false, false); err == nil {
t.Fatal("symlink traversal accepted")
}
}
+215
View File
@@ -0,0 +1,215 @@
package platform
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/backupper-unraid/backupper/internal/model"
)
type WorkloadManager struct{ RuntimeDir string }
type Prepared struct {
Sources []string
Stopped []model.Source
MetadataDir string
Kind model.JobType
}
func (w *WorkloadManager) Prepare(ctx context.Context, job model.Job) (Prepared, error) {
prepared := Prepared{Kind: job.Type}
for _, source := range job.Sources {
if source.Path != "" {
prepared.Sources = append(prepared.Sources, source.Path)
}
}
if job.Type != model.JobDocker && job.Type != model.JobVM {
return prepared, nil
}
metadataDir, err := os.MkdirTemp(w.RuntimeDir, "metadata-"+job.ID+"-")
if err != nil {
return prepared, err
}
prepared.MetadataDir = metadataDir
prepared.Sources = append(prepared.Sources, metadataDir)
if job.Type == model.JobDocker {
if _, err := os.Stat("/boot/config/plugins/dockerMan/templates-user"); err == nil {
prepared.Sources = append(prepared.Sources, "/boot/config/plugins/dockerMan/templates-user")
}
}
for _, source := range job.Sources {
if source.WorkloadID == "" {
continue
}
discovered, err := w.captureMetadata(ctx, job.Type, source.WorkloadID, metadataDir)
if err != nil {
w.Cleanup(context.Background(), prepared)
return Prepared{}, err
}
prepared.Sources = append(prepared.Sources, discovered...)
if job.Consistency.Mode == "stop" {
if err := w.stop(ctx, job.Type, source.WorkloadID, time.Duration(job.ShutdownSecs)*time.Second); err != nil {
w.Cleanup(context.Background(), prepared)
return Prepared{}, err
}
prepared.Stopped = append(prepared.Stopped, source)
}
}
prepared.Sources = uniqueStrings(prepared.Sources)
return prepared, nil
}
func (w *WorkloadManager) Cleanup(ctx context.Context, prepared Prepared) error {
var first error
for i := len(prepared.Stopped) - 1; i >= 0; i-- {
source := prepared.Stopped[i]
if err := w.start(ctx, prepared.Kind, source.WorkloadID); err != nil && first == nil {
first = err
}
}
if prepared.MetadataDir != "" {
_ = os.RemoveAll(prepared.MetadataDir)
}
return first
}
func (w *WorkloadManager) captureMetadata(ctx context.Context, kind model.JobType, id, dir string) ([]string, error) {
var cmd *exec.Cmd
var name string
if kind == model.JobDocker {
cmd, name = exec.CommandContext(ctx, "docker", "inspect", id), "docker-"+safeName(id)+".json"
} else {
cmd, name = exec.CommandContext(ctx, "virsh", "dumpxml", id), "vm-"+safeName(id)+".xml"
}
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("source: capture metadata for %s: %w", id, err)
}
if err := os.WriteFile(filepath.Join(dir, name), output, 0600); err != nil {
return nil, err
}
if kind == model.JobDocker {
var inspected []struct {
Mounts []struct {
Type string `json:"Type"`
Source string `json:"Source"`
} `json:"Mounts"`
}
if err := json.Unmarshal(output, &inspected); err != nil {
return nil, fmt.Errorf("source: decode docker metadata: %w", err)
}
var paths []string
if len(inspected) > 0 {
for _, mount := range inspected[0].Mounts {
if mount.Type == "bind" && filepath.IsAbs(mount.Source) {
paths = append(paths, mount.Source)
}
}
}
return paths, nil
}
var domain struct {
OS struct {
NVRAM string `xml:"nvram"`
} `xml:"os"`
Devices struct {
Disks []struct {
Device string `xml:"device,attr"`
Source struct {
File string `xml:"file,attr"`
Dev string `xml:"dev,attr"`
} `xml:"source"`
} `xml:"disk"`
} `xml:"devices"`
}
if err := xml.Unmarshal(output, &domain); err != nil {
return nil, fmt.Errorf("source: decode vm metadata: %w", err)
}
paths := []string{}
if filepath.IsAbs(strings.TrimSpace(domain.OS.NVRAM)) {
paths = append(paths, strings.TrimSpace(domain.OS.NVRAM))
}
for _, disk := range domain.Devices.Disks {
if disk.Device != "disk" && disk.Device != "lun" {
continue
}
path := disk.Source.File
if path == "" {
path = disk.Source.Dev
}
if filepath.IsAbs(path) {
paths = append(paths, path)
}
}
return paths, nil
}
func (w *WorkloadManager) stop(ctx context.Context, kind model.JobType, id string, timeout time.Duration) error {
if timeout <= 0 {
timeout = 120 * time.Second
}
if kind == model.JobDocker {
seconds := fmt.Sprint(int(timeout.Seconds()))
if err := exec.CommandContext(ctx, "docker", "stop", "--time", seconds, id).Run(); err != nil {
return fmt.Errorf("source: stop docker %s: %w", id, err)
}
return nil
}
if err := exec.CommandContext(ctx, "virsh", "shutdown", id).Run(); err != nil {
return fmt.Errorf("source: shutdown vm %s: %w", id, err)
}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
output, _ := exec.CommandContext(ctx, "virsh", "domstate", id).Output()
if strings.Contains(strings.ToLower(string(output)), "shut off") {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
return fmt.Errorf("source: vm %s did not stop before timeout", id)
}
func (w *WorkloadManager) start(ctx context.Context, kind model.JobType, id string) error {
if kind == model.JobDocker {
return exec.CommandContext(ctx, "docker", "start", id).Run()
}
return exec.CommandContext(ctx, "virsh", "start", id).Run()
}
func safeName(value string) string {
value = strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
return r
}
return '_'
}, value)
if value == "" {
return "workload"
}
return value
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = filepath.Clean(value)
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
+157
View File
@@ -0,0 +1,157 @@
package queue
import (
"context"
"errors"
"sort"
"sync"
"time"
"github.com/backupper-unraid/backupper/internal/model"
)
var ErrDuplicate = errors.New("task already queued or running")
type Handler func(context.Context, model.Run) model.Run
type OnChange func([]model.Run)
type Queue struct {
mu sync.Mutex
cond *sync.Cond
pending []model.Run
history []model.Run
active *model.Run
cancel context.CancelFunc
stopping bool
handler Handler
onChange OnChange
}
func New(handler Handler, onChange OnChange) *Queue {
q := &Queue{handler: handler, onChange: onChange}
q.cond = sync.NewCond(&q.mu)
return q
}
func (q *Queue) RestoreHistory(runs []model.Run) {
q.mu.Lock()
defer q.mu.Unlock()
for _, run := range runs {
if run.Status == "queued" || run.Status == "running" {
run.Status = "failed"
run.ErrorCode = "environment"
run.Message = "daemon restarted while task was active"
now := time.Now().UTC()
run.FinishedAt = &now
}
q.history = append(q.history, run)
}
}
func (q *Queue) Enqueue(run model.Run) error {
q.mu.Lock()
defer q.mu.Unlock()
if q.stopping {
return errors.New("queue is stopping")
}
if q.active != nil && run.JobID != "" && q.active.JobID == run.JobID {
return ErrDuplicate
}
for _, item := range q.pending {
if run.JobID != "" && item.JobID == run.JobID {
return ErrDuplicate
}
}
run.Status = "queued"
q.pending = append(q.pending, run)
sort.SliceStable(q.pending, func(i, j int) bool { return q.pending[i].Priority > q.pending[j].Priority })
q.emitLocked()
q.cond.Signal()
return nil
}
func (q *Queue) Run(ctx context.Context) {
for {
q.mu.Lock()
for len(q.pending) == 0 && !q.stopping {
q.cond.Wait()
}
if q.stopping {
q.mu.Unlock()
return
}
run := q.pending[0]
q.pending = q.pending[1:]
now := time.Now().UTC()
run.Status, run.StartedAt = "running", &now
workCtx, cancel := context.WithCancel(ctx)
q.cancel, q.active = cancel, &run
q.emitLocked()
q.mu.Unlock()
result := q.handler(workCtx, run)
finished := time.Now().UTC()
result.FinishedAt = &finished
q.mu.Lock()
q.active, q.cancel = nil, nil
q.history = append(q.history, result)
if len(q.history) > 500 {
q.history = q.history[len(q.history)-500:]
}
q.emitLocked()
q.mu.Unlock()
}
}
func (q *Queue) Cancel(id string) bool {
q.mu.Lock()
defer q.mu.Unlock()
if q.active != nil && q.active.ID == id && q.cancel != nil {
q.cancel()
return true
}
for i := range q.pending {
if q.pending[i].ID == id {
run := q.pending[i]
q.pending = append(q.pending[:i], q.pending[i+1:]...)
now := time.Now().UTC()
run.Status, run.ErrorCode, run.FinishedAt = "cancelled", "cancelled", &now
q.history = append(q.history, run)
q.emitLocked()
return true
}
}
return false
}
func (q *Queue) Stop() {
q.mu.Lock()
q.stopping = true
if q.cancel != nil {
q.cancel()
}
q.cond.Broadcast()
q.mu.Unlock()
}
func (q *Queue) Snapshot() []model.Run {
q.mu.Lock()
defer q.mu.Unlock()
return q.snapshotLocked()
}
func (q *Queue) snapshotLocked() []model.Run {
result := append([]model.Run{}, q.history...)
if q.active != nil {
result = append(result, *q.active)
}
result = append(result, q.pending...)
return result
}
func (q *Queue) emitLocked() {
if q.onChange != nil {
q.onChange(q.snapshotLocked())
}
}
+30
View File
@@ -0,0 +1,30 @@
package queue
import (
"context"
"testing"
"time"
"github.com/backupper-unraid/backupper/internal/model"
)
func TestQueueDeduplicatesJobAndRunsTask(t *testing.T) {
done := make(chan struct{})
q := New(func(_ context.Context, run model.Run) model.Run { run.Status = "success"; close(done); return run }, nil)
run := model.Run{ID: "one", JobID: "job", TaskType: "backup", CreatedAt: time.Now()}
if err := q.Enqueue(run); err != nil {
t.Fatal(err)
}
if err := q.Enqueue(model.Run{ID: "two", JobID: "job"}); err != ErrDuplicate {
t.Fatalf("duplicate error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go q.Run(ctx)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("queue did not execute task")
}
q.Stop()
}
+233
View File
@@ -0,0 +1,233 @@
package restic
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/backupper-unraid/backupper/internal/model"
)
type SecretGetter interface{ Get(string) (string, error) }
type Runner struct {
Binary string
RuntimeDir string
Secrets SecretGetter
Log *slog.Logger
}
type Summary struct {
MessageType string `json:"message_type"`
SnapshotID string `json:"snapshot_id"`
DataAdded int64 `json:"data_added"`
FilesNew int64 `json:"files_new"`
}
func (r *Runner) Init(ctx context.Context, repo model.Repository) error {
return r.run(ctx, repo, []string{"init"}, nil, nil)
}
func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Job, sources []string) (Summary, error) {
args := []string{"backup", "--json", "--compression", job.Compression}
for _, tag := range append([]string{"backupper", "job:" + job.ID}, job.Tags...) {
args = append(args, "--tag", tag)
}
for _, exclude := range job.Excludes {
args = append(args, "--exclude", exclude)
}
args = append(args, sources...)
var summary Summary
err := r.run(ctx, repo, args, func(line []byte) {
var candidate Summary
if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" {
summary = candidate
}
}, nil)
return summary, err
}
func (r *Runner) Forget(ctx context.Context, repo model.Repository, job model.Job) error {
args := []string{"forget", "--tag", "job:" + job.ID, "--keep-daily", fmt.Sprint(job.Retention.Daily), "--keep-weekly", fmt.Sprint(job.Retention.Weekly), "--keep-monthly", fmt.Sprint(job.Retention.Monthly)}
return r.run(ctx, repo, args, nil, nil)
}
func (r *Runner) Check(ctx context.Context, repo model.Repository) error {
return r.run(ctx, repo, []string{"check"}, nil, nil)
}
func (r *Runner) Prune(ctx context.Context, repo model.Repository) error {
return r.run(ctx, repo, []string{"prune"}, nil, nil)
}
func (r *Runner) Snapshots(ctx context.Context, repo model.Repository) ([]model.Snapshot, error) {
var output []byte
err := r.run(ctx, repo, []string{"snapshots", "--json"}, nil, &output)
if err != nil {
return nil, err
}
var snapshots []model.Snapshot
if err := json.Unmarshal(output, &snapshots); err != nil {
return nil, fmt.Errorf("decode snapshots: %w", err)
}
return snapshots, nil
}
func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path string) ([]map[string]any, error) {
args := []string{"ls", snapshot, "--json"}
if path != "" {
args = append(args, path)
}
var output []byte
if err := r.run(ctx, repo, args, nil, &output); err != nil {
return nil, err
}
items := []map[string]any{}
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
var item map[string]any
if json.Unmarshal(scanner.Bytes(), &item) == nil && item["struct_type"] == "node" {
items = append(items, item)
}
if len(items) >= 5000 {
break
}
}
return items, scanner.Err()
}
func (r *Runner) Restore(ctx context.Context, repo model.Repository, task model.RestoreTask) error {
args := []string{"restore", task.SnapshotID, "--target", task.Target}
for _, include := range task.Includes {
args = append(args, "--include", include)
}
return r.run(ctx, repo, args, nil, nil)
}
func (r *Runner) run(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte) error {
password, err := r.Secrets.Get(repo.PasswordRef)
if err != nil {
return fmt.Errorf("authentication: load repository password: %w", err)
}
secretDir := filepath.Join(r.RuntimeDir, "secrets")
if err := os.MkdirAll(secretDir, 0700); err != nil {
return err
}
f, err := os.CreateTemp(secretDir, "restic-password-*")
if err != nil {
return err
}
passwordPath := f.Name()
defer os.Remove(passwordPath)
if err := f.Chmod(0600); err != nil {
f.Close()
return err
}
if _, err := io.WriteString(f, password); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
globalArgs := []string{"-r", repositoryLocation(repo)}
credentialPath := ""
if repo.Type == model.RepositorySFTP && repo.CredentialRef != "" {
privateKey, err := r.Secrets.Get(repo.CredentialRef)
if err != nil {
return fmt.Errorf("authentication: load SFTP private key: %w", err)
}
keyFile, err := os.CreateTemp(secretDir, "sftp-key-*")
if err != nil {
return err
}
credentialPath = keyFile.Name()
defer os.Remove(credentialPath)
if err := keyFile.Chmod(0600); err != nil {
keyFile.Close()
return err
}
if _, err := io.WriteString(keyFile, privateKey); err != nil {
keyFile.Close()
return err
}
if err := keyFile.Close(); err != nil {
return err
}
knownHosts := repo.Options["knownHostsPath"]
command := "ssh -i " + credentialPath + " -o IdentitiesOnly=yes -o UserKnownHostsFile=" + knownHosts + " -o StrictHostKeyChecking=yes"
globalArgs = append(globalArgs, "-o", "sftp.command="+command)
}
fullArgs := append(globalArgs, args...)
cmd := exec.CommandContext(ctx, r.Binary, fullArgs...)
cmd.Env = []string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME=/root", "RESTIC_PASSWORD_FILE=" + passwordPath}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return classify(err)
}
var captured []byte
done := make(chan struct{})
go func() {
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
for scanner.Scan() {
line := append([]byte(nil), scanner.Bytes()...)
if capture != nil {
captured = append(captured, line...)
captured = append(captured, '\n')
}
if onLine != nil {
onLine(line)
}
}
close(done)
}()
errBytes, _ := io.ReadAll(io.LimitReader(stderr, 1024*1024))
err = cmd.Wait()
<-done
if capture != nil {
*capture = captured
}
if err != nil {
message := redact(string(errBytes), repo)
if errors.Is(ctx.Err(), context.Canceled) {
return fmt.Errorf("cancelled: %w", ctx.Err())
}
return fmt.Errorf("restic: %s: %w", strings.TrimSpace(message), err)
}
return nil
}
func repositoryLocation(repo model.Repository) string {
if repo.Type == model.RepositorySFTP && !strings.HasPrefix(repo.Location, "sftp:") {
return "sftp:" + repo.Location
}
return repo.Location
}
func redact(value string, repo model.Repository) string {
value = strings.ReplaceAll(value, repo.Location, "[repository]")
return value
}
func classify(err error) error {
if errors.Is(err, exec.ErrNotFound) {
return fmt.Errorf("environment: restic binary not found: %w", err)
}
return err
}
+47
View File
@@ -0,0 +1,47 @@
package restic
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/backupper-unraid/backupper/internal/model"
)
type fakeSecrets map[string]string
func (s fakeSecrets) Get(id string) (string, error) { return s[id], nil }
func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) {
dir := t.TempDir()
argsPath := filepath.Join(dir, "args")
passwordPath := filepath.Join(dir, "password")
script := filepath.Join(dir, "restic")
body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3}'\n", argsPath, passwordPath)
if err := os.WriteFile(script, []byte(body), 0700); err != nil {
t.Fatal(err)
}
runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "top-secret"}}
repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo path", PasswordRef: "password"}
job := model.Job{ID: "job", Compression: "auto", Tags: []string{"daily"}, Excludes: []string{"*.tmp"}}
summary, err := runner.Backup(context.Background(), repo, job, []string{"/source path"})
if err != nil {
t.Fatal(err)
}
if summary.SnapshotID != "abc123" || summary.DataAdded != 42 {
t.Fatalf("unexpected summary: %+v", summary)
}
args, _ := os.ReadFile(argsPath)
for _, expected := range []string{"/repo path", "backup", "--json", "/source path"} {
if !strings.Contains(string(args), expected) {
t.Fatalf("arguments missing %q: %s", expected, args)
}
}
password, _ := os.ReadFile(passwordPath)
if string(password) != "top-secret" {
t.Fatal("password file was not provided")
}
}
+96
View File
@@ -0,0 +1,96 @@
package scheduler
import (
"fmt"
"strconv"
"strings"
"time"
)
type Expression struct{ fields [5]field }
type field map[int]bool
var bounds = [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 6}}
func Parse(spec string) (Expression, error) {
parts := strings.Fields(spec)
if len(parts) != 5 {
return Expression{}, fmt.Errorf("cron requires five fields")
}
var expression Expression
for i, part := range parts {
parsed, err := parseField(part, bounds[i][0], bounds[i][1])
if err != nil {
return Expression{}, fmt.Errorf("cron field %d: %w", i+1, err)
}
expression.fields[i] = parsed
}
return expression, nil
}
func parseField(value string, min, max int) (field, error) {
result := field{}
for _, segment := range strings.Split(value, ",") {
step := 1
base := segment
if strings.Contains(segment, "/") {
parts := strings.Split(segment, "/")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid step %q", segment)
}
base = parts[0]
var err error
step, err = strconv.Atoi(parts[1])
if err != nil || step < 1 {
return nil, fmt.Errorf("invalid step %q", parts[1])
}
}
start, end := min, max
if base != "*" {
if strings.Contains(base, "-") {
parts := strings.Split(base, "-")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid range %q", base)
}
start, _ = strconv.Atoi(parts[0])
end, _ = strconv.Atoi(parts[1])
} else {
var err error
start, err = strconv.Atoi(base)
end = start
if err != nil {
return nil, fmt.Errorf("invalid value %q", base)
}
}
}
if start < min || end > max || start > end {
return nil, fmt.Errorf("value outside %d-%d", min, max)
}
for n := start; n <= end; n += step {
result[n] = true
}
}
return result, nil
}
func (e Expression) Matches(t time.Time) bool {
values := [5]int{t.Minute(), t.Hour(), t.Day(), int(t.Month()), int(t.Weekday())}
for i, value := range values {
if !e.fields[i][value] {
return false
}
}
return true
}
func (e Expression) Next(after time.Time) time.Time {
t := after.Truncate(time.Minute).Add(time.Minute)
limit := t.AddDate(2, 0, 0)
for t.Before(limit) {
if e.Matches(t) {
return t
}
t = t.Add(time.Minute)
}
return time.Time{}
}
+45
View File
@@ -0,0 +1,45 @@
package scheduler
import (
"testing"
"time"
)
func TestCronMatchesAndNext(t *testing.T) {
expr, err := Parse("*/15 2 * * 1-5")
if err != nil {
t.Fatal(err)
}
monday := time.Date(2026, 6, 15, 2, 30, 0, 0, time.UTC)
if !expr.Matches(monday) {
t.Fatal("expected expression to match")
}
next := expr.Next(time.Date(2026, 6, 15, 2, 31, 0, 0, time.UTC))
want := time.Date(2026, 6, 15, 2, 45, 0, 0, time.UTC)
if !next.Equal(want) {
t.Fatalf("next = %v, want %v", next, want)
}
}
func TestCronRejectsInvalid(t *testing.T) {
for _, value := range []string{"", "* * * *", "61 * * * *", "*/0 * * * *"} {
if _, err := Parse(value); err == nil {
t.Fatalf("accepted invalid cron %q", value)
}
}
}
func TestPreviousWithinCatchUpWindow(t *testing.T) {
expr, err := Parse("0 2 * * *")
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 6, 13, 8, 30, 0, 0, time.UTC)
want := time.Date(2026, 6, 13, 2, 0, 0, 0, time.UTC)
if got := previous(expr, now, 24*time.Hour); !got.Equal(want) {
t.Fatalf("previous = %v, want %v", got, want)
}
if got := previous(expr, now, 2*time.Hour); !got.IsZero() {
t.Fatalf("unexpected catch-up match %v", got)
}
}
+153
View File
@@ -0,0 +1,153 @@
package scheduler
import (
"context"
"log/slog"
"sync"
"time"
"github.com/backupper-unraid/backupper/internal/model"
)
type ConfigProvider func() model.Config
type Enqueue func(model.Job) error
type EnqueueMaintenance func(string, string) (model.Run, error)
type LastRun func(string) time.Time
type Scheduler struct {
config ConfigProvider
enqueue Enqueue
maintenance EnqueueMaintenance
lastRun LastRun
log *slog.Logger
mu sync.Mutex
last map[string]time.Time
}
func New(config ConfigProvider, enqueue Enqueue, maintenance EnqueueMaintenance, lastRun LastRun, log *slog.Logger) *Scheduler {
return &Scheduler{config: config, enqueue: enqueue, maintenance: maintenance, lastRun: lastRun, log: log, last: map[string]time.Time{}}
}
func (s *Scheduler) Run(ctx context.Context) {
s.catchUp(time.Now())
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
s.tick(time.Now())
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
s.tick(now)
}
}
}
func (s *Scheduler) tick(now time.Time) {
config := s.config()
minute := now.Truncate(time.Minute)
for _, job := range config.Jobs {
if !job.Enabled || job.Schedule.Cron == "" {
continue
}
at, expression, ok := scheduledTime(job, minute, s.log)
if !ok || !expression.Matches(at) {
continue
}
job := job
s.once("job:"+job.ID, minute, func() error { return s.enqueue(job) })
}
s.scheduleMaintenance(config, minute, "check", config.Settings.CheckCron)
s.scheduleMaintenance(config, minute, "prune", config.Settings.PruneCron)
}
func (s *Scheduler) catchUp(now time.Time) {
config := s.config()
window := time.Duration(config.Settings.CatchUpWindowHrs) * time.Hour
if window <= 0 {
return
}
for _, job := range config.Jobs {
if !job.Enabled || job.Schedule.Cron == "" {
continue
}
loc := time.Local
if job.Schedule.Timezone != "" {
if loaded, err := time.LoadLocation(job.Schedule.Timezone); err == nil {
loc = loaded
}
}
expression, err := Parse(job.Schedule.Cron)
if err != nil {
continue
}
lastScheduled := previous(expression, now.In(loc), window)
if !lastScheduled.IsZero() && s.lastRun(job.ID).Before(lastScheduled.UTC()) {
if err := s.enqueue(job); err != nil {
s.log.Warn("catch-up enqueue failed", "jobId", job.ID, "error", err)
}
}
}
}
func (s *Scheduler) scheduleMaintenance(config model.Config, minute time.Time, action, spec string) {
if spec == "" {
return
}
expression, err := Parse(spec)
if err != nil {
s.log.Warn("invalid maintenance schedule", "action", action, "error", err)
return
}
if !expression.Matches(minute) {
return
}
for _, repo := range config.Repositories {
repoID := repo.ID
s.once(action+":"+repoID, minute, func() error {
_, err := s.maintenance(repoID, action)
return err
})
}
}
func (s *Scheduler) once(key string, minute time.Time, action func() error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.last[key].Equal(minute) {
return
}
s.last[key] = minute
if err := action(); err != nil {
s.log.Warn("schedule enqueue failed", "key", key, "error", err)
}
}
func scheduledTime(job model.Job, minute time.Time, log *slog.Logger) (time.Time, Expression, bool) {
expression, err := Parse(job.Schedule.Cron)
if err != nil {
log.Warn("invalid job schedule", "jobId", job.ID, "error", err)
return time.Time{}, Expression{}, false
}
loc := time.Local
if job.Schedule.Timezone != "" {
if loaded, err := time.LoadLocation(job.Schedule.Timezone); err == nil {
loc = loaded
} else {
log.Warn("invalid job timezone", "jobId", job.ID, "error", err)
}
}
return minute.In(loc), expression, true
}
func previous(expression Expression, now time.Time, window time.Duration) time.Time {
current := now.Truncate(time.Minute)
limit := current.Add(-window)
for !current.Before(limit) {
if expression.Matches(current) {
return current
}
current = current.Add(-time.Minute)
}
return time.Time{}
}
+144
View File
@@ -0,0 +1,144 @@
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"regexp"
"sync"
"time"
)
type Record struct {
ID string `json:"id"`
Type string `json:"type"`
Nonce string `json:"nonce"`
Ciphertext string `json:"ciphertext"`
UpdatedAt time.Time `json:"updatedAt"`
}
type Store struct {
dir string
mu sync.Mutex
}
var validID = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
func New(dir string) *Store { return &Store{dir: dir} }
func (s *Store) Init() error {
if err := os.MkdirAll(s.dir, 0700); err != nil {
return err
}
keyPath := filepath.Join(s.dir, "secret.key")
if _, err := os.Stat(keyPath); errors.Is(err, os.ErrNotExist) {
key := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return err
}
return os.WriteFile(keyPath, key, 0600)
}
return nil
}
func (s *Store) Put(id, kind, plaintext string) error {
if !validID.MatchString(id) || kind == "" {
return errors.New("invalid secret id or type")
}
s.mu.Lock()
defer s.mu.Unlock()
key, err := s.key()
if err != nil {
return err
}
block, err := aes.NewCipher(key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return err
}
sealed := gcm.Seal(nil, nonce, []byte(plaintext), []byte(id+":"+kind))
record := Record{ID: id, Type: kind, Nonce: base64.StdEncoding.EncodeToString(nonce), Ciphertext: base64.StdEncoding.EncodeToString(sealed), UpdatedAt: time.Now().UTC()}
b, err := json.Marshal(record)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(s.dir, id+".json"), append(b, '\n'), 0600)
}
func (s *Store) Get(id string) (string, error) {
if !validID.MatchString(id) {
return "", errors.New("invalid secret id")
}
s.mu.Lock()
defer s.mu.Unlock()
b, err := os.ReadFile(filepath.Join(s.dir, id+".json"))
if err != nil {
return "", err
}
var record Record
if err := json.Unmarshal(b, &record); err != nil {
return "", err
}
key, err := s.key()
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce, err := base64.StdEncoding.DecodeString(record.Nonce)
if err != nil {
return "", err
}
sealed, err := base64.StdEncoding.DecodeString(record.Ciphertext)
if err != nil {
return "", err
}
plain, err := gcm.Open(nil, nonce, sealed, []byte(record.ID+":"+record.Type))
if err != nil {
return "", err
}
return string(plain), nil
}
func (s *Store) Delete(id string) error {
if !validID.MatchString(id) {
return errors.New("invalid secret id")
}
s.mu.Lock()
defer s.mu.Unlock()
err := os.Remove(filepath.Join(s.dir, id+".json"))
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
func (s *Store) key() ([]byte, error) {
key, err := os.ReadFile(filepath.Join(s.dir, "secret.key"))
if err != nil {
return nil, err
}
if len(key) != 32 {
return nil, errors.New("invalid secret key length")
}
return key, nil
}
+58
View File
@@ -0,0 +1,58 @@
package secrets
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestEncryptedRoundTrip(t *testing.T) {
dir := t.TempDir()
s := New(dir)
if err := s.Init(); err != nil {
t.Fatal(err)
}
if err := s.Put("repo", "restic-password", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
plain, err := s.Get("repo")
if err != nil {
t.Fatal(err)
}
if plain != "correct horse battery staple" {
t.Fatalf("unexpected plaintext %q", plain)
}
raw, err := os.ReadFile(filepath.Join(dir, "repo.json"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), plain) {
t.Fatal("plaintext stored on disk")
}
}
func TestCiphertextBoundToIdentity(t *testing.T) {
dir := t.TempDir()
s := New(dir)
if err := s.Init(); err != nil {
t.Fatal(err)
}
if err := s.Put("one", "password", "secret"); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(filepath.Join(dir, "one.json"))
var record Record
if err := json.Unmarshal(b, &record); err != nil {
t.Fatal(err)
}
record.ID = "two"
b, _ = json.Marshal(record)
if err := os.WriteFile(filepath.Join(dir, "two.json"), b, 0600); err != nil {
t.Fatal(err)
}
if _, err := s.Get("two"); err == nil {
t.Fatal("renamed secret record decrypted")
}
}
+375
View File
@@ -0,0 +1,375 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/backupper-unraid/backupper/internal/model"
"github.com/backupper-unraid/backupper/internal/notify"
"github.com/backupper-unraid/backupper/internal/platform"
"github.com/backupper-unraid/backupper/internal/queue"
"github.com/backupper-unraid/backupper/internal/restic"
"github.com/backupper-unraid/backupper/internal/scheduler"
"github.com/backupper-unraid/backupper/internal/store"
)
type SecretStore interface {
Get(string) (string, error)
Put(string, string, string) error
Delete(string) error
}
type Service struct {
store *store.Store
secrets SecretStore
restic *restic.Runner
mounts *platform.MountManager
workloads *platform.WorkloadManager
notifier *notify.Sender
log *slog.Logger
queue *queue.Queue
mu sync.RWMutex
config model.Config
}
func New(st *store.Store, secrets SecretStore, rr *restic.Runner, mounts *platform.MountManager, workloads *platform.WorkloadManager, notifier *notify.Sender, log *slog.Logger) (*Service, error) {
config, err := st.LoadConfig()
if err != nil {
return nil, err
}
s := &Service{store: st, secrets: secrets, restic: rr, mounts: mounts, workloads: workloads, notifier: notifier, log: log, config: config}
s.queue = queue.New(s.execute, func(runs []model.Run) {
if err := st.SaveRuns(runs); err != nil {
log.Error("save run history", "error", err)
}
})
if runs, err := st.LoadRuns(); err == nil {
s.queue.RestoreHistory(runs)
}
return s, nil
}
func (s *Service) RunQueue(ctx context.Context) { s.queue.Run(ctx) }
func (s *Service) Stop() { s.queue.Stop() }
func (s *Service) Config() model.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.config }
func (s *Service) Runs() []model.Run { return s.queue.Snapshot() }
func (s *Service) LastRun(jobID string) time.Time {
var latest time.Time
for _, run := range s.queue.Snapshot() {
if run.JobID == jobID && run.CreatedAt.After(latest) {
latest = run.CreatedAt
}
}
return latest
}
func (s *Service) SaveConfig(config model.Config) error {
for _, job := range config.Jobs {
if job.Schedule.Cron != "" {
if _, err := scheduler.Parse(job.Schedule.Cron); err != nil {
return fmt.Errorf("validation: job %q schedule: %w", job.ID, err)
}
}
if job.Schedule.Timezone != "" {
if _, err := time.LoadLocation(job.Schedule.Timezone); err != nil {
return fmt.Errorf("validation: job %q timezone: %w", job.ID, err)
}
}
}
for name, spec := range map[string]string{"check": config.Settings.CheckCron, "prune": config.Settings.PruneCron} {
if spec != "" {
if _, err := scheduler.Parse(spec); err != nil {
return fmt.Errorf("validation: %s schedule: %w", name, err)
}
}
}
if err := s.store.SaveConfig(config); err != nil {
return err
}
s.mu.Lock()
s.config = config
s.mu.Unlock()
s.restic.Binary = config.Settings.ResticPath
return nil
}
func (s *Service) PutSecret(id, kind, value string) error {
if id == "" || value == "" {
return errors.New("validation: id and value are required")
}
return s.secrets.Put(id, kind, value)
}
func (s *Service) DeleteSecret(id string) error { return s.secrets.Delete(id) }
func (s *Service) EnqueueJob(jobID string, priority int) (model.Run, error) {
job, ok := s.job(jobID)
if !ok {
return model.Run{}, errors.New("validation: unknown job")
}
run := model.Run{SchemaVersion: model.SchemaVersion, ID: newID(), JobID: job.ID, TaskType: "backup", Priority: priority, CreatedAt: time.Now().UTC()}
return run, s.queue.Enqueue(run)
}
func (s *Service) EnqueueScheduled(job model.Job) error {
_, err := s.EnqueueJob(job.ID, 0)
return err
}
func (s *Service) EnqueueMaintenance(repoID, action string) (model.Run, error) {
if action != "check" && action != "prune" {
return model.Run{}, errors.New("validation: invalid maintenance action")
}
if _, ok := s.repository(repoID); !ok {
return model.Run{}, errors.New("validation: unknown repository")
}
run := model.Run{SchemaVersion: model.SchemaVersion, ID: newID(), JobID: repoID, TaskType: action, Priority: 5, CreatedAt: time.Now().UTC()}
return run, s.queue.Enqueue(run)
}
func (s *Service) EnqueueRestore(task model.RestoreTask) (model.Run, error) {
config := s.Config()
target, err := platform.ValidateRestoreTarget(task.Target, config.Settings.RestoreRoot, task.InPlace, task.Confirmed)
if err != nil {
return model.Run{}, err
}
task.Target = target
if task.ID == "" {
task.ID = newID()
}
task.SchemaVersion = model.SchemaVersion
encoded := strings.Join(append([]string{task.RepositoryID, task.SnapshotID, task.Target, fmt.Sprint(task.InPlace)}, task.Includes...), "\x00")
run := model.Run{SchemaVersion: model.SchemaVersion, ID: task.ID, JobID: encoded, TaskType: "restore", Priority: 10, CreatedAt: time.Now().UTC()}
return run, s.queue.Enqueue(run)
}
func (s *Service) Cancel(runID string) bool { return s.queue.Cancel(runID) }
func (s *Service) Snapshots(ctx context.Context, repoID string) ([]model.Snapshot, error) {
repo, ok := s.repository(repoID)
if !ok {
return nil, errors.New("validation: unknown repository")
}
mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil {
return nil, err
}
defer s.mounts.Cleanup(context.Background(), mounted)
return s.restic.Snapshots(ctx, mounted.Repository)
}
func (s *Service) SnapshotFiles(ctx context.Context, repoID, snapshot, path string) ([]map[string]any, error) {
repo, ok := s.repository(repoID)
if !ok {
return nil, errors.New("validation: unknown repository")
}
mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil {
return nil, err
}
defer s.mounts.Cleanup(context.Background(), mounted)
return s.restic.List(ctx, mounted.Repository, snapshot, path)
}
func (s *Service) TestRepository(ctx context.Context, repoID string, initialize bool) error {
repo, ok := s.repository(repoID)
if !ok {
return errors.New("validation: unknown repository")
}
mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil {
return err
}
defer s.mounts.Cleanup(context.Background(), mounted)
if initialize {
return s.restic.Init(ctx, mounted.Repository)
}
_, err = s.restic.Snapshots(ctx, mounted.Repository)
return err
}
func (s *Service) execute(ctx context.Context, run model.Run) model.Run {
if run.TaskType == "backup" {
return s.executeBackup(ctx, run)
}
if run.TaskType == "restore" {
return s.executeRestore(ctx, run)
}
repo, ok := s.repository(run.JobID)
if !ok {
return failed(run, "validation", "repository no longer exists")
}
mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil {
return failedError(run, err)
}
defer s.mounts.Cleanup(context.Background(), mounted)
if run.TaskType == "check" {
err = s.restic.Check(ctx, mounted.Repository)
} else {
err = s.restic.Prune(ctx, mounted.Repository)
}
if err != nil {
return failedError(run, err)
}
run.Status, run.Message = "success", run.TaskType+" completed"
return run
}
func (s *Service) executeBackup(ctx context.Context, run model.Run) (result model.Run) {
job, ok := s.job(run.JobID)
if !ok {
return failed(run, "validation", "job no longer exists")
}
defer func() { result = s.finishJob(run, job, result) }()
repo, ok := s.repository(job.RepositoryID)
if !ok {
return failed(run, "validation", "repository no longer exists")
}
mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil {
return failedError(run, err)
}
defer func() {
if err := s.mounts.Cleanup(context.Background(), mounted); err != nil && result.Status == "success" {
result.Status, result.ErrorCode, result.Message = "warning", "connectivity", result.Message+"; repository unmount failed: "+err.Error()
}
}()
prepared, err := s.workloads.Prepare(ctx, job)
if err != nil {
return failedError(run, err)
}
defer func() {
if err := s.workloads.Cleanup(context.Background(), prepared); err != nil {
if result.Status == "success" {
result.Status, result.ErrorCode = "warning", "source"
}
result.Message += "; workload restart failed: " + err.Error()
}
}()
if job.Type == model.JobFlash && len(prepared.Sources) == 0 {
prepared.Sources = []string{"/boot"}
}
for _, path := range prepared.Sources {
if _, err := os.Stat(path); err != nil {
return failed(run, "source", "source unavailable: "+path)
}
}
summary, err := s.restic.Backup(ctx, mounted.Repository, job, prepared.Sources)
if err != nil {
return failedError(run, err)
}
if err := s.restic.Forget(ctx, mounted.Repository, job); err != nil {
run.Status, run.Message, run.ErrorCode = "warning", "backup succeeded but retention failed: "+err.Error(), "repository"
} else {
run.Status, run.Message = "success", "backup completed"
}
run.SnapshotID, run.BytesAdded, run.FilesNew = summary.SnapshotID, summary.DataAdded, summary.FilesNew
return run
}
func (s *Service) executeRestore(ctx context.Context, run model.Run) model.Run {
parts := strings.Split(run.JobID, "\x00")
if len(parts) < 4 {
return failed(run, "internal", "invalid restore payload")
}
repo, ok := s.repository(parts[0])
if !ok {
return failed(run, "validation", "repository no longer exists")
}
task := model.RestoreTask{SchemaVersion: model.SchemaVersion, ID: run.ID, RepositoryID: parts[0], SnapshotID: parts[1], Target: parts[2], InPlace: parts[3] == "true", Confirmed: true, Includes: parts[4:]}
if err := os.MkdirAll(task.Target, 0750); err != nil {
return failedError(run, err)
}
mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil {
return failedError(run, err)
}
defer s.mounts.Cleanup(context.Background(), mounted)
if err := s.restic.Restore(ctx, mounted.Repository, task); err != nil {
return failedError(run, err)
}
run.Status, run.Message, run.JobID = "success", "restore completed", ""
return run
}
func (s *Service) finishJob(run model.Run, job model.Job, result model.Run) model.Run {
event := result.Status
for _, wanted := range job.NotifyOn {
if wanted == event {
go s.notify(job.Name, result)
break
}
}
return result
}
func (s *Service) notify(name string, run model.Run) {
for _, target := range s.Config().Notifications {
for _, event := range target.Events {
if event == run.Status {
_ = s.notifier.Send(context.Background(), target, "Backupper: "+name, run.Message, run.Status)
break
}
}
}
}
func (s *Service) job(id string) (model.Job, bool) {
for _, item := range s.Config().Jobs {
if item.ID == id {
return item, true
}
}
return model.Job{}, false
}
func (s *Service) repository(id string) (model.Repository, bool) {
for _, item := range s.Config().Repositories {
if item.ID == id {
return item, true
}
}
return model.Repository{}, false
}
func failed(run model.Run, code, message string) model.Run {
run.Status, run.ErrorCode, run.Message = "failed", code, message
return run
}
func failedError(run model.Run, err error) model.Run {
code := "internal"
message := err.Error()
for _, candidate := range []string{"validation", "environment", "connectivity", "authentication", "repository", "source", "restic", "cancelled"} {
if strings.HasPrefix(message, candidate+":") {
code = candidate
break
}
}
if errors.Is(err, context.Canceled) {
code = "cancelled"
run.Status = "cancelled"
} else {
run.Status = "failed"
}
run.ErrorCode, run.Message = code, message
return run
}
func newID() string {
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func DefaultRestoreTarget(root string) string { return filepath.Join(root, newID()) }
+113
View File
@@ -0,0 +1,113 @@
package store
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/backupper-unraid/backupper/internal/model"
)
type Store struct {
dir string
mu sync.RWMutex
}
func New(dir string) *Store { return &Store{dir: dir} }
func (s *Store) Init() error {
if err := os.MkdirAll(s.dir, 0700); err != nil {
return err
}
if _, err := os.Stat(s.configPath()); errors.Is(err, os.ErrNotExist) {
return s.SaveConfig(model.DefaultConfig())
}
return nil
}
func (s *Store) LoadConfig() (model.Config, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var c model.Config
if err := readJSON(s.configPath(), &c); err != nil {
return c, err
}
return c, model.ValidateConfig(c)
}
func (s *Store) SaveConfig(c model.Config) error {
if err := model.ValidateConfig(c); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
return writeJSONAtomic(s.configPath(), c, 0600)
}
func (s *Store) LoadRuns() ([]model.Run, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var runs []model.Run
if err := readJSON(s.runsPath(), &runs); errors.Is(err, os.ErrNotExist) {
return []model.Run{}, nil
} else if err != nil {
return nil, err
}
return runs, nil
}
func (s *Store) SaveRuns(runs []model.Run) error {
s.mu.Lock()
defer s.mu.Unlock()
if len(runs) > 500 {
runs = runs[len(runs)-500:]
}
return writeJSONAtomic(s.runsPath(), runs, 0600)
}
func (s *Store) configPath() string { return filepath.Join(s.dir, "config.json") }
func (s *Store) runsPath() string { return filepath.Join(s.dir, "runs.json") }
func readJSON(path string, target any) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
if err := json.Unmarshal(b, target); err != nil {
return fmt.Errorf("decode %s: %w", path, err)
}
return nil
}
func writeJSONAtomic(path string, value any, mode os.FileMode) error {
b, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
tmp, err := os.CreateTemp(filepath.Dir(path), ".backupper-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
+30
View File
@@ -0,0 +1,30 @@
package store
import (
"os"
"testing"
"github.com/backupper-unraid/backupper/internal/model"
)
func TestStoreInitializesAndPersists(t *testing.T) {
dir := t.TempDir()
s := New(dir)
if err := s.Init(); err != nil {
t.Fatal(err)
}
c, err := s.LoadConfig()
if err != nil {
t.Fatal(err)
}
if c.SchemaVersion != model.SchemaVersion {
t.Fatalf("schema version = %d", c.SchemaVersion)
}
info, err := os.Stat(s.configPath())
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("config mode = %o", info.Mode().Perm())
}
}