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 }