Files
QuoteForge/internal/appstate/backup.go
2026-02-11 19:48:40 +03:00

274 lines
5.8 KiB
Go

package appstate
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type backupPeriod struct {
name string
retention int
key func(time.Time) string
date func(time.Time) string
}
var backupPeriods = []backupPeriod{
{
name: "daily",
retention: 7,
key: func(t time.Time) string {
return t.Format("2006-01-02")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "weekly",
retention: 4,
key: func(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%04d-W%02d", y, w)
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "monthly",
retention: 12,
key: func(t time.Time) string {
return t.Format("2006-01")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "yearly",
retention: 10,
key: func(t time.Time) string {
return t.Format("2006")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
}
const (
envBackupDisable = "QFS_BACKUP_DISABLE"
envBackupDir = "QFS_BACKUP_DIR"
)
var backupNow = time.Now
// EnsureRotatingLocalBackup creates or refreshes daily/weekly/monthly/yearly backups
// for the local database and config. It keeps a limited number per period.
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
if isBackupDisabled() {
return nil, nil
}
if dbPath == "" {
return nil, nil
}
if _, err := os.Stat(dbPath); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("stat db: %w", err)
}
root := resolveBackupRoot(dbPath)
now := backupNow()
created := make([]string, 0)
for _, period := range backupPeriods {
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
if err != nil {
return created, err
}
if len(newFiles) > 0 {
created = append(created, newFiles...)
}
}
return created, nil
}
func resolveBackupRoot(dbPath string) string {
if fromEnv := strings.TrimSpace(os.Getenv(envBackupDir)); fromEnv != "" {
return filepath.Clean(fromEnv)
}
return filepath.Join(filepath.Dir(dbPath), "backups")
}
func isBackupDisabled() bool {
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
return val == "1" || val == "true" || val == "yes"
}
func ensurePeriodBackup(root string, period backupPeriod, now time.Time, dbPath, configPath string) ([]string, error) {
key := period.key(now)
periodDir := filepath.Join(root, period.name)
if err := os.MkdirAll(periodDir, 0755); err != nil {
return nil, fmt.Errorf("create %s backup dir: %w", period.name, err)
}
if hasBackupForKey(periodDir, key) {
return nil, nil
}
archiveName := fmt.Sprintf("qfs-backp-%s.zip", period.date(now))
archivePath := filepath.Join(periodDir, archiveName)
if err := createBackupArchive(archivePath, dbPath, configPath); err != nil {
return nil, fmt.Errorf("create %s backup archive: %w", period.name, err)
}
if err := writePeriodMarker(periodDir, key); err != nil {
return []string{archivePath}, err
}
if err := pruneOldBackups(periodDir, period.retention); err != nil {
return []string{archivePath}, err
}
return []string{archivePath}, nil
}
func hasBackupForKey(periodDir, key string) bool {
marker := periodMarker{Key: ""}
data, err := os.ReadFile(periodMarkerPath(periodDir))
if err != nil {
return false
}
if err := json.Unmarshal(data, &marker); err != nil {
return false
}
return marker.Key == key
}
type periodMarker struct {
Key string `json:"key"`
}
func periodMarkerPath(periodDir string) string {
return filepath.Join(periodDir, ".period.json")
}
func writePeriodMarker(periodDir, key string) error {
data, err := json.MarshalIndent(periodMarker{Key: key}, "", " ")
if err != nil {
return err
}
return os.WriteFile(periodMarkerPath(periodDir), data, 0644)
}
func pruneOldBackups(periodDir string, keep int) error {
entries, err := os.ReadDir(periodDir)
if err != nil {
return fmt.Errorf("read backups dir: %w", err)
}
files := make([]os.DirEntry, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
if strings.HasSuffix(entry.Name(), ".zip") {
files = append(files, entry)
}
}
if len(files) <= keep {
return nil
}
sort.Slice(files, func(i, j int) bool {
infoI, errI := files[i].Info()
infoJ, errJ := files[j].Info()
if errI != nil || errJ != nil {
return files[i].Name() < files[j].Name()
}
return infoI.ModTime().Before(infoJ.ModTime())
})
for i := 0; i < len(files)-keep; i++ {
path := filepath.Join(periodDir, files[i].Name())
if err := os.Remove(path); err != nil {
return fmt.Errorf("remove old backup %s: %w", path, err)
}
}
return nil
}
func createBackupArchive(destPath, dbPath, configPath string) error {
file, err := os.Create(destPath)
if err != nil {
return err
}
defer file.Close()
zipWriter := zip.NewWriter(file)
if err := addZipFile(zipWriter, dbPath); err != nil {
_ = zipWriter.Close()
return err
}
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
if strings.TrimSpace(configPath) != "" {
_ = addZipOptionalFile(zipWriter, configPath)
}
if err := zipWriter.Close(); err != nil {
return err
}
return file.Sync()
}
func addZipOptionalFile(writer *zip.Writer, path string) error {
if _, err := os.Stat(path); err != nil {
return nil
}
return addZipFile(writer, path)
}
func addZipFile(writer *zip.Writer, path string) error {
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = filepath.Base(path)
header.Method = zip.Deflate
out, err := writer.CreateHeader(header)
if err != nil {
return err
}
_, err = io.Copy(out, in)
return err
}