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