Add scheduled and manual backups
Backups are .tar.gz archives of a consistent database snapshot (VACUUM INTO) plus the images folder. They run on a cron schedule, daily at 3:00 by default, and the newest N scheduled ones are kept (7 by default). Manual backups are never pruned. Settings → Backups offers schedule presets or a custom cron expression, retention, "Back up now", and download/delete for each backup. A small built-in parser handles standard 5-field cron syntax with names, ranges, steps and @daily-style shorthands. Backups go to CANON_BACKUP_DIR (default data/backups). Compose mounts a separate canon-backups volume at /backups, overridable with CANON_BACKUP_PATH. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
72b49d709a
commit
f3185ba7bd
18 files changed
+940
-10
No files matched your search
@@ -0,0 +1,337 @@
|
||||
// Package backup writes the database and cached images to .tar.gz archives,
|
||||
// on demand and on a cron schedule.
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/canon/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultCron = "0 3 * * *" // every day at 3:00
|
||||
DefaultKeep = 7
|
||||
MaxKeep = 365
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("backup not found")
|
||||
|
||||
// Scheduled backups are named canon-YYYYMMDD-HHMMSS.tar.gz; ones made with
|
||||
// "Back up now" end in -manual and are never pruned automatically.
|
||||
var nameRe = regexp.MustCompile(`^canon-\d{8}-\d{6}(-\d+)?(-manual)?\.tar\.gz$`)
|
||||
|
||||
type Info struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Created time.Time `json:"created"`
|
||||
Manual bool `json:"manual"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
LastRun time.Time `json:"lastRun,omitzero"`
|
||||
LastErr string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
dir string
|
||||
imagesDir string
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
|
||||
run sync.Mutex // one backup at a time
|
||||
reload chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
status Status
|
||||
}
|
||||
|
||||
func New(dir, imagesDir string, st *store.Store, log *slog.Logger) *Manager {
|
||||
return &Manager{dir: dir, imagesDir: imagesDir, store: st, log: log, reload: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (m *Manager) Dir() string { return m.dir }
|
||||
|
||||
func (m *Manager) Status() Status {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.status
|
||||
}
|
||||
|
||||
// Reschedule makes the scheduler re-read its settings.
|
||||
func (m *Manager) Reschedule() {
|
||||
select {
|
||||
case m.reload <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Settings returns the configured schedule and retention, with defaults.
|
||||
func Settings(set map[string]string) (cron string, keep int) {
|
||||
cron = strings.TrimSpace(set[store.SettingBackupCron])
|
||||
if cron == "" {
|
||||
cron = DefaultCron
|
||||
}
|
||||
keep = DefaultKeep
|
||||
if n, err := strconv.Atoi(set[store.SettingBackupKeep]); err == nil && n > 0 {
|
||||
keep = n
|
||||
}
|
||||
return cron, keep
|
||||
}
|
||||
|
||||
// NextRun is when the schedule next fires after t, or nil when backups are
|
||||
// off or the schedule is invalid.
|
||||
func NextRun(cron string, t time.Time) *time.Time {
|
||||
if cron == "off" {
|
||||
return nil
|
||||
}
|
||||
sched, err := ParseCron(cron)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
next := sched.Next(t)
|
||||
if next.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &next
|
||||
}
|
||||
|
||||
// Run backs up on schedule until ctx is canceled.
|
||||
func (m *Manager) Run(ctx context.Context) {
|
||||
for {
|
||||
set, err := m.store.Settings(ctx)
|
||||
if err != nil {
|
||||
m.log.Warn("backup: loading settings", "err", err)
|
||||
}
|
||||
cron, keep := Settings(set)
|
||||
var next time.Time
|
||||
if n := NextRun(cron, time.Now()); n != nil {
|
||||
next = *n
|
||||
} else if cron != "off" {
|
||||
m.log.Warn("backup: invalid schedule, not backing up", "cron", cron)
|
||||
}
|
||||
|
||||
// Wake at least hourly so a changed clock or a suspended host
|
||||
// doesn't delay a backup by long.
|
||||
wait := time.Hour
|
||||
if !next.IsZero() {
|
||||
wait = min(wait, time.Until(next))
|
||||
}
|
||||
timer := time.NewTimer(max(wait, 0))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-m.reload:
|
||||
timer.Stop()
|
||||
continue
|
||||
case <-timer.C:
|
||||
}
|
||||
if next.IsZero() || time.Now().Before(next) {
|
||||
continue
|
||||
}
|
||||
if _, err := m.Create(ctx, false); err != nil {
|
||||
m.log.Error("scheduled backup failed", "err", err)
|
||||
continue
|
||||
}
|
||||
if err := m.prune(keep); err != nil {
|
||||
m.log.Warn("backup: removing old backups", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create writes a new backup and returns it.
|
||||
func (m *Manager) Create(ctx context.Context, manual bool) (info Info, err error) {
|
||||
m.run.Lock()
|
||||
defer m.run.Unlock()
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
m.status.LastRun = time.Now()
|
||||
m.status.LastErr = ""
|
||||
if err != nil {
|
||||
m.status.LastErr = err.Error()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
|
||||
if err := os.MkdirAll(m.dir, 0o755); err != nil {
|
||||
return info, err
|
||||
}
|
||||
name := m.freeName(time.Now(), manual)
|
||||
tmpDir, err := os.MkdirTemp(m.dir, ".tmp-")
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbCopy := filepath.Join(tmpDir, "canon.db")
|
||||
if err := m.store.SnapshotTo(ctx, dbCopy); err != nil {
|
||||
return info, fmt.Errorf("copying database: %w", err)
|
||||
}
|
||||
partial := filepath.Join(tmpDir, name)
|
||||
if err := m.writeArchive(partial, dbCopy); err != nil {
|
||||
return info, err
|
||||
}
|
||||
final := filepath.Join(m.dir, name)
|
||||
if err := os.Rename(partial, final); err != nil {
|
||||
return info, err
|
||||
}
|
||||
fi, err := os.Stat(final)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
info = Info{Name: name, Size: fi.Size(), Created: fi.ModTime(), Manual: manual}
|
||||
m.log.Info("backup written", "file", final, "size", fi.Size(), "manual", manual)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (m *Manager) freeName(t time.Time, manual bool) string {
|
||||
base := "canon-" + t.Format("20060102-150405")
|
||||
suffix := ".tar.gz"
|
||||
if manual {
|
||||
suffix = "-manual.tar.gz"
|
||||
}
|
||||
name := base + suffix
|
||||
for i := 2; ; i++ {
|
||||
if _, err := os.Stat(filepath.Join(m.dir, name)); errors.Is(err, os.ErrNotExist) {
|
||||
return name
|
||||
}
|
||||
name = fmt.Sprintf("%s-%d%s", base, i, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) writeArchive(path, dbCopy string) (err error) {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}()
|
||||
gz := gzip.NewWriter(f)
|
||||
tw := tar.NewWriter(gz)
|
||||
if err := addFile(tw, dbCopy, "canon.db"); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(m.imagesDir)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.Type().IsRegular() || strings.HasPrefix(e.Name(), ".") {
|
||||
continue // skip in-progress uploads
|
||||
}
|
||||
if err := addFile(tw, filepath.Join(m.imagesDir, e.Name()), "images/"+e.Name()); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue // removed while we were working
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Sync()
|
||||
}
|
||||
|
||||
func addFile(tw *tar.Writer, src, name string) error {
|
||||
f, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr := &tar.Header{Name: name, Mode: 0o644, Size: fi.Size(), ModTime: fi.ModTime(), Typeflag: tar.TypeReg}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.CopyN(tw, f, fi.Size())
|
||||
return err
|
||||
}
|
||||
|
||||
// List returns backups, newest first.
|
||||
func (m *Manager) List() ([]Info, error) {
|
||||
entries, err := os.ReadDir(m.dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []Info{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Info{}
|
||||
for _, e := range entries {
|
||||
if !e.Type().IsRegular() || !nameRe.MatchString(e.Name()) {
|
||||
continue
|
||||
}
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Info{Name: e.Name(), Size: fi.Size(), Created: fi.ModTime(), Manual: strings.HasSuffix(e.Name(), "-manual.tar.gz")})
|
||||
}
|
||||
slices.SortFunc(out, func(a, b Info) int { return b.Created.Compare(a.Created) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Path returns the file for a backup name, refusing anything that isn't one.
|
||||
func (m *Manager) Path(name string) (string, error) {
|
||||
if !nameRe.MatchString(name) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
p := filepath.Join(m.dir, name)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Delete(name string) error {
|
||||
p, err := m.Path(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(p)
|
||||
}
|
||||
|
||||
// prune keeps the newest keep scheduled backups. Manual ones stay until
|
||||
// deleted by hand.
|
||||
func (m *Manager) prune(keep int) error {
|
||||
all, err := m.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n := 0
|
||||
for _, b := range all {
|
||||
if b.Manual {
|
||||
continue
|
||||
}
|
||||
if n++; n > keep {
|
||||
if err := os.Remove(filepath.Join(m.dir, b.Name)); err != nil {
|
||||
return err
|
||||
}
|
||||
m.log.Info("removed old backup", "file", b.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/cdnutter/canon/internal/store"
|
||||
)
|
||||
|
||||
func TestCreateListPrune(t *testing.T) {
|
||||
data := t.TempDir()
|
||||
st, err := store.Open(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
images := filepath.Join(data, "images")
|
||||
os.MkdirAll(images, 0o755)
|
||||
os.WriteFile(filepath.Join(images, "abc.jpg"), []byte("jpeg"), 0o644)
|
||||
os.WriteFile(filepath.Join(images, ".upload-123"), []byte("partial"), 0o644)
|
||||
|
||||
m := New(filepath.Join(data, "backups"), images, st, slog.New(slog.DiscardHandler))
|
||||
ctx := context.Background()
|
||||
manual, err := m.Create(ctx, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := archiveNames(t, filepath.Join(m.Dir(), manual.Name)); !slices.Equal(got, []string{"canon.db", "images/abc.jpg"}) {
|
||||
t.Fatalf("archive holds %v", got)
|
||||
}
|
||||
for range 3 {
|
||||
if _, err := m.Create(ctx, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := m.prune(2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ := m.List()
|
||||
var scheduled, manuals int
|
||||
for _, b := range list {
|
||||
if b.Manual {
|
||||
manuals++
|
||||
} else {
|
||||
scheduled++
|
||||
}
|
||||
}
|
||||
if scheduled != 2 || manuals != 1 {
|
||||
t.Fatalf("after prune: %d scheduled, %d manual", scheduled, manuals)
|
||||
}
|
||||
if _, err := m.Path("../canon.db"); err == nil {
|
||||
t.Fatal("path traversal accepted")
|
||||
}
|
||||
if err := m.Delete(manual.Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func archiveNames(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
var names []string
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return names
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names = append(names, h.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Schedule is a parsed five-field cron expression:
|
||||
// minute hour day-of-month month day-of-week.
|
||||
type Schedule struct {
|
||||
minute, hour, dom, month, dow uint64 // bit n set = value n allowed
|
||||
domAny, dowAny bool
|
||||
}
|
||||
|
||||
var shorthands = map[string]string{
|
||||
"@hourly": "0 * * * *",
|
||||
"@daily": "0 0 * * *",
|
||||
"@midnight": "0 0 * * *",
|
||||
"@weekly": "0 0 * * 0",
|
||||
"@monthly": "0 0 1 * *",
|
||||
"@yearly": "0 0 1 1 *",
|
||||
"@annually": "0 0 1 1 *",
|
||||
}
|
||||
|
||||
var monthNames = map[string]int{"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12}
|
||||
var dayNames = map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}
|
||||
|
||||
// ParseCron parses standard cron syntax: *, lists (1,15), ranges (1-5),
|
||||
// steps (*/15, 0-30/10), month and weekday names, and the @daily-style
|
||||
// shorthands. Sunday is 0 or 7.
|
||||
func ParseCron(expr string) (Schedule, error) {
|
||||
expr = strings.TrimSpace(strings.ToLower(expr))
|
||||
if s, ok := shorthands[expr]; ok {
|
||||
expr = s
|
||||
}
|
||||
f := strings.Fields(expr)
|
||||
if len(f) != 5 {
|
||||
return Schedule{}, fmt.Errorf("a cron schedule has 5 fields (minute hour day month weekday), got %d", len(f))
|
||||
}
|
||||
var s Schedule
|
||||
var err error
|
||||
if s.minute, err = parseField(f[0], 0, 59, nil); err != nil {
|
||||
return s, fmt.Errorf("minute: %w", err)
|
||||
}
|
||||
if s.hour, err = parseField(f[1], 0, 23, nil); err != nil {
|
||||
return s, fmt.Errorf("hour: %w", err)
|
||||
}
|
||||
if s.dom, err = parseField(f[2], 1, 31, nil); err != nil {
|
||||
return s, fmt.Errorf("day of month: %w", err)
|
||||
}
|
||||
if s.month, err = parseField(f[3], 1, 12, monthNames); err != nil {
|
||||
return s, fmt.Errorf("month: %w", err)
|
||||
}
|
||||
if s.dow, err = parseField(f[4], 0, 7, dayNames); err != nil {
|
||||
return s, fmt.Errorf("weekday: %w", err)
|
||||
}
|
||||
if s.dow&(1<<7) != 0 {
|
||||
s.dow |= 1 // 7 is Sunday too
|
||||
}
|
||||
s.domAny = f[2] == "*" || strings.HasPrefix(f[2], "*/")
|
||||
s.dowAny = f[4] == "*" || strings.HasPrefix(f[4], "*/")
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func parseField(field string, min, max int, names map[string]int) (uint64, error) {
|
||||
var bits uint64
|
||||
for _, part := range strings.Split(field, ",") {
|
||||
rng, stepStr, hasStep := strings.Cut(part, "/")
|
||||
step := 1
|
||||
if hasStep {
|
||||
n, err := strconv.Atoi(stepStr)
|
||||
if err != nil || n < 1 {
|
||||
return 0, fmt.Errorf("bad step %q", stepStr)
|
||||
}
|
||||
step = n
|
||||
}
|
||||
lo, hi := min, max
|
||||
if rng != "*" {
|
||||
a, b, isRange := strings.Cut(rng, "-")
|
||||
var err error
|
||||
if lo, err = parseValue(a, min, max, names); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
hi = lo
|
||||
if isRange {
|
||||
if hi, err = parseValue(b, min, max, names); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if hi < lo {
|
||||
return 0, fmt.Errorf("range %q runs backwards", rng)
|
||||
}
|
||||
} else if hasStep {
|
||||
hi = max // "5/10" means from 5 to the end, every 10
|
||||
}
|
||||
}
|
||||
for v := lo; v <= hi; v += step {
|
||||
bits |= 1 << uint(v)
|
||||
}
|
||||
}
|
||||
return bits, nil
|
||||
}
|
||||
|
||||
func parseValue(s string, min, max int, names map[string]int) (int, error) {
|
||||
if n, ok := names[s]; ok {
|
||||
return n, nil
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%q isn't a number", s)
|
||||
}
|
||||
if n < min || n > max {
|
||||
return 0, fmt.Errorf("%d is outside %d-%d", n, min, max)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Next returns the first matching minute strictly after t, in t's
|
||||
// location. It returns the zero time if nothing matches within 5 years
|
||||
// (e.g. "0 0 30 2 *").
|
||||
func (s Schedule) Next(t time.Time) time.Time {
|
||||
t = t.Truncate(time.Minute).Add(time.Minute)
|
||||
limit := t.AddDate(5, 0, 0)
|
||||
for t.Before(limit) {
|
||||
if s.month&(1<<uint(t.Month())) == 0 {
|
||||
t = time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, t.Location())
|
||||
continue
|
||||
}
|
||||
if !s.dayMatches(t) {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
|
||||
continue
|
||||
}
|
||||
if s.hour&(1<<uint(t.Hour())) == 0 {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, t.Location())
|
||||
continue
|
||||
}
|
||||
if s.minute&(1<<uint(t.Minute())) == 0 {
|
||||
t = t.Add(time.Minute)
|
||||
continue
|
||||
}
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// dayMatches follows cron's rule: when both day fields are restricted, a
|
||||
// day matching either one counts.
|
||||
func (s Schedule) dayMatches(t time.Time) bool {
|
||||
dom := s.dom&(1<<uint(t.Day())) != 0
|
||||
dow := s.dow&(1<<uint(t.Weekday())) != 0
|
||||
switch {
|
||||
case s.domAny && s.dowAny:
|
||||
return true
|
||||
case s.domAny:
|
||||
return dow
|
||||
case s.dowAny:
|
||||
return dom
|
||||
default:
|
||||
return dom || dow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNext(t *testing.T) {
|
||||
utc := func(s string) time.Time {
|
||||
v, err := time.Parse("2006-01-02 15:04", s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
cases := []struct{ expr, from, want string }{
|
||||
{"0 3 * * *", "2026-09-30 02:59", "2026-09-30 03:00"},
|
||||
{"0 3 * * *", "2026-09-30 03:00", "2026-10-01 03:00"},
|
||||
{"@daily", "2026-12-31 23:30", "2027-01-01 00:00"},
|
||||
{"*/15 * * * *", "2026-09-30 10:07", "2026-09-30 10:15"},
|
||||
{"30 4 * * sun", "2026-09-30 00:00", "2026-10-04 04:30"}, // Wed → Sun
|
||||
{"30 4 * * 7", "2026-09-30 00:00", "2026-10-04 04:30"},
|
||||
{"0 0 1,15 * *", "2026-09-02 00:00", "2026-09-15 00:00"},
|
||||
{"0 9 * * mon-fri", "2026-10-02 10:00", "2026-10-05 09:00"}, // Fri after 9 → Mon
|
||||
{"0 0 29 2 *", "2026-03-01 00:00", "2028-02-29 00:00"},
|
||||
{"0 12 13 * 5", "2026-09-30 00:00", "2026-10-02 12:00"}, // Friday OR the 13th
|
||||
{"5/20 * * * *", "2026-09-30 10:26", "2026-09-30 10:45"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s, err := ParseCron(c.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.expr, err)
|
||||
}
|
||||
if got := s.Next(utc(c.from)); !got.Equal(utc(c.want)) {
|
||||
t.Errorf("%s from %s = %s, want %s", c.expr, c.from, got.Format("2006-01-02 15:04 Mon"), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCronErrors(t *testing.T) {
|
||||
for _, expr := range []string{"", "* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "5-1 * * * *", "*/0 * * * *", "a * * * *"} {
|
||||
if _, err := ParseCron(expr); err == nil {
|
||||
t.Errorf("%q: expected an error", expr)
|
||||
}
|
||||
}
|
||||
s, _ := ParseCron("0 0 30 2 *")
|
||||
if !s.Next(time.Now()).IsZero() {
|
||||
t.Error("Feb 30 should never run")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user