414 lines
9.9 KiB
Markdown
414 lines
9.9 KiB
Markdown
# AI Implementation Guide: Go Scheduled Backup Rotation (ZIP)
|
|
|
|
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.
|
|
|
|
## 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:
|
|
- `<backup root>/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.
|
|
|
|
## 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`).
|
|
|
|
## 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.
|
|
|
|
---
|
|
|
|
# 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
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 2) Scheduler Hook (Main)
|
|
Add this to your `main.go` (or equivalent). This schedules daily backups and logs success.
|
|
|
|
```go
|
|
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
|
|
if cfg == nil {
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3) Config Struct (Minimal)
|
|
Add to config:
|
|
|
|
```go
|
|
type BackupConfig struct {
|
|
Time string `yaml:"time"`
|
|
}
|
|
```
|
|
|
|
Default:
|
|
```go
|
|
if c.Backup.Time == "" {
|
|
c.Backup.Time = "00:00"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 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.
|