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:
cdnutterandClaude Opus 5.5 committed 2026-09-30 14:30:40 -07:00
1 parent 72b49d709a
commit f3185ba7bd
18 files changed
+940 -10

No files matched your search

+337
View File
@@ -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
}
+89
View File
@@ -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)
}
}
+162
View File
@@ -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
}
}
+50
View File
@@ -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")
}
}