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