Files
jfa-go/backups.go
Harvey Tindall 60dbfa2d1e messages: custom content described in customcontent.go, message tests
customcontent.go constains a structure with all the custom content,
methods for getting display names, subjects, etc., and a list of
variables, conditionals, and placeholder values. Tests for constructX
methods included in email_test.go, and all jfa-go tests can be run with
make INTERNAL=off test.
2025-08-30 14:21:26 +01:00

306 lines
8.2 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
lm "github.com/hrfee/jfa-go/logmessages"
)
const (
BACKUP_PREFIX = "jfa-go-db"
BACKUP_PREFIX_OLD = "jfa-go-db-"
BACKUP_COMMIT_PREFIX = "-c-"
BACKUP_DATE_PREFIX = "-d-"
BACKUP_UPLOAD_PREFIX = "upload-"
BACKUP_DATEFMT = "2006-01-02T15-04-05"
BACKUP_SUFFIX = ".bak"
)
type Backup struct {
Date time.Time
Commit string
Upload bool
}
func (b Backup) IsZero() bool { return b.Date.IsZero() && b.Commit == "" && b.Upload == false }
func (b Backup) Equals(a Backup) bool {
return a.Date.Equal(b.Date) && a.Commit == b.Commit && a.Upload == b.Upload
}
// Pre 21/03/25 format: "{BACKUP_PREFIX_OLD}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak"
// Post 21/03/25 format: "{BACKUP_PREFIX}-c-{commit}-d-{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-c-0b92060-d-2006-01-02T15-04-05.bak"
func (b Backup) String() string {
t := b.Date
if t.IsZero() {
t = time.Now()
}
out := BACKUP_PREFIX
if b.Upload {
out = BACKUP_UPLOAD_PREFIX + out
}
if b.Commit != "" {
out += BACKUP_COMMIT_PREFIX + b.Commit
}
out += BACKUP_DATE_PREFIX + t.Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX
return out
}
func (b *Backup) FromString(f string) error {
of := f
if strings.HasPrefix(f, BACKUP_UPLOAD_PREFIX) {
b.Upload = true
f = f[len(BACKUP_UPLOAD_PREFIX):]
}
if !strings.HasPrefix(f, BACKUP_PREFIX) {
return fmt.Errorf("file doesn't have correct prefix (\"%s\")", BACKUP_PREFIX)
}
f = f[len(BACKUP_PREFIX):]
if !strings.HasSuffix(f, BACKUP_SUFFIX) {
return fmt.Errorf("file doesn't have correct suffix (\"%s\")", BACKUP_SUFFIX)
}
for range 2 {
if strings.HasPrefix(f, BACKUP_COMMIT_PREFIX) {
f = f[len(BACKUP_COMMIT_PREFIX):]
commitEnd := strings.Index(f, BACKUP_DATE_PREFIX)
if commitEnd == -1 {
commitEnd = strings.Index(f, BACKUP_SUFFIX)
}
if commitEnd == -1 {
return fmt.Errorf("end of commit (\"%s\" or \"%s\") not found in \"%s\"", BACKUP_DATE_PREFIX, BACKUP_PREFIX, f)
}
b.Commit = f[:commitEnd]
f = f[commitEnd:]
} else if strings.HasPrefix(f, BACKUP_DATE_PREFIX) {
f = f[len(BACKUP_DATE_PREFIX):]
dateEnd := strings.Index(f, BACKUP_COMMIT_PREFIX)
if dateEnd == -1 {
dateEnd = strings.Index(f, BACKUP_SUFFIX)
}
if dateEnd == -1 {
return fmt.Errorf("end of date (\"%s\" or \"%s\") not found in \"%s\"", BACKUP_COMMIT_PREFIX, BACKUP_PREFIX, f)
}
t, err := time.Parse(BACKUP_DATEFMT, f[:dateEnd])
if err != nil {
return err
}
b.Date = t
f = f[dateEnd:]
}
}
if b.Date.IsZero() {
return b.FromOldString(of)
}
return nil
}
func (b *Backup) FromOldString(f string) error {
t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(f, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX+"-"), BACKUP_SUFFIX))
if err != nil {
return fmt.Errorf(lm.FailedParseTime, err)
}
b.Date = t
return nil
}
type BackupList struct {
files []os.DirEntry
info []Backup
count int
}
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.info[i], bl.info[j] = bl.info[j], bl.info[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.info[i].Date.IsZero() {
return false
}
if bl.info[j].Date.IsZero() {
return true
}
// Sort by oldest first
return bl.info[j].Date.After(bl.info[i].Date)
}
// Get human-readable file size from f.Size() result.
// https://programming.guide/go/formatting-byte-size-to-human-readable-format.html
func fileSize(l int64) string {
const unit = 1000
if l < unit {
return fmt.Sprintf("%dB", l)
}
div, exp := int64(unit), 0
for n := l / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%c", float64(l)/float64(div), "KMGTPE"[exp])
}
func (app *appContext) getBackups() *BackupList {
path := app.config.Section("backups").Key("path").String()
err := os.MkdirAll(path, 0755)
if err != nil {
app.err.Printf(lm.FailedCreateDir, path, err)
return nil
}
items, err := os.ReadDir(path)
if err != nil {
app.err.Printf(lm.FailedReading, path, err)
return nil
}
backups := &BackupList{}
backups.files = items
backups.info = make([]Backup, len(items))
backups.count = 0
for i, item := range items {
// Even though Backup{} can parse and check validity, still check if the file ends in .bak, we don't need to print an error if a file isn't a .bak.
if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) {
continue
}
b := Backup{}
if err := b.FromString(item.Name()); err != nil {
app.debug.Printf(lm.FailedParseBackup, item.Name(), err)
continue
}
backups.info[i] = b
backups.count++
}
return backups
}
func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) {
toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20)
keepPreviousVersions := app.config.Section("backups").Key("keep_previous_version_backup").MustBool(true)
b := Backup{Commit: commit}
fname := b.String()
path := app.config.Section("backups").Key("path").String()
backups := app.getBackups()
if backups == nil {
return
}
toDelete := backups.count + 1 - toKeep
if toDelete > 0 || keepPreviousVersions {
sort.Sort(backups)
}
backupsByCommit := map[string]int{}
if keepPreviousVersions {
// Count backups by commit
for _, b := range backups.info {
if b.IsZero() {
continue
}
// If b.Commit is empty, the backup is pre-versions-in-backup-names.
// Still use the empty string as a key, considering these as a single version.
count, ok := backupsByCommit[b.Commit]
if !ok {
count = 0
}
count += 1
backupsByCommit[b.Commit] = count
}
fmt.Printf("remaining:%+v\n", backupsByCommit)
}
// fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files))
if toDelete > 0 && toDelete <= backups.count {
for i := range toDelete {
backupsRemaining, ok := backupsByCommit[backups.info[i].Commit]
app.debug.Println("item", backups.files[i], "remaining", backupsRemaining)
if keepPreviousVersions && ok && backupsRemaining <= 1 {
continue
}
item := backups.files[i]
fullpath := filepath.Join(path, item.Name())
err := os.Remove(fullpath)
if err != nil {
app.err.Printf(lm.FailedDeleteOldBackup, fullpath, err)
return
}
app.debug.Printf(lm.DeleteOldBackup, fullpath)
if keepPreviousVersions && ok {
backupsRemaining -= 1
backupsByCommit[backups.info[i].Commit] = backupsRemaining
}
}
}
fullpath := filepath.Join(path, fname)
f, err := os.Create(fullpath)
if err != nil {
app.err.Printf(lm.FailedOpen, fullpath, err)
return
}
defer f.Close()
_, err = app.storage.db.Badger().Backup(f, 0)
if err != nil {
app.err.Printf(lm.FailedCreateBackup, err)
return
}
fstat, err := f.Stat()
if err != nil {
app.err.Printf(lm.FailedStat, fullpath, err)
return
}
fileDetails.Size = fileSize(fstat.Size())
fileDetails.Name = fname
fileDetails.Path = fullpath
app.debug.Printf(lm.CreateBackup, fileDetails)
return
}
func (app *appContext) loadPendingBackup() {
if LOADBAK == "" {
return
}
oldPath := filepath.Join(app.dataPath, "db-"+strconv.FormatInt(time.Now().Unix(), 10)+"-pre-"+filepath.Base(LOADBAK))
err := os.Rename(app.storage.db_path, oldPath)
if err != nil {
app.err.Fatalf(lm.FailedMoveOldDB, oldPath, err)
}
app.info.Printf(lm.MoveOldDB, oldPath)
if err := app.storage.Connect(app.config); err != nil {
app.err.Fatalf(lm.FailedConnectDB, app.storage.db_path, err)
}
defer app.storage.Close()
f, err := os.Open(LOADBAK)
if err != nil {
app.err.Fatalf(lm.FailedOpen, LOADBAK, err)
}
err = app.storage.db.Badger().Load(f, 256)
f.Close()
if err != nil {
app.err.Fatalf(lm.FailedRestoreDB, LOADBAK, err)
}
app.info.Printf(lm.RestoreDB, LOADBAK)
LOADBAK = ""
}
func newBackupDaemon(app *appContext) *GenericDaemon {
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.makeBackup()
},
)
d.Name("Backup")
return d
}