release / web (push) Failing after 15s
release / govulncheck (push) Successful in 16s
release / go (push) Failing after 41s
release / integration (push) Failing after 3m29s
release / android (push) Successful in 4m7s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (push) Canceled after 5m34s
Admin › Backups shows a verdict first (off, not set up, waiting, backed up, overdue, failing since when), Back up now, Test destination and the latest runs, with the settings beside them. - backup.Service: one run at a time, bounded at 30 minutes; the schedule ticks every 5 minutes, catches up a slot missed while the server was down, and retries a failure after an hour and then after as long as it has been failing. Runs are recorded in backup_runs, pruned after 90 days; a run cut off by a shutdown is marked failed at the next start. - A folder destination writes database/minstrel-<UTC stamp>.tar and refuses a folder that isn't there. An S3 destination stages the file, hashes it, and uploads it under database/ through a throttled reader (4 MiB/s by default, 0 for no limit). - Test destination checks the form as typed and only lists. - The bucket secret is sealed like every stored credential; backup_settings and backup_runs are left out of the backup itself. - The panel polls only while a run is going, capped at the run limit plus five minutes. The music switch named in the task is left to #6774, so no control ships that does nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
8.7 KiB
Go
282 lines
8.7 KiB
Go
package backup_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
|
)
|
|
|
|
func newService(t *testing.T) (*backup.Service, *pgxpool.Pool) {
|
|
t.Helper()
|
|
pool := newPool(t)
|
|
svc := backup.NewService(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), "test-build")
|
|
t.Cleanup(svc.Close)
|
|
return svc, pool
|
|
}
|
|
|
|
// waitIdle waits for the running backup to finish, for at most 30 seconds.
|
|
func waitIdle(t *testing.T, svc *backup.Service) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for !svc.Running().IsZero() {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the backup is still running after 30s")
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func status(t *testing.T, svc *backup.Service) backup.Status {
|
|
t.Helper()
|
|
st, err := svc.Status(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return st
|
|
}
|
|
|
|
func TestABackupIsOffAndRefusesToStartUntilSetUp_Integration(t *testing.T) {
|
|
svc, _ := newService(t)
|
|
st := status(t, svc)
|
|
if st.State != backup.StateOff || st.Config.Enabled || st.NextRun != nil || len(st.Runs) != 0 {
|
|
t.Errorf("a fresh server's backup = %+v, want off with no runs", st)
|
|
}
|
|
if err := svc.Start(context.Background(), backup.TriggerManual); !errors.Is(err, backup.ErrNotReady) {
|
|
t.Errorf("Start = %v, want ErrNotReady", err)
|
|
}
|
|
if err := svc.Save(context.Background(), backup.Update{Enabled: true, Destination: backup.DestinationS3, RunHour: 3}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st := status(t, svc); st.State != backup.StateIncomplete || len(st.Problems) != 4 {
|
|
t.Errorf("switched on with no bucket: %s %v", st.State, st.Problems)
|
|
}
|
|
}
|
|
|
|
func TestABackupRunsIntoAFolder_Integration(t *testing.T) {
|
|
svc, _ := newService(t)
|
|
dir := t.TempDir()
|
|
ctx := context.Background()
|
|
if err := svc.Save(ctx, backup.Update{Enabled: true, Destination: backup.DestinationFolder, Folder: dir, RunHour: 3}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st := status(t, svc); st.State != backup.StateWaiting || st.NextRun == nil {
|
|
t.Errorf("set up, never run: %s next %v", st.State, st.NextRun)
|
|
}
|
|
if err := svc.Start(ctx, backup.TriggerManual); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitIdle(t, svc)
|
|
|
|
st := status(t, svc)
|
|
if st.State != backup.StateOK || len(st.Runs) != 1 || st.LastSuccess == nil {
|
|
t.Fatalf("after a run: %+v", st)
|
|
}
|
|
run := st.Runs[0]
|
|
if run.Status != "succeeded" || run.Trigger != backup.TriggerManual || run.FinishedAt == nil ||
|
|
filepath.Dir(run.Location) != filepath.Join(dir, "database") || run.Error != "" {
|
|
t.Fatalf("run = %+v", run)
|
|
}
|
|
f, err := os.Open(run.Location)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
info, _ := f.Stat()
|
|
if info.Size() != run.Bytes {
|
|
t.Errorf("recorded %d bytes, the file holds %d", run.Bytes, info.Size())
|
|
}
|
|
m, _, err := backup.Read(f)
|
|
if err != nil || m.Build != "test-build" {
|
|
t.Errorf("the backup reads back as %+v, %v", m, err)
|
|
}
|
|
|
|
res, err := svc.Test(ctx, backup.Update{Destination: backup.DestinationFolder, Folder: dir, RunHour: 3})
|
|
if err != nil || res.Backups != 1 || res.Latest == nil {
|
|
t.Errorf("Test = %+v, %v; want the one backup", res, err)
|
|
}
|
|
}
|
|
|
|
func TestAMissingFolderFailsTheRunAndSaysSo_Integration(t *testing.T) {
|
|
svc, _ := newService(t)
|
|
ctx := context.Background()
|
|
missing := filepath.Join(t.TempDir(), "not-mounted")
|
|
if err := svc.Save(ctx, backup.Update{Enabled: true, Destination: backup.DestinationFolder, Folder: missing, RunHour: 3}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := svc.Test(ctx, backup.Update{Destination: backup.DestinationFolder, Folder: missing}); err == nil {
|
|
t.Error("Test passed on a missing folder")
|
|
}
|
|
if err := svc.Start(ctx, backup.TriggerManual); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitIdle(t, svc)
|
|
st := status(t, svc)
|
|
if st.State != backup.StateFailing || st.FailingSince == nil || len(st.Runs) != 1 ||
|
|
!strings.Contains(st.Runs[0].Error, "isn't there") {
|
|
t.Errorf("after a failed run: %s %+v", st.State, st.Runs)
|
|
}
|
|
if _, err := os.Stat(missing); !os.IsNotExist(err) {
|
|
t.Error("the run created the missing folder")
|
|
}
|
|
}
|
|
|
|
// fakeBucket answers PUTs and lists like an S3 bucket. While hold is open a
|
|
// PUT waits on it.
|
|
type fakeBucket struct {
|
|
mu sync.Mutex
|
|
puts map[string][]byte
|
|
hold chan struct{}
|
|
}
|
|
|
|
func (b *fakeBucket) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.HasPrefix(r.Header.Get("Authorization"), "AWS4-HMAC-SHA256 Credential=ak/") {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodPut:
|
|
if b.hold != nil {
|
|
<-b.hold
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
b.mu.Lock()
|
|
b.puts[r.URL.Path] = body
|
|
b.mu.Unlock()
|
|
case http.MethodGet:
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
_, _ = fmt.Fprint(w, `<ListBucketResult><IsTruncated>false</IsTruncated>`)
|
|
for p, body := range b.puts {
|
|
_, _ = fmt.Fprintf(w, `<Contents><Key>%s</Key><Size>%d</Size><LastModified>2026-11-02T03:00:05.000Z</LastModified></Contents>`,
|
|
strings.TrimPrefix(p, "/minstrel/"), len(body))
|
|
}
|
|
_, _ = fmt.Fprint(w, `</ListBucketResult>`)
|
|
}
|
|
}
|
|
|
|
func bucketUpdate(endpoint string) backup.Update {
|
|
return backup.Update{Enabled: true, Destination: backup.DestinationS3, Endpoint: endpoint,
|
|
Bucket: "minstrel", AccessKey: "ak", SecretKey: "sk", RunHour: 3}
|
|
}
|
|
|
|
func TestABackupRunsIntoABucket_Integration(t *testing.T) {
|
|
svc, pool := newService(t)
|
|
fake := &fakeBucket{puts: map[string][]byte{}}
|
|
srv := httptest.NewServer(fake)
|
|
defer srv.Close()
|
|
ctx := context.Background()
|
|
|
|
if err := svc.Save(ctx, bucketUpdate(srv.URL)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stored string
|
|
if err := pool.QueryRow(ctx, "SELECT secret_key FROM backup_settings").Scan(&stored); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(stored, "v1.") {
|
|
t.Fatalf("the secret key is stored as %q, want it sealed", stored)
|
|
}
|
|
if cfg, _ := svc.Config(ctx); !cfg.HasSecretKey {
|
|
t.Error("the config doesn't say a secret key is stored")
|
|
}
|
|
if err := svc.Start(ctx, backup.TriggerManual); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitIdle(t, svc)
|
|
|
|
st := status(t, svc)
|
|
if st.State != backup.StateOK || len(st.Runs) != 1 || st.Runs[0].Status != "succeeded" {
|
|
t.Fatalf("after a run: %s %+v", st.State, st.Runs)
|
|
}
|
|
key := st.Runs[0].Location
|
|
if !strings.HasPrefix(key, "database/minstrel-") || !backup.IsFileName(filepath.Base(key)) {
|
|
t.Errorf("stored under %q", key)
|
|
}
|
|
fake.mu.Lock()
|
|
body := fake.puts["/minstrel/"+key]
|
|
fake.mu.Unlock()
|
|
if int64(len(body)) != st.Runs[0].Bytes {
|
|
t.Errorf("the bucket got %d bytes, the run recorded %d", len(body), st.Runs[0].Bytes)
|
|
}
|
|
if _, _, err := backup.Read(bytes.NewReader(body)); err != nil {
|
|
t.Errorf("the uploaded backup doesn't read: %v", err)
|
|
}
|
|
|
|
// The test sends no secret key: it uses the stored one, and only lists.
|
|
u := bucketUpdate(srv.URL)
|
|
u.SecretKey = ""
|
|
res, err := svc.Test(ctx, u)
|
|
if err != nil || res.Backups != 1 {
|
|
t.Errorf("Test = %+v, %v; want the one backup", res, err)
|
|
}
|
|
fake.mu.Lock()
|
|
defer fake.mu.Unlock()
|
|
if len(fake.puts) != 1 {
|
|
t.Errorf("the test wrote to the bucket: %d objects", len(fake.puts))
|
|
}
|
|
}
|
|
|
|
func TestOneBackupAtATime_Integration(t *testing.T) {
|
|
svc, _ := newService(t)
|
|
fake := &fakeBucket{puts: map[string][]byte{}, hold: make(chan struct{})}
|
|
srv := httptest.NewServer(fake)
|
|
defer srv.Close()
|
|
ctx := context.Background()
|
|
if err := svc.Save(ctx, bucketUpdate(srv.URL)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := svc.Start(ctx, backup.TriggerManual); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := svc.Start(ctx, backup.TriggerManual); !errors.Is(err, backup.ErrRunning) {
|
|
t.Errorf("a second Start = %v, want ErrRunning", err)
|
|
}
|
|
if st := status(t, svc); st.RunningSince == nil {
|
|
t.Error("the status doesn't say a backup is running")
|
|
}
|
|
close(fake.hold)
|
|
waitIdle(t, svc)
|
|
}
|
|
|
|
func TestRecoverFailsARunTheLastShutdownCutOff_Integration(t *testing.T) {
|
|
svc, pool := newService(t)
|
|
ctx := context.Background()
|
|
if _, err := pool.Exec(ctx, "INSERT INTO backup_runs (trigger) VALUES ('schedule')"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
svc.Recover(ctx)
|
|
st := status(t, svc)
|
|
if len(st.Runs) != 1 || st.Runs[0].Status != "failed" || st.Runs[0].FinishedAt == nil ||
|
|
!strings.Contains(st.Runs[0].Error, "server stopped") {
|
|
t.Errorf("runs = %+v", st.Runs)
|
|
}
|
|
}
|
|
|
|
func TestSaveRefusesABadValueAndKeepsTheOldSettings_Integration(t *testing.T) {
|
|
svc, _ := newService(t)
|
|
ctx := context.Background()
|
|
err := svc.Save(ctx, backup.Update{Enabled: true, Destination: backup.DestinationFolder, Folder: "relative/path"})
|
|
var fe *backup.FieldError
|
|
if !errors.As(err, &fe) || fe.Field != "folder" {
|
|
t.Fatalf("err = %v, want a FieldError on folder", err)
|
|
}
|
|
if cfg, _ := svc.Config(ctx); cfg.Enabled || cfg.Folder != "" {
|
|
t.Errorf("a refused save changed the settings: %+v", cfg)
|
|
}
|
|
}
|