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
+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{}
}