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

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