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,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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user