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>
90 lines
1.8 KiB
Go
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)
|
|
}
|
|
}
|