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 }