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>
172 lines
6.6 KiB
Go
172 lines
6.6 KiB
Go
package backup
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
func at(s string) time.Time {
|
|
t, err := time.Parse(time.RFC3339, s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return t
|
|
}
|
|
|
|
func TestDueRunsAtTheHourCatchesUpAndBacksOffAfterAFailure(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
now string
|
|
t runTimes
|
|
want string
|
|
}{
|
|
{"never run: the slot that passed is due", "2026-11-02T10:00:00Z",
|
|
runTimes{}, "2026-11-02T03:00:00Z"},
|
|
{"ran at the hour: tomorrow's slot", "2026-11-02T10:00:00Z",
|
|
runTimes{lastSuccess: at("2026-11-02T03:00:05Z"), lastAttempt: at("2026-11-02T03:00:05Z")}, "2026-11-03T03:00:00Z"},
|
|
{"before today's hour, yesterday's ran: today's slot", "2026-11-02T02:00:00Z",
|
|
runTimes{lastSuccess: at("2026-11-01T03:00:05Z"), lastAttempt: at("2026-11-01T03:00:05Z")}, "2026-11-02T03:00:00Z"},
|
|
{"server was down at the hour: catches up", "2026-11-02T10:00:00Z",
|
|
runTimes{lastSuccess: at("2026-11-01T03:00:05Z"), lastAttempt: at("2026-11-01T03:00:05Z")}, "2026-11-02T03:00:00Z"},
|
|
{"first failure: an hour later", "2026-11-02T03:30:00Z",
|
|
runTimes{lastAttempt: at("2026-11-02T03:00:00Z"), failingSince: at("2026-11-02T03:00:00Z")}, "2026-11-02T04:00:00Z"},
|
|
{"failing for two hours: two hours later", "2026-11-02T05:30:00Z",
|
|
runTimes{lastAttempt: at("2026-11-02T05:00:00Z"), failingSince: at("2026-11-02T03:00:00Z")}, "2026-11-02T07:00:00Z"},
|
|
{"a retry past the next slot waits for the slot", "2026-11-02T19:30:00Z",
|
|
runTimes{lastAttempt: at("2026-11-02T19:00:00Z"), failingSince: at("2026-11-02T03:00:00Z")}, "2026-11-03T03:00:00Z"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := due(at(c.now), 3, c.t); !got.Equal(at(c.want)) {
|
|
t.Errorf("%s: due = %s, want %s", c.name, got.Format(time.RFC3339), c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTheHourIsUTC(t *testing.T) {
|
|
// 22:00 at UTC-5 is 03:00 UTC the next day, which is that day's slot.
|
|
now := time.Date(2026, 11, 1, 22, 0, 0, 0, time.FixedZone("x", -5*3600))
|
|
if got := slotBefore(now, 3); !got.Equal(at("2026-11-02T03:00:00Z")) {
|
|
t.Errorf("slot = %s", got)
|
|
}
|
|
}
|
|
|
|
func TestVerdict(t *testing.T) {
|
|
now := at("2026-11-02T10:00:00Z")
|
|
on := dbq.BackupSetting{Enabled: true}
|
|
good := runTimes{lastSuccess: at("2026-11-02T03:00:00Z"), lastAttempt: at("2026-11-02T03:00:00Z")}
|
|
cases := []struct {
|
|
name string
|
|
st dbq.BackupSetting
|
|
probs []string
|
|
t runTimes
|
|
want string
|
|
}{
|
|
{"switched off, even with problems and failures", dbq.BackupSetting{}, []string{"x"}, runTimes{failingSince: now}, StateOff},
|
|
{"on but not set up", on, []string{"x"}, good, StateIncomplete},
|
|
{"the latest run failed", on, nil, runTimes{lastSuccess: good.lastSuccess, failingSince: now}, StateFailing},
|
|
{"nothing has run yet", on, nil, runTimes{}, StateWaiting},
|
|
{"the latest backup is old", on, nil, runTimes{lastSuccess: now.Add(-27 * time.Hour)}, StateOverdue},
|
|
{"a day and a bit is still fine", on, nil, runTimes{lastSuccess: now.Add(-25 * time.Hour)}, StateOK},
|
|
{"fine", on, nil, good, StateOK},
|
|
}
|
|
for _, c := range cases {
|
|
if got := verdict(c.st, c.probs, c.t, now); got != c.want {
|
|
t.Errorf("%s: %s, want %s", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProblemsNameWhatIsMissing(t *testing.T) {
|
|
if p := problems(dbq.BackupSetting{Destination: DestinationS3}); len(p) != 4 {
|
|
t.Errorf("an empty bucket: %v, want endpoint, name, access key and secret key", p)
|
|
}
|
|
if p := problems(dbq.BackupSetting{Destination: DestinationS3, Endpoint: "https://s3.example.com", Bucket: "b", AccessKey: "a", SecretKey: "v1.x"}); len(p) != 0 {
|
|
t.Errorf("a whole bucket: %v", p)
|
|
}
|
|
if p := problems(dbq.BackupSetting{Destination: DestinationFolder}); len(p) != 1 {
|
|
t.Errorf("an empty folder: %v", p)
|
|
}
|
|
if p := problems(dbq.BackupSetting{Destination: DestinationFolder, Folder: "/backups"}); len(p) != 0 {
|
|
t.Errorf("a folder: %v", p)
|
|
}
|
|
}
|
|
|
|
func TestMergeRefusesWhatCannotBeKept(t *testing.T) {
|
|
ok := Update{Destination: DestinationS3, RunHour: 3}
|
|
for field, u := range map[string]func(Update) Update{
|
|
"destination": func(u Update) Update { u.Destination = "ftp"; return u },
|
|
"run_hour": func(u Update) Update { u.RunHour = 24; return u },
|
|
"upload_limit_kib": func(u Update) Update { u.UploadLimitKiB = -1; return u },
|
|
"folder": func(u Update) Update { u.Folder = "backups"; return u },
|
|
"endpoint": func(u Update) Update { u.Endpoint = "s3.example.com"; return u },
|
|
} {
|
|
_, err := merge(dbq.BackupSetting{}, u(ok))
|
|
var fe *FieldError
|
|
if !errors.As(err, &fe) || fe.Field != field {
|
|
t.Errorf("%s: err = %v, want a FieldError on it", field, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMergeTidiesAndKeepsTheStoredSecret(t *testing.T) {
|
|
st, err := merge(dbq.BackupSetting{SecretKey: "v1.stored"}, Update{
|
|
Enabled: true, Destination: DestinationS3, Endpoint: " https://s3.example.com/ ",
|
|
Bucket: " minstrel ", AccessKey: " ak ", RunHour: 4, UploadLimitKiB: 512, Folder: "/srv/backups/",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.Endpoint != "https://s3.example.com" || st.Bucket != "minstrel" || st.AccessKey != "ak" ||
|
|
st.SecretKey != "v1.stored" || st.RunHour != 4 || st.UploadLimitKib != 512 || st.Folder != "/srv/backups" || !st.Enabled {
|
|
t.Errorf("merged = %+v", st)
|
|
}
|
|
}
|
|
|
|
func TestThrottleKeepsToItsRate(t *testing.T) {
|
|
data := bytes.Repeat([]byte("x"), 20*1024)
|
|
start := time.Now()
|
|
got, err := io.ReadAll(Throttle(context.Background(), bytes.NewReader(data), 100*1024))
|
|
if err != nil || !bytes.Equal(got, data) {
|
|
t.Fatalf("read %d bytes, err %v", len(got), err)
|
|
}
|
|
// 20 KiB at 100 KiB a second takes at least a fifth of a second.
|
|
if took := time.Since(start); took < 180*time.Millisecond {
|
|
t.Errorf("took %s, want at least 200ms", took)
|
|
}
|
|
}
|
|
|
|
func TestThrottleWithNoLimitIsTheReaderItself(t *testing.T) {
|
|
r := strings.NewReader("x")
|
|
if Throttle(context.Background(), r, 0) != io.Reader(r) {
|
|
t.Error("a zero limit wrapped the reader")
|
|
}
|
|
}
|
|
|
|
func TestThrottleStopsWhenItsContextEnds(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
_, err := io.ReadAll(Throttle(ctx, bytes.NewReader(make([]byte, 4096)), 1024))
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("err = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestDescribeSaysWhyARunStopped(t *testing.T) {
|
|
if got := describe(context.DeadlineExceeded); !strings.Contains(got, "longer than 30 minutes") {
|
|
t.Errorf("deadline: %q", got)
|
|
}
|
|
if got := describe(context.Canceled); !strings.Contains(got, "server stopped") {
|
|
t.Errorf("canceled: %q", got)
|
|
}
|
|
if got := describe(errors.New("backup: the folder /x isn't there")); got != "the folder /x isn't there" {
|
|
t.Errorf("other: %q", got)
|
|
}
|
|
}
|