Initial Backupper Unraid plugin
This commit is contained in:
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user