Files
cdnutterandClaude Opus 5.5 f3185ba7bd 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>
2026-09-30 14:30:40 -07:00

338 lines
7.7 KiB
Go

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