diff --git a/man/backup.md b/man/backup.md index 0406c46..fdb690a 100644 --- a/man/backup.md +++ b/man/backup.md @@ -1,77 +1,413 @@ -# Backup Policy (QuoteForge) +# AI Implementation Guide: Go Scheduled Backup Rotation (ZIP) -## Overview -QuoteForge performs automatic backups of local runtime data on a daily schedule. Backups are stored as ZIP archives and rotated per period (daily/weekly/monthly/yearly). +This document is written **for an AI** to replicate the same backup approach in another Go project. It contains the exact requirements, design notes, and full module listings you can copy. -The policy is designed to be easily replicated across installations and user environments without additional dependencies. +## Requirements (Behavioral) +- Run backups on a daily schedule at a configured local time (default `00:00`). +- At startup, if there is no backup for the current period, create it immediately. +- Backup content must include: + - Local SQLite DB file (e.g., `qfs.db`). + - SQLite sidecars (`-wal`, `-shm`) if present. + - Runtime config file (e.g., `config.yaml`) if present. +- Backups must be ZIP archives named: + - `qfs-backp-YYYY-MM-DD.zip` +- Retention policy: + - 7 daily, 4 weekly, 12 monthly, 10 yearly archives. +- Keep backups in period-specific directories: + - `/daily`, `/weekly`, `/monthly`, `/yearly`. +- Prevent duplicate backups for the same period via a marker file. +- Log success with the archive path, and log errors on failure. -## What Gets Backed Up -Each backup archive contains: -- Local SQLite database file (`qfs.db`) -- SQLite sidecar files (`qfs.db-wal`, `qfs.db-shm`) if present -- Runtime config file (`config.yaml`) if present +## Configuration & Env +- Config key: `backup.time` with format `HH:MM` in local time. Default: `00:00`. +- Env overrides: + - `QFS_BACKUP_DIR` — backup root directory. + - `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`). -## Schedule -Backups run once per day at a configured time. -- Config key: `backup.time` -- Default: `00:00` -- Format: `HH:MM` (24-hour local time) +## Integration Steps (Minimal) +1. Add `BackupConfig` to your config struct. +2. Add a scheduler goroutine that: + - On startup: runs backup immediately if needed. + - Then sleeps until next configured time and runs daily. +3. Add the backup module (below). +4. Wire logs for success/failure. -## Storage Location -Default location is next to the local DB: -``` -/backups/ -``` -Period-specific subdirectories: -``` -backups/daily -backups/weekly -backups/monthly -backups/yearly +--- + +# Full Go Listings + +## 1) Backup Module (Drop-in) +Create: `internal/appstate/backup.go` + +```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 +} ``` -Optional override: -- `QFS_BACKUP_DIR` — absolute or relative path to the backup root +--- -## Naming Convention -Backup files are ZIP archives named by creation date: -``` -qfs-backp-YYYY-MM-DD.zip -``` -Each period keeps its own copy under its directory. +## 2) Scheduler Hook (Main) +Add this to your `main.go` (or equivalent). This schedules daily backups and logs success. -## Retention -Rotation keeps a fixed number of archives per period: -- Daily: 7 archives -- Weekly: 4 archives -- Monthly: 12 archives -- Yearly: 10 archives +```go +func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) { + if cfg == nil { + return + } -Older archives beyond these limits are deleted automatically. + hour, minute, err := parseBackupTime(cfg.Backup.Time) + if err != nil { + slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err) + hour = 0 + minute = 0 + } -## Period Dedupe -A marker file stored inside each period directory tracks the last backup key to avoid duplicate backups within the same period. -- File: `.period.json` -- Content: `{ "key": "" }` + // Startup check: if no backup exists for current periods, create now. + if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil { + slog.Error("local backup failed", "error", backupErr) + } else if len(created) > 0 { + for _, path := range created { + slog.Info("local backup completed", "archive", path) + } + } -## Logs -Successful backup creation is logged with the archive path: -``` -local backup completed archive=/path/to/.../qfs-backp-YYYY-MM-DD.zip duration=... + for { + next := nextBackupTime(time.Now(), hour, minute) + timer := time.NewTimer(time.Until(next)) + + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + start := time.Now() + created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath) + duration := time.Since(start) + if backupErr != nil { + slog.Error("local backup failed", "error", backupErr, "duration", duration) + } else { + for _, path := range created { + slog.Info("local backup completed", "archive", path, "duration", duration) + } + } + } + } +} + +func parseBackupTime(value string) (int, int, error) { + if strings.TrimSpace(value) == "" { + return 0, 0, fmt.Errorf("empty backup time") + } + parsed, err := time.Parse("15:04", value) + if err != nil { + return 0, 0, err + } + return parsed.Hour(), parsed.Minute(), nil +} + +func nextBackupTime(now time.Time, hour, minute int) time.Time { + location := now.Location() + target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location) + if !now.Before(target) { + target = target.Add(24 * time.Hour) + } + return target +} ``` -Failures are logged with: -``` -local backup failed error=... duration=... +--- + +## 3) Config Struct (Minimal) +Add to config: + +```go +type BackupConfig struct { + Time string `yaml:"time"` +} ``` -## Disable Backups -Set environment variable: +Default: +```go +if c.Backup.Time == "" { + c.Backup.Time = "00:00" +} ``` -QFS_BACKUP_DISABLE=1 -``` -Accepted values: `1`, `true`, `yes` (case-insensitive). -## Notes -- Backups are performed on startup if the current period has no backup. -- All paths are resolved relative to runtime DB/config paths. +--- + +## Notes for Replication +- Keep `backup.time` in local time. Do **not** parse with timezone offsets unless required. +- The `.period.json` marker is what prevents duplicate backups within the same period. +- The archive file name only contains the date. Uniqueness is ensured by per-period directories and the period marker. +- If you change naming or retention, update both the file naming and prune logic together.