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>
338 lines
7.7 KiB
Go
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
|
|
}
|