feat: Minstrel backs itself up nightly to a folder or an S3 bucket, off until an admin switches it on (#6773)
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
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>
This commit is contained in:
@@ -16,6 +16,8 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
|
||||
@@ -470,6 +472,11 @@ func run() error {
|
||||
// The sweeper above holds this same instance, so a save from the admin
|
||||
// card changes what it does on its next tick.
|
||||
srv.ReacqSettings = reacqSettings
|
||||
// The server's own backup: the loop runs the nightly schedule, and the
|
||||
// admin page starts, tests and configures the same instance.
|
||||
backups := backup.NewService(pool, logger.With("component", "backup"), buildinfo.Version)
|
||||
go backups.Loop(ctx)
|
||||
srv.Backups = backups
|
||||
srv.StreamSecret = cfg.StreamSecret
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Server.Address,
|
||||
@@ -510,6 +517,8 @@ func run() error {
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("shutdown: %w", err)
|
||||
}
|
||||
// A backup still running is stopped and records that it was cut off.
|
||||
backups.Close()
|
||||
logger.Info("minstrel stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -83,6 +83,25 @@ Creating the first account (which becomes the admin) requires that token, so
|
||||
whoever reaches a newly exposed server first can't claim it. The token
|
||||
changes on every restart until an account exists.
|
||||
|
||||
## Backups
|
||||
|
||||
Minstrel can back itself up once a night: accounts, likes, playlists,
|
||||
listening history, requests and settings, in one file. It is off until an
|
||||
admin switches it on under **Admin › Backups**. Anything Minstrel can fetch
|
||||
or work out again (artwork, fingerprints, MusicBrainz data) is left out, and
|
||||
so are the keys and passwords Minstrel keeps for other services, which are
|
||||
entered again on a restored server. Sign-in passwords are kept.
|
||||
|
||||
A backup goes to one of:
|
||||
- **an S3 bucket**, path-style (MinIO, Garage, or any S3-compatible store).
|
||||
The key only needs to add and list objects. Files land under `database/`.
|
||||
- **a folder** on the server, under `database/` inside it. Mount a volume
|
||||
there; Minstrel refuses to write into a folder that isn't there.
|
||||
|
||||
Minstrel only adds files, named by date, and never deletes one. Keep as many
|
||||
as you like with the destination's own retention. **Test destination** lists
|
||||
what is there without writing anything.
|
||||
|
||||
## Android app
|
||||
|
||||
The app connects over whatever URL you give it, HTTP or HTTPS. Once the
|
||||
|
||||
@@ -56,6 +56,7 @@ value itself:
|
||||
- the API keys and passwords for indexers, Prowlarr and download clients;
|
||||
- the AcoustID key, and the cover-art and tag provider keys;
|
||||
- the SMTP password;
|
||||
- the secret key of the S3 bucket backups go to;
|
||||
- each user's ListenBrainz token and Subsonic password.
|
||||
|
||||
These are encrypted in the database (AES-256-GCM). Each column has its own key,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
||||
)
|
||||
|
||||
// GET /api/admin/backups — the backup's verdict, settings and recent runs.
|
||||
func (h *handlers) handleGetBackups(w http.ResponseWriter, r *http.Request) {
|
||||
st, err := h.backups.Status(r.Context())
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin backups: status", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
func decodeBackupUpdate(w http.ResponseWriter, r *http.Request) (backup.Update, bool) {
|
||||
var u backup.Update
|
||||
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
|
||||
writeErr(w, apierror.BadRequest("invalid_body", ""))
|
||||
return u, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
// fieldErr writes a setting that can't be kept as a 400 whose message says
|
||||
// which setting and why, and reports whether err was one.
|
||||
func fieldErr(w http.ResponseWriter, err error) bool {
|
||||
var fe *backup.FieldError
|
||||
if !errors.As(err, &fe) {
|
||||
return false
|
||||
}
|
||||
writeErr(w, apierror.BadRequest("invalid_backup_setting", fe.Message))
|
||||
return true
|
||||
}
|
||||
|
||||
// PUT /api/admin/backups — save the settings. An empty secret_key keeps the
|
||||
// stored one. Returns the new status.
|
||||
func (h *handlers) handleUpdateBackups(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := decodeBackupUpdate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.backups.Save(r.Context(), u); err != nil {
|
||||
if !fieldErr(w, err) {
|
||||
writeErrWithLog(w, h.logger, "admin backups: save", apierror.Internal(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
h.handleGetBackups(w, r)
|
||||
}
|
||||
|
||||
// POST /api/admin/backups/test — check the destination in the body without
|
||||
// saving it, by listing the backups already there. Nothing is written.
|
||||
func (h *handlers) handleTestBackups(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := decodeBackupUpdate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res, err := h.backups.Test(r.Context(), u)
|
||||
if err != nil {
|
||||
if fieldErr(w, err) {
|
||||
return
|
||||
}
|
||||
msg := strings.TrimPrefix(err.Error(), "backup: ")
|
||||
writeErr(w, &apierror.Error{Status: http.StatusBadGateway, Code: "destination_failed", Message: msg})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// POST /api/admin/backups/run — start a backup now. 202 once it has started;
|
||||
// 409 while one runs or before the destination is set up.
|
||||
func (h *handlers) handleRunBackup(w http.ResponseWriter, r *http.Request) {
|
||||
err := h.backups.Start(r.Context(), backup.TriggerManual)
|
||||
switch {
|
||||
case err == nil:
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case errors.Is(err, backup.ErrRunning):
|
||||
writeErr(w, apierror.Conflict("backup_running", "A backup is already running."))
|
||||
case errors.Is(err, backup.ErrNotReady):
|
||||
writeErr(w, apierror.Conflict("backup_not_ready", "Set up where backups go first."))
|
||||
case errors.Is(err, backup.ErrClosed):
|
||||
writeErr(w, apierror.Conflict("shutting_down", "The server is shutting down."))
|
||||
default:
|
||||
writeErrWithLog(w, h.logger, "admin backups: start", apierror.Internal(err))
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
@@ -40,7 +41,7 @@ import (
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, requestSvc *requests.Service, quarantineSvc *quarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, requestSvc *requests.Service, quarantineSvc *quarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker, backups *backup.Service) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
setupToken, err := auth.NewSetupToken()
|
||||
if err != nil {
|
||||
@@ -74,6 +75,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
fingerprintSettings: fpSettings,
|
||||
loudnessSettings: loudSettings,
|
||||
acoustIDLookup: acoustIDLookup,
|
||||
backups: backups,
|
||||
acquisition: acquire.NewStore(pool),
|
||||
catalogue: cat,
|
||||
wanted: wanted.New(pool, cat, logger),
|
||||
@@ -358,6 +360,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
||||
admin.Post("/tag-sources/research", h.handleResearchTags)
|
||||
|
||||
admin.Get("/backups", h.handleGetBackups)
|
||||
admin.Put("/backups", h.handleUpdateBackups)
|
||||
admin.Post("/backups/test", h.handleTestBackups)
|
||||
admin.Post("/backups/run", h.handleRunBackup)
|
||||
|
||||
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
||||
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
||||
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
||||
@@ -429,6 +436,8 @@ type handlers struct {
|
||||
// acoustIDLookup is the AcoustID lookup worker: its settings, its
|
||||
// status and "look up now". Nil serves the defaults (off).
|
||||
acoustIDLookup *library.AcoustIDLookupWorker
|
||||
// backups is the server's own backup: settings, status, test and run.
|
||||
backups *backup.Service
|
||||
// acquisition is the configured indexers and download clients.
|
||||
acquisition *acquire.Store
|
||||
// catalogue is the release catalogue read from MusicBrainz, and
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
|
||||
)
|
||||
|
||||
// Where a backup can go.
|
||||
const (
|
||||
DestinationFolder = "folder"
|
||||
DestinationS3 = "s3"
|
||||
)
|
||||
|
||||
// What started a run.
|
||||
const (
|
||||
TriggerSchedule = "schedule"
|
||||
TriggerManual = "manual"
|
||||
)
|
||||
|
||||
// The verdict a Status gives, worst first in the order an admin acts on them.
|
||||
const (
|
||||
StateOff = "off" // the nightly backup is switched off
|
||||
StateIncomplete = "incomplete" // switched on, but the destination isn't set up
|
||||
StateFailing = "failing" // the latest run failed
|
||||
StateWaiting = "waiting" // set up, and no backup has finished yet
|
||||
StateOverdue = "overdue" // the latest good backup is more than a day old
|
||||
StateOK = "ok"
|
||||
)
|
||||
|
||||
const (
|
||||
// RunLimit bounds one run, snapshot and upload together. The web panel
|
||||
// stops polling a run a little after this, since the run is over by then.
|
||||
RunLimit = 30 * time.Minute
|
||||
// A nightly backup that hasn't succeeded for this long is overdue; the
|
||||
// slack past a day covers a run that starts late or takes a while.
|
||||
overdueAfter = 26 * time.Hour
|
||||
tickEvery = 5 * time.Minute
|
||||
// A failed run is tried again after at least this long, and after longer
|
||||
// each time it fails again, so a destination that is down isn't hammered.
|
||||
retryAfter = time.Hour
|
||||
recentRuns = 10
|
||||
// Run history older than this is pruned after each run.
|
||||
keepRunsFor = 90 * 24 * time.Hour
|
||||
testTimeout = 30 * time.Second
|
||||
// The database backups' folder inside the destination. The music, when
|
||||
// it is backed up, sits beside it in the same destination.
|
||||
databaseDir = "database"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrRunning is returned by Start while a backup is running.
|
||||
ErrRunning = errors.New("backup: a backup is already running")
|
||||
// ErrNotReady is returned by Start when the destination isn't set up.
|
||||
ErrNotReady = errors.New("backup: the destination isn't set up yet")
|
||||
// ErrClosed is returned by Start once the server is shutting down.
|
||||
ErrClosed = errors.New("backup: the server is shutting down")
|
||||
)
|
||||
|
||||
// FieldError is a setting that can't be saved, with the reason in words.
|
||||
type FieldError struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *FieldError) Error() string { return e.Field + ": " + e.Message }
|
||||
|
||||
// Config is the backup's settings as an admin sees them. The bucket's secret
|
||||
// key is never sent back, only whether one is stored.
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Destination string `json:"destination"`
|
||||
Folder string `json:"folder"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKey string `json:"access_key"`
|
||||
HasSecretKey bool `json:"has_secret_key"`
|
||||
RunHour int `json:"run_hour"`
|
||||
UploadLimitKiB int `json:"upload_limit_kib"`
|
||||
}
|
||||
|
||||
// Update is a save from the admin card. An empty SecretKey keeps the stored
|
||||
// one, so the key doesn't have to be typed again on every save.
|
||||
type Update struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Destination string `json:"destination"`
|
||||
Folder string `json:"folder"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
RunHour int `json:"run_hour"`
|
||||
UploadLimitKiB int `json:"upload_limit_kib"`
|
||||
}
|
||||
|
||||
// Run is one attempt at a backup.
|
||||
type Run struct {
|
||||
ID string `json:"id"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
Location string `json:"location"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// Status is everything the admin panel shows: a verdict, what stands in its
|
||||
// way, and the recent runs.
|
||||
type Status struct {
|
||||
Config Config `json:"config"`
|
||||
State string `json:"state"`
|
||||
Problems []string `json:"problems"`
|
||||
RunningSince *time.Time `json:"running_since"`
|
||||
LastSuccess *time.Time `json:"last_success"`
|
||||
FailingSince *time.Time `json:"failing_since"`
|
||||
// NextRun is when the schedule starts the next backup, or nil when it
|
||||
// won't: switched off, or not set up.
|
||||
NextRun *time.Time `json:"next_run"`
|
||||
RunLimitSeconds int `json:"run_limit_seconds"`
|
||||
Runs []Run `json:"runs"`
|
||||
}
|
||||
|
||||
// TestResult is what a destination test found: how many backups are there
|
||||
// already and when the newest was made.
|
||||
type TestResult struct {
|
||||
Backups int `json:"backups"`
|
||||
Latest *time.Time `json:"latest"`
|
||||
}
|
||||
|
||||
// Service runs Minstrel's own backup: nightly when switched on, or when an
|
||||
// admin asks. One run at a time; each is recorded in backup_runs.
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
build string
|
||||
now func() time.Time
|
||||
|
||||
base context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
|
||||
mu sync.Mutex
|
||||
runningSince time.Time
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewService returns a Service over pool. build is the server's version,
|
||||
// written into each backup's manifest.
|
||||
func NewService(pool *pgxpool.Pool, logger *slog.Logger, build string) *Service {
|
||||
base, cancel := context.WithCancel(context.Background())
|
||||
return &Service{pool: pool, logger: logger, build: build, now: time.Now, base: base, cancel: cancel}
|
||||
}
|
||||
|
||||
func (s *Service) settings(ctx context.Context) (dbq.BackupSetting, error) {
|
||||
st, err := dbq.New(s.pool).GetBackupSettings(ctx)
|
||||
if err != nil {
|
||||
return st, fmt.Errorf("backup: read settings: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func view(st dbq.BackupSetting) Config {
|
||||
return Config{
|
||||
Enabled: st.Enabled,
|
||||
Destination: st.Destination,
|
||||
Folder: st.Folder,
|
||||
Endpoint: st.Endpoint,
|
||||
Bucket: st.Bucket,
|
||||
AccessKey: st.AccessKey,
|
||||
HasSecretKey: st.SecretKey != "",
|
||||
RunHour: int(st.RunHour),
|
||||
UploadLimitKiB: int(st.UploadLimitKib),
|
||||
}
|
||||
}
|
||||
|
||||
// Config returns the settings as an admin sees them.
|
||||
func (s *Service) Config(ctx context.Context) (Config, error) {
|
||||
st, err := s.settings(ctx)
|
||||
return view(st), err
|
||||
}
|
||||
|
||||
// merge applies u over the stored settings: it checks each field, and seals
|
||||
// a new secret key. It returns a *FieldError for a value that can't be kept.
|
||||
func merge(st dbq.BackupSetting, u Update) (dbq.BackupSetting, error) {
|
||||
if u.Destination != DestinationFolder && u.Destination != DestinationS3 {
|
||||
return st, &FieldError{"destination", "Choose a folder or an S3 bucket."}
|
||||
}
|
||||
if u.RunHour < 0 || u.RunHour > 23 {
|
||||
return st, &FieldError{"run_hour", "The hour is from 0 to 23."}
|
||||
}
|
||||
if u.UploadLimitKiB < 0 || u.UploadLimitKiB > 1<<30 {
|
||||
return st, &FieldError{"upload_limit_kib", "The upload limit is 0 (no limit) or more."}
|
||||
}
|
||||
folder := strings.TrimSpace(u.Folder)
|
||||
if folder != "" && !filepath.IsAbs(folder) {
|
||||
return st, &FieldError{"folder", "Give the folder's full path, starting with /."}
|
||||
}
|
||||
endpoint := strings.TrimRight(strings.TrimSpace(u.Endpoint), "/")
|
||||
if endpoint != "" {
|
||||
e, err := url.Parse(endpoint)
|
||||
if err != nil || (e.Scheme != "http" && e.Scheme != "https") || e.Host == "" {
|
||||
return st, &FieldError{"endpoint", "Give the endpoint as a URL, such as https://s3.example.com."}
|
||||
}
|
||||
}
|
||||
st.Enabled = u.Enabled
|
||||
st.Destination = u.Destination
|
||||
st.Folder = filepath.Clean(folder)
|
||||
if folder == "" {
|
||||
st.Folder = ""
|
||||
}
|
||||
st.Endpoint = endpoint
|
||||
st.Bucket = strings.TrimSpace(u.Bucket)
|
||||
st.AccessKey = strings.TrimSpace(u.AccessKey)
|
||||
st.RunHour = int16(u.RunHour)
|
||||
st.UploadLimitKib = int32(u.UploadLimitKiB)
|
||||
if u.SecretKey != "" {
|
||||
sealed, err := secret.Seal(secret.BackupSecretKey.Purpose(), "", u.SecretKey)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.SecretKey = sealed
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Save stores u. A value that can't be kept is a *FieldError and nothing is
|
||||
// stored. Settings that are incomplete still save; Status lists what's missing.
|
||||
func (s *Service) Save(ctx context.Context, u Update) error {
|
||||
st, err := s.settings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err = merge(st, u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return dbq.New(s.pool).SaveBackupSettings(ctx, dbq.SaveBackupSettingsParams{
|
||||
Enabled: st.Enabled,
|
||||
Destination: st.Destination,
|
||||
Folder: st.Folder,
|
||||
Endpoint: st.Endpoint,
|
||||
Bucket: st.Bucket,
|
||||
AccessKey: st.AccessKey,
|
||||
SecretKey: st.SecretKey,
|
||||
RunHour: st.RunHour,
|
||||
UploadLimitKib: st.UploadLimitKib,
|
||||
})
|
||||
}
|
||||
|
||||
// problems is what stops a backup from running with these settings, in words.
|
||||
func problems(st dbq.BackupSetting) []string {
|
||||
var out []string
|
||||
switch st.Destination {
|
||||
case DestinationFolder:
|
||||
if st.Folder == "" {
|
||||
out = append(out, "Choose the folder backups are written to.")
|
||||
}
|
||||
case DestinationS3:
|
||||
if st.Endpoint == "" {
|
||||
out = append(out, "Enter the bucket's endpoint.")
|
||||
}
|
||||
if st.Bucket == "" {
|
||||
out = append(out, "Enter the bucket's name.")
|
||||
}
|
||||
if st.AccessKey == "" {
|
||||
out = append(out, "Enter the access key.")
|
||||
}
|
||||
if st.SecretKey == "" {
|
||||
out = append(out, "Enter the secret key.")
|
||||
}
|
||||
default:
|
||||
out = append(out, "Choose where backups go.")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func timeOf(t pgtype.Timestamptz) time.Time {
|
||||
if !t.Valid || t.Time.Unix() <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return t.Time
|
||||
}
|
||||
|
||||
func ptr(t time.Time) *time.Time {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
// runTimes is what the verdict and the schedule turn on. A zero time is a run
|
||||
// that never happened.
|
||||
type runTimes struct {
|
||||
lastSuccess, lastAttempt, failingSince time.Time
|
||||
}
|
||||
|
||||
func (s *Service) times(ctx context.Context) (runTimes, error) {
|
||||
row, err := dbq.New(s.pool).BackupRunTimes(ctx)
|
||||
if err != nil {
|
||||
return runTimes{}, fmt.Errorf("backup: read run times: %w", err)
|
||||
}
|
||||
return runTimes{timeOf(row.LastSuccess), timeOf(row.LastAttempt), timeOf(row.FailingSince)}, nil
|
||||
}
|
||||
|
||||
// slotBefore is the latest scheduled start at or before now: today at hour
|
||||
// UTC, or yesterday's if that hour hasn't come yet.
|
||||
func slotBefore(now time.Time, hour int) time.Time {
|
||||
now = now.UTC()
|
||||
slot := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC)
|
||||
if slot.After(now) {
|
||||
slot = slot.AddDate(0, 0, -1)
|
||||
}
|
||||
return slot
|
||||
}
|
||||
|
||||
// due reports when the schedule next starts a backup: now or earlier means
|
||||
// the next tick starts one. A slot that passed with no attempt since is due,
|
||||
// so a server that was down at the hour catches up. A run that failed is
|
||||
// tried again, waiting at least retryAfter and then as long again as it has
|
||||
// been failing, until it succeeds or the next slot comes.
|
||||
func due(now time.Time, hour int, t runTimes) time.Time {
|
||||
slot := slotBefore(now, hour)
|
||||
if t.lastAttempt.Before(slot) {
|
||||
return slot
|
||||
}
|
||||
next := slot.AddDate(0, 0, 1)
|
||||
if !t.failingSince.IsZero() {
|
||||
wait := max(retryAfter, t.lastAttempt.Sub(t.failingSince))
|
||||
if retry := t.lastAttempt.Add(wait); retry.Before(next) {
|
||||
return retry
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func verdict(st dbq.BackupSetting, probs []string, t runTimes, now time.Time) string {
|
||||
switch {
|
||||
case !st.Enabled:
|
||||
return StateOff
|
||||
case len(probs) > 0:
|
||||
return StateIncomplete
|
||||
case !t.failingSince.IsZero():
|
||||
return StateFailing
|
||||
case t.lastSuccess.IsZero():
|
||||
return StateWaiting
|
||||
case now.Sub(t.lastSuccess) > overdueAfter:
|
||||
return StateOverdue
|
||||
}
|
||||
return StateOK
|
||||
}
|
||||
|
||||
func uuidText(u pgtype.UUID) string {
|
||||
b := u.Bytes
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
// Status is the verdict, what stands in its way, and the recent runs.
|
||||
func (s *Service) Status(ctx context.Context) (Status, error) {
|
||||
st, err := s.settings(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
t, err := s.times(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
rows, err := dbq.New(s.pool).ListBackupRuns(ctx, recentRuns)
|
||||
if err != nil {
|
||||
return Status{}, fmt.Errorf("backup: list runs: %w", err)
|
||||
}
|
||||
now := s.now()
|
||||
probs := problems(st)
|
||||
out := Status{
|
||||
Config: view(st),
|
||||
State: verdict(st, probs, t, now),
|
||||
Problems: probs,
|
||||
LastSuccess: ptr(t.lastSuccess),
|
||||
FailingSince: ptr(t.failingSince),
|
||||
RunLimitSeconds: int(RunLimit / time.Second),
|
||||
Runs: make([]Run, 0, len(rows)),
|
||||
}
|
||||
if out.Problems == nil {
|
||||
out.Problems = []string{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
out.RunningSince = ptr(s.runningSince)
|
||||
s.mu.Unlock()
|
||||
if st.Enabled && len(probs) == 0 {
|
||||
next := due(now, int(st.RunHour), t)
|
||||
if next.Before(now) {
|
||||
// Due now: the next tick starts it.
|
||||
next = now
|
||||
}
|
||||
out.NextRun = &next
|
||||
}
|
||||
for _, r := range rows {
|
||||
out.Runs = append(out.Runs, Run{
|
||||
ID: uuidText(r.ID),
|
||||
Trigger: r.Trigger,
|
||||
Status: r.Status,
|
||||
StartedAt: r.StartedAt.Time,
|
||||
FinishedAt: ptr(timeOf(r.FinishedAt)),
|
||||
Location: r.Location,
|
||||
Bytes: r.Bytes,
|
||||
Error: r.Error,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func bucketOf(st dbq.BackupSetting) (Bucket, error) {
|
||||
key, err := secret.Open(secret.BackupSecretKey.Purpose(), "", st.SecretKey)
|
||||
if err != nil {
|
||||
return Bucket{}, fmt.Errorf("backup: the stored secret key doesn't open; enter it again: %w", err)
|
||||
}
|
||||
return Bucket{Endpoint: st.Endpoint, Name: st.Bucket, AccessKey: st.AccessKey, SecretKey: key}, nil
|
||||
}
|
||||
|
||||
// Test checks the destination u describes, falling back to the stored
|
||||
// secret key when u leaves it empty. It only reads: it lists the backups
|
||||
// already there and writes nothing.
|
||||
func (s *Service) Test(ctx context.Context, u Update) (TestResult, error) {
|
||||
st, err := s.settings(ctx)
|
||||
if err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
st, err = merge(st, u)
|
||||
if err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
if probs := problems(st); len(probs) > 0 {
|
||||
return TestResult{}, fmt.Errorf("%w: %s", ErrNotReady, strings.Join(probs, " "))
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, testTimeout)
|
||||
defer cancel()
|
||||
|
||||
var res TestResult
|
||||
note := func(name string, at time.Time) {
|
||||
if !IsFileName(name) {
|
||||
return
|
||||
}
|
||||
res.Backups++
|
||||
if res.Latest == nil || at.After(*res.Latest) {
|
||||
res.Latest = &at
|
||||
}
|
||||
}
|
||||
if st.Destination == DestinationFolder {
|
||||
if err := isFolder(st.Folder); err != nil {
|
||||
return res, err
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(st.Folder, databaseDir))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return res, fmt.Errorf("backup: read %s: %w", filepath.Join(st.Folder, databaseDir), err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if info, err := e.Info(); err == nil && info.Mode().IsRegular() {
|
||||
note(e.Name(), info.ModTime())
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
b, err := bucketOf(st)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
objs, err := b.List(ctx, databaseDir+"/", 1000)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
for _, o := range objs {
|
||||
note(path.Base(o.Key), o.LastModified)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// isFolder refuses a folder that isn't there. A backup never creates the
|
||||
// destination folder itself: a missing one usually means a volume that
|
||||
// isn't mounted, and writing beside it would fill the server's own disk.
|
||||
func isFolder(dir string) error {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: the folder %s isn't there; is its volume mounted? %w", dir, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("backup: %s is a file, not a folder", dir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start begins a backup in the background and returns at once. It returns
|
||||
// ErrRunning while one runs, ErrNotReady when the destination isn't set up,
|
||||
// and ErrClosed once the server is shutting down.
|
||||
func (s *Service) Start(ctx context.Context, trigger string) error {
|
||||
st, err := s.settings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(problems(st)) > 0 {
|
||||
return ErrNotReady
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return ErrClosed
|
||||
}
|
||||
if !s.runningSince.IsZero() {
|
||||
return ErrRunning
|
||||
}
|
||||
s.runningSince = s.now()
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.runningSince = time.Time{}
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
s.run(trigger, st)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Running reports when the running backup started, or the zero time.
|
||||
func (s *Service) Running() time.Time {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.runningSince
|
||||
}
|
||||
|
||||
func (s *Service) run(trigger string, st dbq.BackupSetting) {
|
||||
ctx, cancel := context.WithTimeout(s.base, RunLimit)
|
||||
defer cancel()
|
||||
q := dbq.New(s.pool)
|
||||
started, err := q.StartBackupRun(ctx, trigger)
|
||||
if err != nil {
|
||||
s.logger.Error("backup: record the run's start", "err", err)
|
||||
return
|
||||
}
|
||||
location, size, err := s.write(ctx, st, started.StartedAt.Time)
|
||||
|
||||
// The finish is recorded on its own context, so a run stopped by a
|
||||
// shutdown or its time limit still says how it ended.
|
||||
fctx, fcancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer fcancel()
|
||||
fin := dbq.FinishBackupRunParams{ID: started.ID, Status: "succeeded", Location: location, Bytes: size}
|
||||
if err != nil {
|
||||
fin.Status, fin.Error = "failed", describe(err)
|
||||
s.logger.Warn("backup failed", "trigger", trigger, "err", err)
|
||||
} else {
|
||||
s.logger.Info("backup written", "trigger", trigger, "location", location, "bytes", size)
|
||||
}
|
||||
if err := q.FinishBackupRun(fctx, fin); err != nil {
|
||||
s.logger.Error("backup: record the run's end", "err", err)
|
||||
}
|
||||
if _, err := q.PruneBackupRuns(fctx, pgtype.Timestamptz{Time: s.now().Add(-keepRunsFor), Valid: true}); err != nil {
|
||||
s.logger.Warn("backup: prune old runs", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// describe is a run's error as the panel shows it.
|
||||
func describe(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return fmt.Sprintf("The backup took longer than %d minutes and was stopped.", int(RunLimit/time.Minute))
|
||||
case errors.Is(err, context.Canceled):
|
||||
return "The server stopped while the backup was running."
|
||||
}
|
||||
return strings.TrimPrefix(err.Error(), "backup: ")
|
||||
}
|
||||
|
||||
// write snapshots the database into one backup file at the destination and
|
||||
// returns where it went and its size.
|
||||
func (s *Service) write(ctx context.Context, st dbq.BackupSetting, at time.Time) (string, int64, error) {
|
||||
if st.Destination == DestinationFolder {
|
||||
// Checked before the snapshot, so a missing volume costs nothing.
|
||||
if err := isFolder(st.Folder); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
}
|
||||
c, err := Snapshot(ctx, s.pool)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if st.Destination == DestinationFolder {
|
||||
dir := filepath.Join(st.Folder, databaseDir)
|
||||
if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return "", 0, fmt.Errorf("backup: create %s: %w", dir, err)
|
||||
}
|
||||
file, _, err := WriteFile(dir, c, at, s.build)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
info, err := os.Stat(file)
|
||||
if err != nil {
|
||||
return file, 0, err
|
||||
}
|
||||
return file, info.Size(), nil
|
||||
}
|
||||
|
||||
b, err := bucketOf(st)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
// The file is written to a temporary file first: S3 signs over the
|
||||
// body's hash and needs its length before the upload starts.
|
||||
tmp, err := os.CreateTemp("", ".minstrel-backup-*.tar")
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("backup: create a temporary file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmp.Name())
|
||||
}()
|
||||
h := sha256.New()
|
||||
if _, err := Write(io.MultiWriter(tmp, h), c, at, s.build); err != nil {
|
||||
return "", 0, fmt.Errorf("backup: write the temporary file: %w", err)
|
||||
}
|
||||
size, err := tmp.Seek(0, io.SeekCurrent)
|
||||
if err == nil {
|
||||
_, err = tmp.Seek(0, io.SeekStart)
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("backup: rewind the temporary file: %w", err)
|
||||
}
|
||||
key := databaseDir + "/" + FileName(at)
|
||||
body := Throttle(ctx, tmp, int64(st.UploadLimitKib)*1024)
|
||||
if err := b.Put(ctx, key, body, size, hex.EncodeToString(h.Sum(nil))); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return key, size, nil
|
||||
}
|
||||
|
||||
// Tick starts a scheduled backup when one is due. A backup that is already
|
||||
// running, or settings that aren't ready, mean there's nothing to do.
|
||||
func (s *Service) Tick(ctx context.Context) {
|
||||
st, err := s.settings(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("backup: tick", "err", err)
|
||||
return
|
||||
}
|
||||
if !st.Enabled || len(problems(st)) > 0 {
|
||||
return
|
||||
}
|
||||
t, err := s.times(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("backup: tick", "err", err)
|
||||
return
|
||||
}
|
||||
if due(s.now(), int(st.RunHour), t).After(s.now()) {
|
||||
return
|
||||
}
|
||||
if err := s.Start(ctx, TriggerSchedule); err != nil && !errors.Is(err, ErrRunning) && !errors.Is(err, ErrClosed) {
|
||||
s.logger.Warn("backup: start the scheduled backup", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Recover marks runs a stopped server left running as failed. It runs once
|
||||
// at startup, before the first tick.
|
||||
func (s *Service) Recover(ctx context.Context) {
|
||||
n, err := dbq.New(s.pool).FailUnfinishedBackupRuns(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("backup: recover unfinished runs", "err", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
s.logger.Warn("backup: a run was cut off by the last shutdown", "runs", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Loop recovers, then ticks until ctx ends. The first tick waits a full
|
||||
// interval, so a restart isn't followed straight away by a backup.
|
||||
func (s *Service) Loop(ctx context.Context) {
|
||||
s.Recover(ctx)
|
||||
t := time.NewTicker(tickEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.Tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops a running backup and waits for it to record how it ended.
|
||||
func (s *Service) Close() {
|
||||
s.mu.Lock()
|
||||
s.closed = true
|
||||
s.mu.Unlock()
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,9 @@ var Excluded = map[string]string{
|
||||
"want_candidates": "the last search's results; the next search finds them again",
|
||||
"scan_runs": "a log of past library scans",
|
||||
"diagnostic_events": "debug-mode diagnostics",
|
||||
|
||||
"backup_settings": "holds the bucket's secret key; point the restored server at its destination again",
|
||||
"backup_runs": "this server's own backup history",
|
||||
}
|
||||
|
||||
// carried reports whether a table is in Tables.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Throttle returns a reader that reads r no faster than perSecond bytes a
|
||||
// second, so an upload leaves room on the connection for everything else.
|
||||
// perSecond of 0 or less returns r unchanged. A wait ends early when ctx does,
|
||||
// and the read then returns ctx's error.
|
||||
func Throttle(ctx context.Context, r io.Reader, perSecond int64) io.Reader {
|
||||
if perSecond <= 0 {
|
||||
return r
|
||||
}
|
||||
return &throttled{ctx: ctx, r: r, perSecond: perSecond}
|
||||
}
|
||||
|
||||
type throttled struct {
|
||||
ctx context.Context
|
||||
r io.Reader
|
||||
perSecond int64
|
||||
start time.Time
|
||||
sent int64
|
||||
}
|
||||
|
||||
func (t *throttled) Read(p []byte) (int, error) {
|
||||
if t.start.IsZero() {
|
||||
t.start = time.Now()
|
||||
}
|
||||
// Reads are cut to a tenth of a second's worth, so the pace is even
|
||||
// rather than a burst followed by a long pause.
|
||||
if chunk := max(t.perSecond/10, 1); int64(len(p)) > chunk {
|
||||
p = p[:chunk]
|
||||
}
|
||||
n, err := t.r.Read(p)
|
||||
t.sent += int64(n)
|
||||
at := t.start.Add(time.Duration(float64(t.sent) / float64(t.perSecond) * float64(time.Second)))
|
||||
if wait := time.Until(at); wait > 0 {
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-t.ctx.Done():
|
||||
return n, t.ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: backups.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const backupRunTimes = `-- name: BackupRunTimes :one
|
||||
SELECT
|
||||
COALESCE((SELECT max(started_at) FROM backup_runs WHERE status = 'succeeded'),
|
||||
'epoch'::timestamptz)::timestamptz AS last_success,
|
||||
COALESCE((SELECT max(started_at) FROM backup_runs),
|
||||
'epoch'::timestamptz)::timestamptz AS last_attempt,
|
||||
COALESCE((SELECT min(started_at) FROM backup_runs
|
||||
WHERE status = 'failed'
|
||||
AND started_at > COALESCE((SELECT max(started_at) FROM backup_runs WHERE status = 'succeeded'),
|
||||
'epoch'::timestamptz)),
|
||||
'epoch'::timestamptz)::timestamptz AS failing_since
|
||||
`
|
||||
|
||||
type BackupRunTimesRow struct {
|
||||
LastSuccess pgtype.Timestamptz
|
||||
LastAttempt pgtype.Timestamptz
|
||||
FailingSince pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// The times the verdict turns on. A run that never happened is the epoch,
|
||||
// so the row always exists. failing_since is the first failure after the last
|
||||
// success, and the epoch when the latest finished run succeeded.
|
||||
func (q *Queries) BackupRunTimes(ctx context.Context) (BackupRunTimesRow, error) {
|
||||
row := q.db.QueryRow(ctx, backupRunTimes)
|
||||
var i BackupRunTimesRow
|
||||
err := row.Scan(&i.LastSuccess, &i.LastAttempt, &i.FailingSince)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const failUnfinishedBackupRuns = `-- name: FailUnfinishedBackupRuns :execrows
|
||||
UPDATE backup_runs
|
||||
SET status = 'failed', finished_at = now(),
|
||||
error = 'The server stopped while the backup was running.'
|
||||
WHERE status = 'running'
|
||||
`
|
||||
|
||||
func (q *Queries) FailUnfinishedBackupRuns(ctx context.Context) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, failUnfinishedBackupRuns)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const finishBackupRun = `-- name: FinishBackupRun :exec
|
||||
UPDATE backup_runs
|
||||
SET status = $2,
|
||||
finished_at = now(),
|
||||
location = $3,
|
||||
bytes = $4,
|
||||
error = $5
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type FinishBackupRunParams struct {
|
||||
ID pgtype.UUID
|
||||
Status string
|
||||
Location string
|
||||
Bytes int64
|
||||
Error string
|
||||
}
|
||||
|
||||
func (q *Queries) FinishBackupRun(ctx context.Context, arg FinishBackupRunParams) error {
|
||||
_, err := q.db.Exec(ctx, finishBackupRun,
|
||||
arg.ID,
|
||||
arg.Status,
|
||||
arg.Location,
|
||||
arg.Bytes,
|
||||
arg.Error,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const getBackupSettings = `-- name: GetBackupSettings :one
|
||||
|
||||
SELECT id, enabled, destination, folder, endpoint, bucket, access_key, secret_key, run_hour, upload_limit_kib, updated_at FROM backup_settings WHERE id = true
|
||||
`
|
||||
|
||||
// Minstrel's own backup: its settings and its runs.
|
||||
func (q *Queries) GetBackupSettings(ctx context.Context) (BackupSetting, error) {
|
||||
row := q.db.QueryRow(ctx, getBackupSettings)
|
||||
var i BackupSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Enabled,
|
||||
&i.Destination,
|
||||
&i.Folder,
|
||||
&i.Endpoint,
|
||||
&i.Bucket,
|
||||
&i.AccessKey,
|
||||
&i.SecretKey,
|
||||
&i.RunHour,
|
||||
&i.UploadLimitKib,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listBackupRuns = `-- name: ListBackupRuns :many
|
||||
SELECT id, trigger, status, started_at, finished_at, location, bytes, error
|
||||
FROM backup_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
func (q *Queries) ListBackupRuns(ctx context.Context, limit int32) ([]BackupRun, error) {
|
||||
rows, err := q.db.Query(ctx, listBackupRuns, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []BackupRun
|
||||
for rows.Next() {
|
||||
var i BackupRun
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Trigger,
|
||||
&i.Status,
|
||||
&i.StartedAt,
|
||||
&i.FinishedAt,
|
||||
&i.Location,
|
||||
&i.Bytes,
|
||||
&i.Error,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const pruneBackupRuns = `-- name: PruneBackupRuns :execrows
|
||||
DELETE FROM backup_runs WHERE status <> 'running' AND started_at < $1
|
||||
`
|
||||
|
||||
func (q *Queries) PruneBackupRuns(ctx context.Context, startedAt pgtype.Timestamptz) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, pruneBackupRuns, startedAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const saveBackupSettings = `-- name: SaveBackupSettings :exec
|
||||
UPDATE backup_settings
|
||||
SET enabled = $1,
|
||||
destination = $2,
|
||||
folder = $3,
|
||||
endpoint = $4,
|
||||
bucket = $5,
|
||||
access_key = $6,
|
||||
secret_key = $7,
|
||||
run_hour = $8,
|
||||
upload_limit_kib = $9,
|
||||
updated_at = now()
|
||||
WHERE id = true
|
||||
`
|
||||
|
||||
type SaveBackupSettingsParams struct {
|
||||
Enabled bool
|
||||
Destination string
|
||||
Folder string
|
||||
Endpoint string
|
||||
Bucket string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
RunHour int16
|
||||
UploadLimitKib int32
|
||||
}
|
||||
|
||||
func (q *Queries) SaveBackupSettings(ctx context.Context, arg SaveBackupSettingsParams) error {
|
||||
_, err := q.db.Exec(ctx, saveBackupSettings,
|
||||
arg.Enabled,
|
||||
arg.Destination,
|
||||
arg.Folder,
|
||||
arg.Endpoint,
|
||||
arg.Bucket,
|
||||
arg.AccessKey,
|
||||
arg.SecretKey,
|
||||
arg.RunHour,
|
||||
arg.UploadLimitKib,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const startBackupRun = `-- name: StartBackupRun :one
|
||||
INSERT INTO backup_runs (trigger) VALUES ($1)
|
||||
RETURNING id, started_at
|
||||
`
|
||||
|
||||
type StartBackupRunRow struct {
|
||||
ID pgtype.UUID
|
||||
StartedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
func (q *Queries) StartBackupRun(ctx context.Context, trigger string) (StartBackupRunRow, error) {
|
||||
row := q.db.QueryRow(ctx, startBackupRun, trigger)
|
||||
var i StartBackupRunRow
|
||||
err := row.Scan(&i.ID, &i.StartedAt)
|
||||
return i, err
|
||||
}
|
||||
@@ -305,6 +305,31 @@ type AuditLog struct {
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type BackupRun struct {
|
||||
ID pgtype.UUID
|
||||
Trigger string
|
||||
Status string
|
||||
StartedAt pgtype.Timestamptz
|
||||
FinishedAt pgtype.Timestamptz
|
||||
Location string
|
||||
Bytes int64
|
||||
Error string
|
||||
}
|
||||
|
||||
type BackupSetting struct {
|
||||
ID bool
|
||||
Enabled bool
|
||||
Destination string
|
||||
Folder string
|
||||
Endpoint string
|
||||
Bucket string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
RunHour int16
|
||||
UploadLimitKib int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type CandidateArtistTag struct {
|
||||
CandidateMbid string
|
||||
Tag string
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS backup_runs;
|
||||
DROP TABLE IF EXISTS backup_settings;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- 0099_backups.up.sql — Minstrel backing itself up.
|
||||
--
|
||||
-- backup_settings one row: whether the nightly backup is on, where it goes
|
||||
-- (a folder, or an S3 bucket such as a Fabled Archives
|
||||
-- target), and the hour it runs, in UTC. Off until an
|
||||
-- admin switches it on.
|
||||
-- backup_runs one row per attempt: when, what started it, how it ended,
|
||||
-- where the file went, its size, and the error in words.
|
||||
CREATE TABLE backup_settings (
|
||||
id boolean PRIMARY KEY DEFAULT true CONSTRAINT backup_settings_one_row CHECK (id),
|
||||
enabled boolean NOT NULL DEFAULT false,
|
||||
destination text NOT NULL DEFAULT 's3'
|
||||
CONSTRAINT backup_settings_destination_check CHECK (destination IN ('folder', 's3')),
|
||||
folder text NOT NULL DEFAULT '',
|
||||
endpoint text NOT NULL DEFAULT '',
|
||||
bucket text NOT NULL DEFAULT '',
|
||||
access_key text NOT NULL DEFAULT '',
|
||||
-- Sealed under the stream secret, as every stored credential is.
|
||||
secret_key text NOT NULL DEFAULT '',
|
||||
run_hour smallint NOT NULL DEFAULT 3
|
||||
CONSTRAINT backup_settings_run_hour_check CHECK (run_hour BETWEEN 0 AND 23),
|
||||
-- The most an upload to the bucket may send, in KiB a second; 0 is no limit.
|
||||
upload_limit_kib integer NOT NULL DEFAULT 4096
|
||||
CONSTRAINT backup_settings_upload_limit_check CHECK (upload_limit_kib >= 0),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO backup_settings DEFAULT VALUES;
|
||||
|
||||
CREATE TABLE backup_runs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
trigger text NOT NULL
|
||||
CONSTRAINT backup_runs_trigger_check CHECK (trigger IN ('schedule', 'manual')),
|
||||
status text NOT NULL DEFAULT 'running'
|
||||
CONSTRAINT backup_runs_status_check CHECK (status IN ('running', 'succeeded', 'failed')),
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
-- Where the file went: the object key, or the file's path in the folder.
|
||||
location text NOT NULL DEFAULT '',
|
||||
bytes bigint NOT NULL DEFAULT 0,
|
||||
error text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX backup_runs_started_at_idx ON backup_runs (started_at DESC);
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Minstrel's own backup: its settings and its runs.
|
||||
|
||||
-- name: GetBackupSettings :one
|
||||
SELECT * FROM backup_settings WHERE id = true;
|
||||
|
||||
-- name: SaveBackupSettings :exec
|
||||
UPDATE backup_settings
|
||||
SET enabled = $1,
|
||||
destination = $2,
|
||||
folder = $3,
|
||||
endpoint = $4,
|
||||
bucket = $5,
|
||||
access_key = $6,
|
||||
secret_key = $7,
|
||||
run_hour = $8,
|
||||
upload_limit_kib = $9,
|
||||
updated_at = now()
|
||||
WHERE id = true;
|
||||
|
||||
-- name: StartBackupRun :one
|
||||
INSERT INTO backup_runs (trigger) VALUES ($1)
|
||||
RETURNING id, started_at;
|
||||
|
||||
-- name: FinishBackupRun :exec
|
||||
UPDATE backup_runs
|
||||
SET status = $2,
|
||||
finished_at = now(),
|
||||
location = $3,
|
||||
bytes = $4,
|
||||
error = $5
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ListBackupRuns :many
|
||||
SELECT id, trigger, status, started_at, finished_at, location, bytes, error
|
||||
FROM backup_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $1;
|
||||
|
||||
-- name: BackupRunTimes :one
|
||||
-- The times the verdict turns on. A run that never happened is the epoch,
|
||||
-- so the row always exists. failing_since is the first failure after the last
|
||||
-- success, and the epoch when the latest finished run succeeded.
|
||||
SELECT
|
||||
COALESCE((SELECT max(started_at) FROM backup_runs WHERE status = 'succeeded'),
|
||||
'epoch'::timestamptz)::timestamptz AS last_success,
|
||||
COALESCE((SELECT max(started_at) FROM backup_runs),
|
||||
'epoch'::timestamptz)::timestamptz AS last_attempt,
|
||||
COALESCE((SELECT min(started_at) FROM backup_runs
|
||||
WHERE status = 'failed'
|
||||
AND started_at > COALESCE((SELECT max(started_at) FROM backup_runs WHERE status = 'succeeded'),
|
||||
'epoch'::timestamptz)),
|
||||
'epoch'::timestamptz)::timestamptz AS failing_since;
|
||||
|
||||
-- name: FailUnfinishedBackupRuns :execrows
|
||||
UPDATE backup_runs
|
||||
SET status = 'failed', finished_at = now(),
|
||||
error = 'The server stopped while the backup was running.'
|
||||
WHERE status = 'running';
|
||||
|
||||
-- name: PruneBackupRuns :execrows
|
||||
DELETE FROM backup_runs WHERE status <> 'running' AND started_at < $1;
|
||||
@@ -184,6 +184,16 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
|
||||
); err != nil {
|
||||
t.Fatalf("dbtest.ResetDB reset release-type profiles: %v", err)
|
||||
}
|
||||
// The server's own backup: no runs, and settings back to off.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
DELETE FROM backup_runs;
|
||||
UPDATE backup_settings
|
||||
SET enabled = DEFAULT, destination = DEFAULT, folder = DEFAULT, endpoint = DEFAULT,
|
||||
bucket = DEFAULT, access_key = DEFAULT, secret_key = DEFAULT, run_hour = DEFAULT,
|
||||
upload_limit_kib = DEFAULT, updated_at = DEFAULT`,
|
||||
); err != nil {
|
||||
t.Fatalf("dbtest.ResetDB reset backup settings: %v", err)
|
||||
}
|
||||
// Search and grab settings, a singleton reset the same way.
|
||||
// The library listing reads as done, so a test's albums are not
|
||||
// put on the wanted list behind its back; its own tests clear it.
|
||||
|
||||
@@ -35,6 +35,7 @@ var (
|
||||
SubsonicPassword = Column{"users", "subsonic_password", "id", false}
|
||||
CoverArtAPIKey = Column{"cover_art_provider_settings", "api_key", "provider_id", false}
|
||||
TagProviderAPIKey = Column{"tag_provider_settings", "api_key", "provider_id", false}
|
||||
BackupSecretKey = Column{"backup_settings", "secret_key", "id", true}
|
||||
)
|
||||
|
||||
// Columns is every sealed column, for SealStored and for anything that has to
|
||||
@@ -42,6 +43,7 @@ var (
|
||||
var Columns = []Column{
|
||||
AcquisitionAPIKey, AcquisitionPassword, AcoustIDAPIKey, SMTPPassword,
|
||||
ListenBrainzToken, SubsonicPassword, CoverArtAPIKey, TagProviderAPIKey,
|
||||
BackupSecretKey,
|
||||
}
|
||||
|
||||
// Owner is the owner string for a row whose key is key. A single-row table's
|
||||
|
||||
@@ -84,6 +84,7 @@ func TestSealStoredSealsWhatIsInTheClear_Integration(t *testing.T) {
|
||||
`INSERT INTO tag_provider_settings (provider_id, enabled, display_order, api_key)
|
||||
VALUES ('seal-test', false, 99, 'tag-key')
|
||||
ON CONFLICT (provider_id) DO UPDATE SET api_key = EXCLUDED.api_key`,
|
||||
`UPDATE backup_settings SET secret_key = 'bucket-secret' WHERE id = true`,
|
||||
}
|
||||
for _, s := range seed {
|
||||
if _, err := pool.Exec(ctx, s); err != nil {
|
||||
@@ -97,6 +98,7 @@ func TestSealStoredSealsWhatIsInTheClear_Integration(t *testing.T) {
|
||||
`DELETE FROM acquisition_services WHERE code = 'seal-test'`,
|
||||
`UPDATE acoustid_settings SET api_key = NULL WHERE id = true`,
|
||||
`UPDATE smtp_config SET password = '' WHERE id = true`,
|
||||
`UPDATE backup_settings SET secret_key = '' WHERE id = true`,
|
||||
} {
|
||||
_, _ = pool.Exec(context.Background(), s)
|
||||
}
|
||||
@@ -115,6 +117,7 @@ func TestSealStoredSealsWhatIsInTheClear_Integration(t *testing.T) {
|
||||
{secret.SubsonicPassword, u.ID.String(), "sub-pw"},
|
||||
{secret.CoverArtAPIKey, "seal-test", "cover-key"},
|
||||
{secret.TagProviderAPIKey, "seal-test", "tag-key"},
|
||||
{secret.BackupSecretKey, "true", "bucket-secret"},
|
||||
}
|
||||
if len(want) != len(secret.Columns) {
|
||||
t.Fatalf("the test covers %d columns, secret.Columns lists %d", len(want), len(secret.Columns))
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/api"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
@@ -94,6 +95,9 @@ type Server struct {
|
||||
// the admin card reaches it without a restart. Router() constructs a
|
||||
// fallback when nil (tests).
|
||||
ReacqSettings *reacquisition.SettingsService
|
||||
// Backups is the server's own backup, the instance whose loop runs the
|
||||
// nightly schedule. Nil builds one that runs only when an admin asks.
|
||||
Backups *backup.Service
|
||||
// StreamSecret is the HMAC key used by /api/cast/stream-token to
|
||||
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
|
||||
// to verify them. Sourced from config.Config.StreamSecret. Tests that
|
||||
@@ -200,7 +204,11 @@ func (s *Server) Router() http.Handler {
|
||||
}
|
||||
acoustIDLookup = library.NewAcoustIDLookupWorker(s.Pool, s.Logger, acoustIDSettings, nil)
|
||||
}
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, requestSvc, quarantineSvc, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings, acoustIDLookup)
|
||||
backups := s.Backups
|
||||
if backups == nil {
|
||||
backups = backup.NewService(s.Pool, s.Logger, buildinfo.Version)
|
||||
}
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, requestSvc, quarantineSvc, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings, acoustIDLookup, backups)
|
||||
// /api/admin/scan is the only admin route owned by the server package
|
||||
// (it needs the Scanner). Register it as a single inline-middleware
|
||||
// route — using r.Route("/api/admin", ...) here would create a second
|
||||
|
||||
@@ -1086,3 +1086,72 @@ export async function clearAcquisitionSecret(
|
||||
): Promise<void> {
|
||||
await api.del(`${acquisitionBase}/${encodeURIComponent(code)}/${which}`);
|
||||
}
|
||||
|
||||
// The server's own backup ---------------------------------------------------
|
||||
|
||||
export type BackupDestination = 'folder' | 's3';
|
||||
|
||||
export type BackupConfig = {
|
||||
enabled: boolean;
|
||||
destination: BackupDestination;
|
||||
folder: string;
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
access_key: string;
|
||||
/** The secret key itself never comes back. */
|
||||
has_secret_key: boolean;
|
||||
/** The hour of day the nightly backup starts, in UTC. */
|
||||
run_hour: number;
|
||||
/** KiB a second an upload to the bucket may use; 0 is no limit. */
|
||||
upload_limit_kib: number;
|
||||
};
|
||||
|
||||
export type BackupState = 'off' | 'incomplete' | 'failing' | 'waiting' | 'overdue' | 'ok';
|
||||
|
||||
export type BackupRun = {
|
||||
id: string;
|
||||
trigger: 'schedule' | 'manual';
|
||||
status: 'running' | 'succeeded' | 'failed';
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
/** The file's path in the folder, or its key in the bucket. */
|
||||
location: string;
|
||||
bytes: number;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type BackupStatus = {
|
||||
config: BackupConfig;
|
||||
/** Worked out by the server; the panel only words it. */
|
||||
state: BackupState;
|
||||
problems: string[];
|
||||
running_since: string | null;
|
||||
last_success: string | null;
|
||||
failing_since: string | null;
|
||||
next_run: string | null;
|
||||
/** The longest a run may take; the panel stops polling a little after. */
|
||||
run_limit_seconds: number;
|
||||
runs: BackupRun[];
|
||||
};
|
||||
|
||||
/** A save or test. An empty secret_key keeps the stored one. */
|
||||
export type BackupUpdate = Omit<BackupConfig, 'has_secret_key'> & { secret_key: string };
|
||||
|
||||
export type BackupTestResult = { backups: number; latest: string | null };
|
||||
|
||||
export async function getBackups(): Promise<BackupStatus> {
|
||||
return api.get<BackupStatus>('/api/admin/backups');
|
||||
}
|
||||
|
||||
export async function saveBackups(u: BackupUpdate): Promise<BackupStatus> {
|
||||
return api.put<BackupStatus>('/api/admin/backups', u);
|
||||
}
|
||||
|
||||
/** Checks the destination in u without saving it; nothing is written there. */
|
||||
export async function testBackups(u: BackupUpdate): Promise<BackupTestResult> {
|
||||
return api.post<BackupTestResult>('/api/admin/backups/test', u);
|
||||
}
|
||||
|
||||
export async function runBackup(): Promise<void> {
|
||||
await api.post<null>('/api/admin/backups/run', {});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,11 @@ const DETAIL_CODES: ReadonlySet<string> = new Set([
|
||||
// The server says what shape of address it wants.
|
||||
'invalid_public_url',
|
||||
// The server names the folder and what is wrong with it.
|
||||
'bad_folders'
|
||||
'bad_folders',
|
||||
// The server says which backup setting and why.
|
||||
'invalid_backup_setting',
|
||||
// The destination's own answer: the bucket's refusal, or the missing folder.
|
||||
'destination_failed'
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { getBackups, runBackup, type BackupStatus } from '#lib/api/admin.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { formatBytes } from '#lib/utils/bytes.js';
|
||||
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import BackupSettings from './BackupSettings.svelte';
|
||||
import { when } from './backupTime.js';
|
||||
|
||||
// The server's own backup: a verdict line first, then what to do about it,
|
||||
// then the latest runs, with the settings beside them. The server works out
|
||||
// the state; this only words it.
|
||||
|
||||
let status = $state<BackupStatus | null>(null);
|
||||
let loadError = $state(false);
|
||||
let starting = $state(false);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
status = await getBackups();
|
||||
loadError = false;
|
||||
} catch {
|
||||
loadError = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
// Polled only while a backup runs, and never past the server's own limit
|
||||
// on a run plus a margin, so a stuck run can't keep a tab asking forever.
|
||||
const POLL_MS = 3000;
|
||||
const POLL_MARGIN_MS = 5 * 60 * 1000;
|
||||
let poll: ReturnType<typeof setTimeout> | null = null;
|
||||
let pollStarted = 0;
|
||||
|
||||
function stopPolling() {
|
||||
if (poll !== null) clearTimeout(poll);
|
||||
poll = null;
|
||||
}
|
||||
|
||||
function pollWhileRunning() {
|
||||
stopPolling();
|
||||
const limit = (status?.run_limit_seconds ?? 1800) * 1000 + POLL_MARGIN_MS;
|
||||
if (!status?.running_since || Date.now() - pollStarted > limit) return;
|
||||
poll = setTimeout(async () => {
|
||||
try {
|
||||
await load();
|
||||
} finally {
|
||||
pollWhileRunning();
|
||||
}
|
||||
}, POLL_MS);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (status?.running_since && poll === null) {
|
||||
pollStarted = Date.now();
|
||||
pollWhileRunning();
|
||||
}
|
||||
});
|
||||
onDestroy(stopPolling);
|
||||
|
||||
async function backUpNow() {
|
||||
starting = true;
|
||||
try {
|
||||
await runBackup();
|
||||
pushToast('Backup started.');
|
||||
await load();
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
await load();
|
||||
} finally {
|
||||
starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
type Tone = 'good' | 'warn' | 'bad' | 'plain';
|
||||
const toneClass: Record<Tone, string> = {
|
||||
good: 'text-success-fg',
|
||||
warn: 'text-warning-fg',
|
||||
bad: 'text-error-fg',
|
||||
plain: 'text-text-primary'
|
||||
};
|
||||
|
||||
const verdict = $derived.by((): { tone: Tone; text: string } | null => {
|
||||
const s = status;
|
||||
if (!s) return null;
|
||||
if (s.running_since) return { tone: 'plain', text: `Backing up, since ${when(s.running_since)}.` };
|
||||
switch (s.state) {
|
||||
case 'off':
|
||||
return { tone: 'plain', text: 'Off. Nothing is backed up on a schedule.' };
|
||||
case 'incomplete':
|
||||
return { tone: 'warn', text: 'Switched on, but not set up yet.' };
|
||||
case 'waiting':
|
||||
return { tone: 'plain', text: 'Set up. No backup has been made yet.' };
|
||||
case 'ok':
|
||||
return { tone: 'good', text: `Backed up ${when(s.last_success!)}.` };
|
||||
case 'overdue':
|
||||
return { tone: 'warn', text: `No backup since ${when(s.last_success!)}.` };
|
||||
case 'failing':
|
||||
return { tone: 'bad', text: `Backups failing since ${when(s.failing_since!)}.` };
|
||||
}
|
||||
});
|
||||
|
||||
const latest = $derived(status?.runs[0]);
|
||||
const ready = $derived(!!status && status.problems.length === 0);
|
||||
|
||||
const button =
|
||||
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
||||
</script>
|
||||
|
||||
{#if loadError && !status}
|
||||
<p class="text-sm text-error-fg">
|
||||
Couldn't load the backup status.
|
||||
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
|
||||
</p>
|
||||
{:else if !status}
|
||||
<p class="text-sm text-text-secondary" aria-busy="true">Loading the backup status…</p>
|
||||
{:else}
|
||||
<SettingsColumns>
|
||||
{#snippet main()}
|
||||
<SettingsGroup title="Backup" id="backup" hint="Your accounts, likes, playlists, history and settings, in one file a night.">
|
||||
<div class="space-y-1 px-4 py-3" role="status" data-testid="backup-verdict">
|
||||
<p class="text-sm {toneClass[verdict?.tone ?? 'plain']}">{verdict?.text}</p>
|
||||
{#if status.state === 'failing' && latest?.error && !status.running_since}
|
||||
<p class="text-xs text-text-secondary">{latest.error}</p>
|
||||
{/if}
|
||||
{#if status.config.enabled}
|
||||
{#each status.problems as problem (problem)}
|
||||
<p class="text-xs text-text-secondary">{problem}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if status.next_run && !status.running_since}
|
||||
<p class="text-xs text-text-secondary">Next backup {when(status.next_run)}.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
class={button}
|
||||
disabled={starting || !!status.running_since || !ready}
|
||||
title={ready ? undefined : 'Set up where backups go first'}
|
||||
onclick={backUpNow}
|
||||
>
|
||||
{status.running_since ? 'Backing up…' : 'Back up now'}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
{#if status.runs.length > 0}
|
||||
<SettingsGroup title="Latest backups" id="backup-runs">
|
||||
<ul class="divide-y divide-border text-sm" aria-label="Latest backups">
|
||||
{#each status.runs as run (run.id)}
|
||||
<li class="flex items-start justify-between gap-4 px-4 py-2">
|
||||
<span class="min-w-0">
|
||||
<span class="block text-text-primary">{when(run.started_at)}</span>
|
||||
<span class="block break-all text-xs text-text-secondary">
|
||||
{run.trigger === 'manual' ? 'Started by hand' : 'Scheduled'}
|
||||
{#if run.status === 'succeeded'}· {run.location} · {formatBytes(run.bytes)}{/if}
|
||||
{#if run.status === 'failed'}· {run.error}{/if}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="shrink-0 text-xs {run.status === 'succeeded'
|
||||
? 'text-success-fg'
|
||||
: run.status === 'failed'
|
||||
? 'text-error-fg'
|
||||
: 'text-text-secondary'}"
|
||||
>
|
||||
{run.status === 'succeeded' ? 'Saved' : run.status === 'failed' ? 'Failed' : 'Running'}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</SettingsGroup>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet side()}
|
||||
<BackupSettings config={status!.config} onsaved={(s) => (status = s)} />
|
||||
{/snippet}
|
||||
</SettingsColumns>
|
||||
{/if}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import type { BackupStatus } from '#lib/api/admin.js';
|
||||
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
getBackups: vi.fn(),
|
||||
saveBackups: vi.fn(),
|
||||
testBackups: vi.fn(),
|
||||
runBackup: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import BackupPanel from './BackupPanel.svelte';
|
||||
import { getBackups, runBackup, saveBackups, testBackups } from '#lib/api/admin.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
|
||||
const base: BackupStatus = {
|
||||
config: {
|
||||
enabled: true,
|
||||
destination: 's3',
|
||||
folder: '',
|
||||
endpoint: 'https://s3.example.com',
|
||||
bucket: 'minstrel',
|
||||
access_key: 'ak',
|
||||
has_secret_key: true,
|
||||
run_hour: 3,
|
||||
upload_limit_kib: 4096
|
||||
},
|
||||
state: 'ok',
|
||||
problems: [],
|
||||
running_since: null,
|
||||
last_success: '2026-11-02T03:00:00Z',
|
||||
failing_since: null,
|
||||
next_run: '2026-11-03T03:00:00Z',
|
||||
run_limit_seconds: 1800,
|
||||
runs: [
|
||||
{
|
||||
id: 'r1',
|
||||
trigger: 'schedule',
|
||||
status: 'succeeded',
|
||||
started_at: '2026-11-02T03:00:00Z',
|
||||
finished_at: '2026-11-02T03:00:09Z',
|
||||
location: 'database/minstrel-20261102T030000Z.tar',
|
||||
bytes: 12_000_000,
|
||||
error: ''
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
function status(over: Partial<BackupStatus> = {}): BackupStatus {
|
||||
return { ...base, ...over, config: { ...base.config, ...over.config } };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function renderPanel(s: BackupStatus = base) {
|
||||
vi.mocked(getBackups).mockResolvedValue(s);
|
||||
render(BackupPanel);
|
||||
await screen.findByTestId('backup-verdict');
|
||||
}
|
||||
|
||||
const verdict = () => screen.getByTestId('backup-verdict').textContent?.replace(/\s+/g, ' ') ?? '';
|
||||
|
||||
describe('BackupPanel', () => {
|
||||
test('leads with the verdict and lists the latest runs', async () => {
|
||||
await renderPanel();
|
||||
expect(verdict()).toMatch(/^ ?Backed up /);
|
||||
expect(verdict()).toMatch(/Next backup /);
|
||||
const runs = screen.getByRole('list', { name: /latest backups/i });
|
||||
expect(runs.textContent).toMatch(/Scheduled · database\/minstrel-20261102T030000Z\.tar · 12 MB/);
|
||||
expect(runs.textContent).toMatch(/Saved/);
|
||||
});
|
||||
|
||||
test('a failing backup says since when and why', async () => {
|
||||
await renderPanel(
|
||||
status({
|
||||
state: 'failing',
|
||||
failing_since: '2026-11-02T03:00:00Z',
|
||||
runs: [{ ...base.runs[0], status: 'failed', error: 'HTTP 403 SignatureDoesNotMatch: the secret key is wrong' }]
|
||||
})
|
||||
);
|
||||
expect(verdict()).toMatch(/Backups failing since /);
|
||||
expect(verdict()).toMatch(/the secret key is wrong/);
|
||||
});
|
||||
|
||||
test('switched on but not set up: names what is missing and holds Back up now', async () => {
|
||||
await renderPanel(status({ state: 'incomplete', problems: ['Enter the secret key.'], next_run: null }));
|
||||
expect(verdict()).toMatch(/not set up yet/);
|
||||
expect(verdict()).toMatch(/Enter the secret key\./);
|
||||
expect(screen.getByRole('button', { name: /back up now/i })).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('switched off reads as off, without nagging about the destination', async () => {
|
||||
await renderPanel(status({ state: 'off', config: { ...base.config, enabled: false }, problems: ['Enter the secret key.'] }));
|
||||
expect(verdict()).toMatch(/^ ?Off\./);
|
||||
expect(verdict()).not.toMatch(/secret key/);
|
||||
});
|
||||
|
||||
test('Back up now starts a run and polls only while it runs', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(runBackup).mockResolvedValue();
|
||||
const running = status({ running_since: '2026-11-02T10:00:00Z' });
|
||||
vi.mocked(getBackups).mockResolvedValueOnce(base).mockResolvedValueOnce(running).mockResolvedValueOnce(running).mockResolvedValue(base);
|
||||
render(BackupPanel);
|
||||
await vi.waitFor(() => screen.getByTestId('backup-verdict'));
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /back up now/i }));
|
||||
await vi.waitFor(() => expect(runBackup).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(verdict()).toMatch(/Backing up, since /));
|
||||
expect(pushToast).toHaveBeenCalledWith('Backup started.');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await vi.waitFor(() => expect(verdict()).toMatch(/Backed up /));
|
||||
const calls = vi.mocked(getBackups).mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(vi.mocked(getBackups).mock.calls.length).toBe(calls);
|
||||
});
|
||||
|
||||
test('a stuck run stops being polled after the run limit and a margin', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(getBackups).mockResolvedValue(status({ running_since: '2026-11-02T10:00:00Z', run_limit_seconds: 60 }));
|
||||
render(BackupPanel);
|
||||
await vi.waitFor(() => screen.getByTestId('backup-verdict'));
|
||||
// 60 s limit + 5 min margin, and a little more.
|
||||
await vi.advanceTimersByTimeAsync(7 * 60 * 1000);
|
||||
const calls = vi.mocked(getBackups).mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(vi.mocked(getBackups).mock.calls.length).toBe(calls);
|
||||
});
|
||||
|
||||
test('never shows the stored secret key, and sends one only when typed', async () => {
|
||||
vi.mocked(saveBackups).mockResolvedValue(base);
|
||||
await renderPanel();
|
||||
const key = screen.getByLabelText(/secret key/i) as HTMLInputElement;
|
||||
expect(key.value).toBe('');
|
||||
expect(key.placeholder).toBe('Key saved');
|
||||
const save = screen.getByRole('button', { name: /^save$/i });
|
||||
expect(save).toHaveProperty('disabled', true);
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/^bucket$/i), { target: { value: 'music-backup' } });
|
||||
await fireEvent.click(save);
|
||||
await waitFor(() =>
|
||||
expect(saveBackups).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
destination: 's3',
|
||||
folder: '',
|
||||
endpoint: 'https://s3.example.com',
|
||||
bucket: 'music-backup',
|
||||
access_key: 'ak',
|
||||
secret_key: '',
|
||||
run_hour: 3,
|
||||
upload_limit_kib: 4096
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('Test destination checks the form as typed and says what it found', async () => {
|
||||
vi.mocked(testBackups).mockResolvedValue({ backups: 3, latest: '2026-11-02T03:00:05Z' });
|
||||
await renderPanel();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /test destination/i }));
|
||||
const out = await screen.findByTestId('backup-test');
|
||||
expect(out.textContent).toMatch(/Reached it\.\s+3 backups there, the newest from /);
|
||||
});
|
||||
|
||||
test('a refused test shows the destination’s answer', async () => {
|
||||
vi.mocked(testBackups).mockRejectedValue({
|
||||
code: 'destination_failed',
|
||||
message: 'HTTP 404 NoSuchBucket: there is no bucket by that name'
|
||||
});
|
||||
await renderPanel();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /test destination/i }));
|
||||
expect((await screen.findByTestId('backup-test')).textContent).toMatch(
|
||||
/Couldn't reach the backup destination\. HTTP 404 NoSuchBucket/
|
||||
);
|
||||
});
|
||||
|
||||
test('the folder destination asks for a path, not a bucket', async () => {
|
||||
await renderPanel(status({ config: { ...base.config, destination: 'folder', folder: '/backups' } }));
|
||||
expect(screen.getByLabelText(/^folder$/i)).toHaveProperty('value', '/backups');
|
||||
expect(screen.queryByLabelText(/^bucket$/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
saveBackups,
|
||||
testBackups,
|
||||
type BackupConfig,
|
||||
type BackupDestination,
|
||||
type BackupStatus,
|
||||
type BackupTestResult,
|
||||
type BackupUpdate
|
||||
} from '#lib/api/admin.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
import Segmented from '#lib/components/settings/Segmented.svelte';
|
||||
import { hourLabel, when } from './backupTime.js';
|
||||
|
||||
// Where the nightly backup goes and when. The form keeps its own drafts,
|
||||
// filled from the server on load and after each save, so a status poll
|
||||
// never overwrites what is being typed.
|
||||
|
||||
let { config, onsaved }: { config: BackupConfig; onsaved: (s: BackupStatus) => void } = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let draft = $state(fromConfig(config));
|
||||
// Empty means "keep the stored key". The key itself never comes back.
|
||||
let secretKey = $state('');
|
||||
let saving = $state(false);
|
||||
let testing = $state(false);
|
||||
let check = $state<BackupTestResult | null>(null);
|
||||
let testError = $state('');
|
||||
|
||||
function fromConfig(c: BackupConfig) {
|
||||
return {
|
||||
enabled: c.enabled,
|
||||
destination: c.destination,
|
||||
folder: c.folder,
|
||||
endpoint: c.endpoint,
|
||||
bucket: c.bucket,
|
||||
access_key: c.access_key,
|
||||
run_hour: c.run_hour,
|
||||
upload_limit_kib: c.upload_limit_kib
|
||||
};
|
||||
}
|
||||
|
||||
const update = (): BackupUpdate => ({ ...draft, secret_key: secretKey.trim() });
|
||||
|
||||
const dirty = $derived(
|
||||
secretKey.trim() !== '' || JSON.stringify(draft) !== JSON.stringify(fromConfig(config))
|
||||
);
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
const s = await saveBackups(update());
|
||||
secretKey = '';
|
||||
draft = fromConfig(s.config);
|
||||
check = null;
|
||||
onsaved(s);
|
||||
pushToast('Backup settings saved.');
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
testing = true;
|
||||
check = null;
|
||||
testError = '';
|
||||
try {
|
||||
check = await testBackups(update());
|
||||
} catch (e) {
|
||||
testError = errMessage(e);
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
const destinations: { value: BackupDestination; label: string }[] = [
|
||||
{ value: 's3', label: 'S3 bucket' },
|
||||
{ value: 'folder', label: 'Folder' }
|
||||
];
|
||||
const hours = Array.from({ length: 24 }, (_, h) => h);
|
||||
|
||||
const button =
|
||||
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
||||
const field = 'rounded-md border border-border bg-background px-3 py-1.5 text-sm';
|
||||
</script>
|
||||
|
||||
<SettingsGroup
|
||||
title="Backup settings"
|
||||
id="backup-settings"
|
||||
hint="Minstrel only ever adds backups. Thin out old ones with the destination's own retention."
|
||||
>
|
||||
<SettingRow label="Back up every night" for="backup-enabled" hint="Off until you switch it on.">
|
||||
<input
|
||||
id="backup-enabled"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={draft.enabled}
|
||||
bind:checked={draft.enabled}
|
||||
class="h-4 w-4 cursor-pointer accent-accent"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Destination">
|
||||
<Segmented
|
||||
name="backup-destination"
|
||||
label="Destination"
|
||||
options={destinations}
|
||||
value={draft.destination}
|
||||
onchange={(v) => (draft.destination = v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{#if draft.destination === 'folder'}
|
||||
<SettingRow label="Folder" for="backup-folder" hint="A full path on the server, such as a mounted volume.">
|
||||
<input id="backup-folder" bind:value={draft.folder} placeholder="/backups" autocomplete="off" spellcheck="false" class="{field} w-56 font-mono" />
|
||||
</SettingRow>
|
||||
{:else}
|
||||
<SettingRow label="Endpoint" for="backup-endpoint" hint="The bucket's server, path-style.">
|
||||
<input
|
||||
id="backup-endpoint"
|
||||
bind:value={draft.endpoint}
|
||||
placeholder="https://s3.example.com"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="{field} w-56"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Bucket" for="backup-bucket">
|
||||
<input id="backup-bucket" bind:value={draft.bucket} placeholder="minstrel" autocomplete="off" spellcheck="false" class="{field} w-56" />
|
||||
</SettingRow>
|
||||
<SettingRow label="Access key" for="backup-access-key">
|
||||
<input id="backup-access-key" bind:value={draft.access_key} autocomplete="off" spellcheck="false" class="{field} w-56 font-mono" />
|
||||
</SettingRow>
|
||||
<SettingRow label="Secret key" for="backup-secret-key" hint="Kept encrypted on the server.">
|
||||
<input
|
||||
id="backup-secret-key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={secretKey}
|
||||
placeholder={config.has_secret_key ? 'Key saved' : 'Paste key'}
|
||||
class="{field} w-56 font-mono"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Upload limit" for="backup-limit" hint="KiB a second, so an upload leaves room on the connection. 0 is no limit.">
|
||||
<input id="backup-limit" type="number" min="0" step="256" bind:value={draft.upload_limit_kib} class="{field} w-28" />
|
||||
</SettingRow>
|
||||
{/if}
|
||||
|
||||
<SettingRow label="Time of day" for="backup-hour" hint="On your clock.">
|
||||
<select id="backup-hour" bind:value={draft.run_hour} class={field}>
|
||||
{#each hours as h (h)}
|
||||
<option value={h}>{hourLabel(h)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
<div class="space-y-2 px-4 py-3">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<button type="button" class={button} disabled={testing} onclick={test}>
|
||||
{testing ? 'Testing…' : 'Test destination'}
|
||||
</button>
|
||||
<button type="button" class={button} disabled={!dirty || saving} onclick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{#if testError}
|
||||
<p class="text-sm text-error-fg" role="alert" data-testid="backup-test">{testError}</p>
|
||||
{:else if check}
|
||||
<p class="text-sm text-success-fg" role="status" data-testid="backup-test">
|
||||
Reached it.
|
||||
{#if check.backups === 0}
|
||||
No backups there yet.
|
||||
{:else}
|
||||
{check.backups} {check.backups === 1 ? 'backup' : 'backups'} there, the newest from {when(check.latest!)}.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
@@ -0,0 +1,14 @@
|
||||
/** An exact moment in the viewer's own zone, with its date. */
|
||||
export function when(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
/**
|
||||
* The server keeps the run hour in UTC; this is that hour on the viewer's
|
||||
* clock today. Today's offset is used, so across daylight saving the local
|
||||
* label moves while the run stays at the same UTC hour.
|
||||
*/
|
||||
export function hourLabel(utcHour: number, now: Date = new Date()): string {
|
||||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), utcHour));
|
||||
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
@@ -72,5 +72,10 @@
|
||||
"block_not_found": "That release isn't set aside any more.",
|
||||
"not_running": "That download isn't running any more. Refresh the list.",
|
||||
"bad_lifecycle": "Seed ratio is 0 to 100, seed days 0 to 365, and stall hours 0 to 720.",
|
||||
"bad_upgrade_until": "Upgrade until has to be one of the qualities listed."
|
||||
"bad_upgrade_until": "Upgrade until has to be one of the qualities listed.",
|
||||
"invalid_backup_setting": "That backup setting can't be saved.",
|
||||
"destination_failed": "Couldn't reach the backup destination.",
|
||||
"backup_running": "A backup is already running.",
|
||||
"backup_not_ready": "Set up where backups go first.",
|
||||
"shutting_down": "The server is shutting down. Try again in a moment."
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
{ href: '/admin/metadata', label: 'Metadata sources' },
|
||||
{ href: '/admin/email', label: 'Email & network' },
|
||||
{ href: '/admin/users', label: 'Users' },
|
||||
{ href: '/admin/backups', label: 'Backups' },
|
||||
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
||||
{ href: '/admin/tuning', label: 'Tuning' }
|
||||
]);
|
||||
|
||||
@@ -291,6 +291,7 @@ describe('admin layout', () => {
|
||||
['/admin/metadata', 'Metadata sources'],
|
||||
['/admin/email', 'Email & network'],
|
||||
['/admin/users', 'Users'],
|
||||
['/admin/backups', 'Backups'],
|
||||
['/admin/diagnostics', 'Diagnostics'],
|
||||
['/admin/tuning', 'Tuning']
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import BackupPanel from '#lib/components/admin/BackupPanel.svelte';
|
||||
|
||||
// Admin › Backups: Minstrel's own nightly backup, where it goes, and how
|
||||
// the latest runs went.
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Backups')}</title></svelte:head>
|
||||
|
||||
<h2 class="sr-only">Backups</h2>
|
||||
|
||||
<BackupPanel />
|
||||
Reference in New Issue
Block a user