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