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

90 lines
1.8 KiB
Go

package backup
import (
"archive/tar"
"compress/gzip"
"context"
"io"
"log/slog"
"os"
"path/filepath"
"slices"
"testing"
"github.com/cdnutter/canon/internal/store"
)
func TestCreateListPrune(t *testing.T) {
data := t.TempDir()
st, err := store.Open(data)
if err != nil {
t.Fatal(err)
}
defer st.Close()
images := filepath.Join(data, "images")
os.MkdirAll(images, 0o755)
os.WriteFile(filepath.Join(images, "abc.jpg"), []byte("jpeg"), 0o644)
os.WriteFile(filepath.Join(images, ".upload-123"), []byte("partial"), 0o644)
m := New(filepath.Join(data, "backups"), images, st, slog.New(slog.DiscardHandler))
ctx := context.Background()
manual, err := m.Create(ctx, true)
if err != nil {
t.Fatal(err)
}
if got := archiveNames(t, filepath.Join(m.Dir(), manual.Name)); !slices.Equal(got, []string{"canon.db", "images/abc.jpg"}) {
t.Fatalf("archive holds %v", got)
}
for range 3 {
if _, err := m.Create(ctx, false); err != nil {
t.Fatal(err)
}
}
if err := m.prune(2); err != nil {
t.Fatal(err)
}
list, _ := m.List()
var scheduled, manuals int
for _, b := range list {
if b.Manual {
manuals++
} else {
scheduled++
}
}
if scheduled != 2 || manuals != 1 {
t.Fatalf("after prune: %d scheduled, %d manual", scheduled, manuals)
}
if _, err := m.Path("../canon.db"); err == nil {
t.Fatal("path traversal accepted")
}
if err := m.Delete(manual.Name); err != nil {
t.Fatal(err)
}
}
func archiveNames(t *testing.T, path string) []string {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
t.Fatal(err)
}
tr := tar.NewReader(gz)
var names []string
for {
h, err := tr.Next()
if err == io.EOF {
return names
}
if err != nil {
t.Fatal(err)
}
names = append(names, h.Name)
}
}