From 733ab3753996114488f4507196117829f2e0757b Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 21 Dec 2023 13:03:16 +0000 Subject: [PATCH] backups: add backup daemon to run every n minutes, keep x most recent backups --- config.go | 3 ++ config/config-base.json | 43 ++++++++++++++++ daemon.go | 109 ++++++++++++++++++++++++++++++++++++++++ main.go | 7 +++ updater.go | 1 - 5 files changed, 162 insertions(+), 1 deletion(-) diff --git a/config.go b/config.go index aa9c214..ba4373c 100644 --- a/config.go +++ b/config.go @@ -112,6 +112,9 @@ func (app *appContext) loadConfig() error { app.MustSetValue("telegram", "show_on_reg", "true") + app.MustSetValue("backups", "every_n_minutes", "1440") + app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups")) + app.config.Section("jellyfin").Key("version").SetValue(version) app.config.Section("jellyfin").Key("device").SetValue("jfa-go") app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit)) diff --git a/config/config-base.json b/config/config-base.json index d45dadf..86c3c89 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -1557,6 +1557,49 @@ } } }, + "backups": { + "order": [], + "meta": { + "name": "Backups", + "description": "Settings for database backups." + }, + "settings": { + "enabled": { + "name": "Enabled", + "required": false, + "requires_restart": true, + "type": "bool", + "value": false, + "description": "Enable to generate database backups on a schedule." + }, + "path": { + "name": "Backup Path", + "required": false, + "requires_restart": true, + "type": "text", + "value": "", + "description": "Path to directory to store backups in. defaults to /backups." + }, + "every_n_minutes": { + "name": "Backup frequency (Minutes)", + "required": false, + "requires_restart": true, + "depends_true": "enabled", + "type": "number", + "value": 1440, + "description": "Backup after this many minutes has passed since the last. Resets every restart." + }, + "keep_n_backups": { + "name": "Number of backups to keep", + "required": false, + "requires_restart": true, + "depends_true": "enabled", + "type": "number", + "value": 20, + "description": "Number of most recent backups to keep. Once this is hit, the oldest backup will be deleted before doing a new one." + } + } + }, "welcome_email": { "order": [], "meta": { diff --git a/daemon.go b/daemon.go index 9a6a9d1..e541cab 100644 --- a/daemon.go +++ b/daemon.go @@ -1,6 +1,10 @@ package main import ( + "os" + "path/filepath" + "sort" + "strings" "time" "github.com/dgraph-io/badger/v3" @@ -8,6 +12,12 @@ import ( "github.com/timshannon/badgerhold/v4" ) +const ( + BACKUP_PREFIX = "jfa-go-db-" + BACKUP_DATEFMT = "2006-01-02T15-04-05" + BACKUP_SUFFIX = ".bak" +) + // clearEmails removes stored emails for users which no longer exist. // meant to be called with other such housekeeping functions, so assumes // the user cache is fresh. @@ -74,6 +84,87 @@ func (app *appContext) clearTelegram() { } } +type BackupList struct { + files []os.DirEntry + dates []time.Time +} + +func (bl BackupList) Len() int { return len(bl.files) } +func (bl BackupList) Swap(i, j int) { + bl.files[i], bl.files[j] = bl.files[j], bl.files[i] + bl.dates[i], bl.dates[j] = bl.dates[j], bl.dates[i] +} + +func (bl BackupList) Less(i, j int) bool { + // Push non-backup files to the end of the array, + // Since they didn't have a date parsed. + if bl.dates[i].IsZero() { + return false + } + if bl.dates[j].IsZero() { + return true + } + // Sort by oldest first + return bl.dates[j].After(bl.dates[i]) +} + +func (app *appContext) makeBackup() { + toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20) + fname := BACKUP_PREFIX + time.Now().Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX + path := app.config.Section("backups").Key("path").String() + err := os.MkdirAll(path, 0755) + if err != nil { + app.err.Printf("Failed to create backup directory \"%s\": %v\n", path, err) + return + } + items, err := os.ReadDir(path) + if err != nil { + app.err.Printf("Failed to read backup directory \"%s\": %v\n", path, err) + return + } + backups := BackupList{} + backups.files = items + backups.dates = make([]time.Time, len(items)) + backupCount := 0 + for i, item := range items { + if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) { + continue + } + t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(item.Name(), BACKUP_PREFIX), BACKUP_SUFFIX)) + if err != nil { + app.debug.Printf("Failed to parse backup filename \"%s\": %v\n", item.Name(), err) + continue + } + backups.dates[i] = t + backupCount++ + } + toDelete := backupCount + 1 - toKeep + if toDelete > 0 { + sort.Sort(backups) + for _, item := range backups.files[:toDelete] { + fullpath := filepath.Join(path, item.Name()) + app.debug.Printf("Deleting old backup \"%s\"\n", item.Name()) + err := os.Remove(fullpath) + if err != nil { + app.err.Printf("Failed to delete old backup \"%s\": %v\n", fullpath, err) + return + } + } + } + fullpath := filepath.Join(path, fname) + f, err := os.Create(fullpath) + if err != nil { + app.err.Printf("Failed to open backup file \"%s\": %v\n", fullpath, err) + return + } + defer f.Close() + _, err = app.storage.db.Badger().Backup(f, 0) + if err != nil { + app.err.Printf("Failed to create backup: %v\n", err) + return + } +} + func (app *appContext) clearActivities() { app.debug.Println("Husekeeping: Cleaning up Activity log...") keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000) @@ -116,6 +207,24 @@ type housekeepingDaemon struct { app *appContext } +func newBackupDaemon(app *appContext) *housekeepingDaemon { + interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute + daemon := housekeepingDaemon{ + Stopped: false, + ShutdownChannel: make(chan string), + Interval: interval, + period: interval, + app: app, + } + daemon.jobs = []func(app *appContext){ + func(app *appContext) { + app.debug.Println("Backups: Creating backup") + app.makeBackup() + }, + } + return &daemon +} + func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemon { daemon := housekeepingDaemon{ Stopped: false, diff --git a/main.go b/main.go index 1249d29..69331d3 100644 --- a/main.go +++ b/main.go @@ -475,6 +475,13 @@ func start(asDaemon, firstCall bool) { go app.checkForUpdates() } + var backupDaemon *housekeepingDaemon + if app.config.Section("backups").Key("enabled").MustBool(false) { + backupDaemon = newBackupDaemon(app) + go backupDaemon.run() + defer backupDaemon.Shutdown() + } + if telegramEnabled { app.telegram, err = newTelegramDaemon(app) if err != nil { diff --git a/updater.go b/updater.go index 7df4123..4e08e1e 100644 --- a/updater.go +++ b/updater.go @@ -161,7 +161,6 @@ func newUpdater(buildroneURL, namespace, repo, version, commit, buildType string if runtime.GOOS == "windows" { binary += ".exe" } - fmt.Println("monitoring", tag) return &Updater{ httpClient: &http.Client{Timeout: 10 * time.Second}, timeoutHandler: common.NewTimeoutHandler("updater", buildroneURL, true),