157 lines
3.9 KiB
Go
157 lines
3.9 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const backupDirName = ".backups"
|
|
|
|
// BackupInfo holds metadata about a single backup.
|
|
type BackupInfo struct {
|
|
Version string `json:"version"`
|
|
Timestamp string `json:"timestamp"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// CreateBackup copies the current file into a timestamped backup before modification.
|
|
// Returns the backup version string. If the source file doesn't exist, returns "" with no error.
|
|
func CreateBackup(baseDir, relPath string, maxBackups int) (string, error) {
|
|
srcPath, err := FullPath(baseDir, relPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
|
|
data, err := os.ReadFile(srcPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read source for backup: %w", err)
|
|
}
|
|
|
|
backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath)))
|
|
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
|
return "", fmt.Errorf("create backup dir: %w", err)
|
|
}
|
|
|
|
version := time.Now().UTC().Format("20060102T150405Z")
|
|
base := filepath.Base(relPath)
|
|
ext := filepath.Ext(base)
|
|
name := strings.TrimSuffix(base, ext)
|
|
backupName := fmt.Sprintf("%s.%s%s", name, version, ext)
|
|
backupPath := filepath.Join(backupDir, backupName)
|
|
|
|
if err := AtomicWrite(backupPath, data, 0o644); err != nil {
|
|
return "", fmt.Errorf("write backup: %w", err)
|
|
}
|
|
|
|
if err := pruneBackups(backupDir, name, ext, maxBackups); err != nil {
|
|
return "", fmt.Errorf("prune backups: %w", err)
|
|
}
|
|
|
|
return version, nil
|
|
}
|
|
|
|
// ListBackups returns all backup versions for a given config file, newest first.
|
|
func ListBackups(baseDir, relPath string) ([]BackupInfo, error) {
|
|
if err := ValidatePath(relPath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath)))
|
|
base := filepath.Base(relPath)
|
|
ext := filepath.Ext(base)
|
|
name := strings.TrimSuffix(base, ext)
|
|
prefix := name + "."
|
|
|
|
entries, err := os.ReadDir(backupDir)
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read backup dir: %w", err)
|
|
}
|
|
|
|
var backups []BackupInfo
|
|
for _, e := range entries {
|
|
fname := e.Name()
|
|
if !strings.HasPrefix(fname, prefix) || !strings.HasSuffix(fname, ext) {
|
|
continue
|
|
}
|
|
version := strings.TrimSuffix(strings.TrimPrefix(fname, prefix), ext)
|
|
backups = append(backups, BackupInfo{
|
|
Version: version,
|
|
Timestamp: version,
|
|
Path: filepath.Join(backupDir, fname),
|
|
})
|
|
}
|
|
|
|
sort.Slice(backups, func(i, j int) bool {
|
|
return backups[i].Version > backups[j].Version
|
|
})
|
|
|
|
return backups, nil
|
|
}
|
|
|
|
// Rollback restores a config file to the given backup version.
|
|
func Rollback(baseDir, relPath, version string) error {
|
|
if err := ValidatePath(relPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath)))
|
|
base := filepath.Base(relPath)
|
|
ext := filepath.Ext(base)
|
|
name := strings.TrimSuffix(base, ext)
|
|
backupName := fmt.Sprintf("%s.%s%s", name, version, ext)
|
|
backupPath := filepath.Join(backupDir, backupName)
|
|
|
|
data, err := os.ReadFile(backupPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read backup %q: %w", version, err)
|
|
}
|
|
|
|
targetPath, err := FullPath(baseDir, relPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return AtomicWrite(targetPath, data, 0o644)
|
|
}
|
|
|
|
// pruneBackups removes oldest backups exceeding maxBackups.
|
|
func pruneBackups(backupDir, name, ext string, maxBackups int) error {
|
|
if maxBackups <= 0 {
|
|
return nil
|
|
}
|
|
|
|
prefix := name + "."
|
|
entries, err := os.ReadDir(backupDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var matching []string
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ext) {
|
|
matching = append(matching, e.Name())
|
|
}
|
|
}
|
|
|
|
sort.Strings(matching)
|
|
|
|
if len(matching) > maxBackups {
|
|
for _, old := range matching[:len(matching)-maxBackups] {
|
|
os.Remove(filepath.Join(backupDir, old))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|