feat: docker compose backup
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DirectoryCopier copies a directory tree with optional exclusions.
|
||||
type DirectoryCopier struct {
|
||||
Exclude []string
|
||||
Context context.Context // optional; checked during copy
|
||||
}
|
||||
|
||||
// Copy recursively copies src to dst, skipping paths that match any
|
||||
// exclusion pattern (filepath.Match semantics). Skips special files
|
||||
// (sockets, FIFOs, devices) that cannot be meaningfully copied.
|
||||
func (c *DirectoryCopier) Copy(src, dst string) error {
|
||||
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Context != nil {
|
||||
select {
|
||||
case <-c.Context.Done():
|
||||
return c.Context.Err()
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rel(%s, %s): %w", src, path, err)
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, pattern := range c.Exclude {
|
||||
matched, err := filepath.Match(pattern, rel)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
baseMatched, _ := filepath.Match(pattern, d.Name())
|
||||
if matched || baseMatched {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
return copyDirMetadata(path, target, d)
|
||||
}
|
||||
|
||||
if isSpecial(d) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return copyFile(path, target, d)
|
||||
})
|
||||
}
|
||||
|
||||
func copyDirMetadata(src, dst string, entry fs.DirEntry) error {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dst, info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(dst, info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chtimes(dst, info.ModTime(), info.ModTime())
|
||||
}
|
||||
|
||||
// isSpecial returns true if the entry is a socket, FIFO, device, or other
|
||||
// non-regular, non-symlink, non-directory file.
|
||||
func isSpecial(d fs.DirEntry) bool {
|
||||
if d.Type().IsRegular() || d.Type()&fs.ModeSymlink != 0 {
|
||||
return false
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return true // can't stat → skip it
|
||||
}
|
||||
mode := info.Mode()
|
||||
return mode&(os.ModeSocket|os.ModeNamedPipe|os.ModeDevice|os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, entry fs.DirEntry) error {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
link, err := os.Readlink(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(dst)
|
||||
return os.Symlink(link, dst)
|
||||
}
|
||||
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, copyErr := io.Copy(dstFile, srcFile)
|
||||
closeErr := dstFile.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if err := os.Chmod(dst, info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chtimes(dst, info.ModTime(), info.ModTime())
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.misaka.ren/M1saka/docker_backup/internal/config"
|
||||
)
|
||||
|
||||
// Result holds the outcome of backing up a single project.
|
||||
type Result struct {
|
||||
ProjectName string
|
||||
Error error
|
||||
BackupPath string
|
||||
FileSize int64
|
||||
Downtime time.Duration
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// Engine orchestrates backup of Docker Compose projects.
|
||||
type Engine struct {
|
||||
BackupDir string
|
||||
TempDir string
|
||||
DryRun bool
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewEngine creates a new backup engine.
|
||||
func NewEngine(backupDir, tempDir string, dryRun bool, logger *slog.Logger) *Engine {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Engine{
|
||||
BackupDir: backupDir,
|
||||
TempDir: tempDir,
|
||||
DryRun: dryRun,
|
||||
Logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Run backs up all configured projects. Results are returned in order.
|
||||
func (e *Engine) Run(ctx context.Context, projects []config.ProjectConfig) []Result {
|
||||
results := make([]Result, 0, len(projects))
|
||||
for _, proj := range projects {
|
||||
results = append(results, e.backupOne(ctx, proj))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (e *Engine) backupOne(ctx context.Context, proj config.ProjectConfig) Result {
|
||||
start := time.Now()
|
||||
result := Result{ProjectName: proj.Name}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Error = err
|
||||
result.Duration = time.Since(start)
|
||||
return result
|
||||
}
|
||||
|
||||
e.Logger.Info("starting backup", "project", proj.Name, "path", proj.Path)
|
||||
|
||||
composeFilePath := filepath.Join(proj.Path, proj.ComposeFile)
|
||||
if _, err := os.Stat(composeFilePath); err != nil && !e.DryRun {
|
||||
result.Error = fmt.Errorf("compose file not found: %s: %w", composeFilePath, err)
|
||||
result.Duration = time.Since(start)
|
||||
return result
|
||||
}
|
||||
|
||||
backupPath := e.backupPath(proj.Name)
|
||||
if e.DryRun {
|
||||
result.BackupPath = backupPath
|
||||
result.Duration = time.Since(start)
|
||||
e.Logger.Info("dry-run: backup plan complete", "project", proj.Name, "file", backupPath)
|
||||
return result
|
||||
}
|
||||
|
||||
executor := NewComposeExecutor(proj.Path, proj.ComposeFile)
|
||||
|
||||
// 1. Stop the compose project.
|
||||
stopStart := time.Now()
|
||||
if err := executor.Stop(ctx); err != nil {
|
||||
result.Error = fmt.Errorf("stop: %w", err)
|
||||
result.Duration = time.Since(start)
|
||||
return result
|
||||
}
|
||||
e.Logger.Info("compose stopped", "project", proj.Name)
|
||||
|
||||
// 2. Create a staging directory.
|
||||
stagingDir, err := os.MkdirTemp(e.TempDir, "dc-backup-"+proj.Name+"-*")
|
||||
if err != nil {
|
||||
_ = restartCompose(executor)
|
||||
result.Error = fmt.Errorf("create staging dir: %w", err)
|
||||
result.Duration = time.Since(start)
|
||||
return result
|
||||
}
|
||||
|
||||
// 3. Copy project directory to staging.
|
||||
copier := &DirectoryCopier{Exclude: proj.Exclude, Context: ctx}
|
||||
if err := copier.Copy(proj.Path, stagingDir); err != nil {
|
||||
_ = os.RemoveAll(stagingDir)
|
||||
_ = restartCompose(executor)
|
||||
result.Error = fmt.Errorf("copy: %w", err)
|
||||
result.Duration = time.Since(start)
|
||||
return result
|
||||
}
|
||||
e.Logger.Info("files copied to staging", "project", proj.Name, "staging", stagingDir)
|
||||
|
||||
// 4. Immediately restart the compose project.
|
||||
if err := restartCompose(executor); err != nil {
|
||||
_ = os.RemoveAll(stagingDir)
|
||||
result.Error = fmt.Errorf("start: %w", err)
|
||||
result.Duration = time.Since(start)
|
||||
return result
|
||||
}
|
||||
result.Downtime = time.Since(stopStart)
|
||||
e.Logger.Info("compose restarted", "project", proj.Name, "downtime", result.Downtime)
|
||||
|
||||
// 5. Compress staging directory to the backup destination.
|
||||
backupDir := filepath.Dir(backupPath)
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
_ = os.RemoveAll(stagingDir)
|
||||
result.Error = fmt.Errorf("create backup dir: %w", err)
|
||||
result.Duration = time.Since(start)
|
||||
return result
|
||||
}
|
||||
|
||||
if err := createTarGz(ctx, stagingDir, backupPath); err != nil {
|
||||
_ = os.RemoveAll(stagingDir)
|
||||
result.Error = fmt.Errorf("compress: %w", err)
|
||||
result.Duration = time.Since(start)
|
||||
return result
|
||||
}
|
||||
e.Logger.Info("backup compressed", "project", proj.Name, "file", backupPath)
|
||||
|
||||
// 6. Clean up staging.
|
||||
_ = os.RemoveAll(stagingDir)
|
||||
|
||||
// 7. Record file size.
|
||||
if fi, err := os.Stat(backupPath); err == nil {
|
||||
result.FileSize = fi.Size()
|
||||
}
|
||||
|
||||
result.BackupPath = backupPath
|
||||
result.Duration = time.Since(start)
|
||||
e.Logger.Info("backup complete", "project", proj.Name, "duration", result.Duration, "size", result.FileSize)
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *Engine) backupPath(projectName string) string {
|
||||
timestamp := time.Now().Format("20060102-150405.000000000")
|
||||
backupFile := fmt.Sprintf("%s-%s.tar.gz", projectName, timestamp)
|
||||
return filepath.Join(e.BackupDir, projectName, backupFile)
|
||||
}
|
||||
|
||||
func restartCompose(executor *ComposeExecutor) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
return executor.Start(ctx)
|
||||
}
|
||||
|
||||
// createTarGz creates a tar.gz archive from a source directory.
|
||||
func createTarGz(ctx context.Context, srcDir, dstPath string) error {
|
||||
f, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gw := gzip.NewWriter(f)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
walkErr := filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rel: %w", err)
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
linkTarget := ""
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
linkTarget, err = os.Readlink(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(info, linkTarget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = filepath.ToSlash(rel)
|
||||
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() || !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return copyFileToTar(tw, path)
|
||||
})
|
||||
|
||||
closeErr := closeTarGz(tw, gw, f)
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func closeTarGz(tw *tar.Writer, gw *gzip.Writer, f *os.File) error {
|
||||
if err := tw.Close(); err != nil {
|
||||
_ = gw.Close()
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("close tar: %w", err)
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("close gzip: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("close file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFileToTar opens a file and copies it into the tar writer.
|
||||
// The file is closed before returning — no deferred leak in a loop.
|
||||
func copyFileToTar(tw *tar.Writer, path string) error {
|
||||
srcFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
_, err = io.Copy(tw, srcFile)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// ComposeExecutor runs docker compose commands.
|
||||
type ComposeExecutor struct {
|
||||
ProjectPath string
|
||||
ComposeFile string
|
||||
}
|
||||
|
||||
// NewComposeExecutor creates an executor for a project.
|
||||
func NewComposeExecutor(projectPath, composeFile string) *ComposeExecutor {
|
||||
return &ComposeExecutor{
|
||||
ProjectPath: projectPath,
|
||||
ComposeFile: composeFile,
|
||||
}
|
||||
}
|
||||
|
||||
// Stop runs `docker compose -f <file> stop`.
|
||||
func (e *ComposeExecutor) Stop(ctx context.Context) error {
|
||||
args := []string{"compose", "-f", e.ComposeFile, "stop"}
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd.Dir = e.ProjectPath
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("docker compose stop: %w\n%s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start runs `docker compose -f <file> up -d`.
|
||||
func (e *ComposeExecutor) Start(ctx context.Context) error {
|
||||
args := []string{"compose", "-f", e.ComposeFile, "up", "-d"}
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd.Dir = e.ProjectPath
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("docker compose up -d: %w\n%s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDockerAvailable checks whether docker is on PATH.
|
||||
func IsDockerAvailable() bool {
|
||||
_, err := exec.LookPath("docker")
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package backup
|
||||
|
||||
import "fmt"
|
||||
|
||||
// FormatSize formats a byte count as a human-readable string.
|
||||
func FormatSize(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for b := n / unit; b >= unit; b /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RetentionPolicy defines how many backups to keep.
|
||||
type RetentionPolicy struct {
|
||||
Count int // keep at most N most recent backups
|
||||
Days int // also delete backups older than N days (0 = disabled)
|
||||
}
|
||||
|
||||
// ApplyRetention removes old backups from the project's backup directory.
|
||||
// Returns the number of files removed.
|
||||
func ApplyRetention(backupDir, projectName string, policy RetentionPolicy) (int, error) {
|
||||
dir := filepath.Join(backupDir, projectName)
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
type fileInfo struct {
|
||||
name string
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
var backups []fileInfo
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !isBackupArchive(projectName, entry.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
backups = append(backups, fileInfo{
|
||||
name: entry.Name(),
|
||||
modTime: info.ModTime(),
|
||||
})
|
||||
}
|
||||
|
||||
if len(backups) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Sort newest-first by modification time.
|
||||
sort.Slice(backups, func(i, j int) bool {
|
||||
return backups[i].modTime.After(backups[j].modTime)
|
||||
})
|
||||
|
||||
removed := 0
|
||||
|
||||
// Enforce count-based retention.
|
||||
if policy.Count > 0 && len(backups) > policy.Count {
|
||||
for _, b := range backups[policy.Count:] {
|
||||
path := filepath.Join(dir, b.name)
|
||||
if err := os.Remove(path); err == nil {
|
||||
removed++
|
||||
}
|
||||
}
|
||||
// Trim the list for the next check.
|
||||
backups = backups[:policy.Count]
|
||||
}
|
||||
|
||||
// Enforce age-based retention.
|
||||
if policy.Days > 0 {
|
||||
cutoff := time.Now().AddDate(0, 0, -policy.Days)
|
||||
for _, b := range backups {
|
||||
if b.modTime.Before(cutoff) {
|
||||
path := filepath.Join(dir, b.name)
|
||||
if err := os.Remove(path); err == nil {
|
||||
removed++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func isBackupArchive(projectName, name string) bool {
|
||||
return strings.HasPrefix(name, projectName+"-") && strings.HasSuffix(name, ".tar.gz")
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestApplyRetentionByCount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
projDir := filepath.Join(dir, "testapp")
|
||||
os.MkdirAll(projDir, 0755)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
name := "testapp-20250101-00000" + string(rune('1'+i)) + ".tar.gz"
|
||||
f, err := os.Create(filepath.Join(projDir, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
mt := time.Now().Add(-time.Duration(5-i) * time.Hour)
|
||||
os.Chtimes(f.Name(), mt, mt)
|
||||
}
|
||||
|
||||
removed, err := ApplyRetention(dir, "testapp", RetentionPolicy{Count: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 2 {
|
||||
t.Errorf("expected 2 removed, got %d", removed)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(projDir)
|
||||
if len(entries) != 3 {
|
||||
t.Errorf("expected 3 remaining, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRetentionByDays(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
projDir := filepath.Join(dir, "testapp")
|
||||
os.MkdirAll(projDir, 0755)
|
||||
|
||||
oldTime := time.Now().AddDate(0, 0, -10)
|
||||
recentTime := time.Now().Add(-1 * time.Hour)
|
||||
|
||||
f1, _ := os.Create(filepath.Join(projDir, "testapp-old.tar.gz"))
|
||||
f1.Close()
|
||||
os.Chtimes(f1.Name(), oldTime, oldTime)
|
||||
|
||||
f2, _ := os.Create(filepath.Join(projDir, "testapp-recent.tar.gz"))
|
||||
f2.Close()
|
||||
os.Chtimes(f2.Name(), recentTime, recentTime)
|
||||
|
||||
removed, err := ApplyRetention(dir, "testapp", RetentionPolicy{Count: 0, Days: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Errorf("expected 1 removed, got %d", removed)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(projDir)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("expected 1 remaining, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRetentionIgnoresNonBackupFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
projDir := filepath.Join(dir, "testapp")
|
||||
os.MkdirAll(projDir, 0755)
|
||||
|
||||
keepNames := []string{"README.txt", "otherapp-20250101.tar.gz", "testapp-note.txt"}
|
||||
for _, name := range keepNames {
|
||||
f, err := os.Create(filepath.Join(projDir, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
name := "testapp-20250101-00000" + string(rune('1'+i)) + ".tar.gz"
|
||||
f, err := os.Create(filepath.Join(projDir, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
mt := time.Now().Add(-time.Duration(3-i) * time.Hour)
|
||||
os.Chtimes(f.Name(), mt, mt)
|
||||
}
|
||||
|
||||
removed, err := ApplyRetention(dir, "testapp", RetentionPolicy{Count: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 2 {
|
||||
t.Errorf("expected 2 removed, got %d", removed)
|
||||
}
|
||||
|
||||
for _, name := range keepNames {
|
||||
if _, err := os.Stat(filepath.Join(projDir, name)); err != nil {
|
||||
t.Fatalf("non-backup file %s should remain: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRetentionEmptyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
removed, err := ApplyRetention(dir, "nonexistent", RetentionPolicy{Count: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 0 {
|
||||
t.Errorf("expected 0 removed, got %d", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRetentionSortOrder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
projDir := filepath.Join(dir, "testapp")
|
||||
os.MkdirAll(projDir, 0755)
|
||||
|
||||
times := []time.Time{
|
||||
time.Now().Add(-5 * time.Hour),
|
||||
time.Now().Add(-1 * time.Hour),
|
||||
time.Now().Add(-3 * time.Hour),
|
||||
}
|
||||
|
||||
for i, mt := range times {
|
||||
name := "testapp-2025010" + string(rune('1'+i)) + "-000001.tar.gz"
|
||||
f, _ := os.Create(filepath.Join(projDir, name))
|
||||
f.Close()
|
||||
os.Chtimes(f.Name(), mt, mt)
|
||||
}
|
||||
|
||||
removed, err := ApplyRetention(dir, "testapp", RetentionPolicy{Count: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Fatalf("expected 1 removed, got %d", removed)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(projDir)
|
||||
names := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
names[i] = e.Name()
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
expected := []string{"testapp-20250102-000001.tar.gz", "testapp-20250103-000001.tar.gz"}
|
||||
if len(names) != len(expected) {
|
||||
t.Fatalf("expected %v, got %v", expected, names)
|
||||
}
|
||||
for i := range expected {
|
||||
if expected[i] != names[i] {
|
||||
t.Errorf("expected[%d]=%s, got %s", i, expected[i], names[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user