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