package appstate import ( "archive/zip" "encoding/json" "fmt" "io" "os" "path/filepath" "sort" "strings" "time" "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) 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) if err := validateBackupRoot(root); err != nil { return nil, err } 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 validateBackupRoot(root string) error { absRoot, err := filepath.Abs(root) if err != nil { return fmt.Errorf("resolve backup root: %w", err) } if gitRoot, ok := findGitWorktreeRoot(absRoot); ok { return fmt.Errorf("backup root must stay outside git worktree: %s is inside %s", absRoot, gitRoot) } return nil } func findGitWorktreeRoot(path string) (string, bool) { current := filepath.Clean(path) info, err := os.Stat(current) if err == nil && !info.IsDir() { current = filepath.Dir(current) } for { gitPath := filepath.Join(current, ".git") if _, err := os.Stat(gitPath); err == nil { return current, true } parent := filepath.Dir(current) if parent == current { return "", false } current = parent } } 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 { snapshotPath, cleanup, err := createSQLiteSnapshot(dbPath) if err != nil { return err } defer cleanup() file, err := os.Create(destPath) if err != nil { return err } defer file.Close() zipWriter := zip.NewWriter(file) if err := addZipFileAs(zipWriter, snapshotPath, filepath.Base(dbPath)); err != nil { _ = zipWriter.Close() return err } if strings.TrimSpace(configPath) != "" { _ = addZipOptionalFile(zipWriter, configPath) } if err := zipWriter.Close(); err != nil { return err } return file.Sync() } func createSQLiteSnapshot(dbPath string) (string, func(), error) { tempFile, err := os.CreateTemp("", "qfs-backup-*.db") if err != nil { return "", func() {}, err } tempPath := tempFile.Name() if err := tempFile.Close(); err != nil { _ = os.Remove(tempPath) return "", func() {}, err } if err := os.Remove(tempPath); err != nil && !os.IsNotExist(err) { return "", func() {}, err } cleanup := func() { _ = os.Remove(tempPath) } db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { cleanup() return "", func() {}, err } sqlDB, err := db.DB() if err != nil { cleanup() return "", func() {}, err } defer sqlDB.Close() if err := db.Exec("PRAGMA busy_timeout = 5000").Error; err != nil { cleanup() return "", func() {}, fmt.Errorf("configure sqlite busy_timeout: %w", err) } literalPath := strings.ReplaceAll(tempPath, "'", "''") if err := vacuumIntoWithRetry(db, literalPath); err != nil { cleanup() return "", func() {}, err } return tempPath, cleanup, nil } func vacuumIntoWithRetry(db *gorm.DB, literalPath string) error { var lastErr error for attempt := 0; attempt < 3; attempt++ { if err := db.Exec("VACUUM INTO '" + literalPath + "'").Error; err != nil { lastErr = err if !isSQLiteBusyError(err) { return fmt.Errorf("create sqlite snapshot: %w", err) } time.Sleep(time.Duration(attempt+1) * 250 * time.Millisecond) continue } return nil } return fmt.Errorf("create sqlite snapshot after retries: %w", lastErr) } func isSQLiteBusyError(err error) bool { if err == nil { return false } lower := strings.ToLower(err.Error()) return strings.Contains(lower, "database is locked") || strings.Contains(lower, "database is busy") } 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 { return addZipFileAs(writer, path, filepath.Base(path)) } func addZipFileAs(writer *zip.Writer, path string, archiveName 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 = archiveName header.Method = zip.Deflate out, err := writer.CreateHeader(header) if err != nil { return err } _, err = io.Copy(out, in) return err }