Gitfed
bastien-mrq/gitfed / cmd / gitfed-ctl / backup.go
package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"time"
)

// defaultBackupKeep matches deploy/backup.sh's own default — kept the same
// so the two tools' pruning behavior doesn't quietly diverge.
const defaultBackupKeep = 14

// runBackupNow is the local (no ssh) equivalent of deploy/backup.sh's core
// snapshot: stream a tar of the whole /data volume out of the running
// server container, write it atomically (a .part file renamed on success,
// so a crash mid-stream never leaves a truncated backup mistaken for a
// good one), then prune down to defaultBackupKeep. It's a live, crash-
// consistent copy (bbolt tolerates this), not a maintenance-window
// snapshot — same caveat deploy/backup.sh's own header documents.
func runBackupNow(ctx context.Context, sourceDir string) (path string, err error) {
	backupDir := filepath.Join(sourceDir, "backups")
	if err := os.MkdirAll(backupDir, 0755); err != nil {
		return "", err
	}

	timestamp := time.Now().UTC().Format("20060102T150405Z")
	out := filepath.Join(backupDir, fmt.Sprintf("gitfed-%s.tar.gz", timestamp))
	tmp := out + ".part"

	f, err := os.Create(tmp)
	if err != nil {
		return "", err
	}

	cmdErr := runToFile(ctx, f, "kubectl", "-n", "gitfed", "exec", "deployment/gitfed", "-c", "server",
		"--", "tar", "czf", "-", "-C", "/data", ".")
	closeErr := f.Close()
	if cmdErr != nil {
		os.Remove(tmp)
		return "", cmdErr
	}
	if closeErr != nil {
		os.Remove(tmp)
		return "", closeErr
	}

	if err := os.Rename(tmp, out); err != nil {
		return "", err
	}

	if err := pruneBackups(sourceDir, defaultBackupKeep); err != nil {
		// A pruning failure shouldn't fail the backup that already
		// succeeded — the disk just has more history than intended.
		return out, nil
	}
	return out, nil
}

// pruneBackups keeps the keep most recent backups/gitfed-*.tar.gz files
// (by modification time) and removes the rest — same "keep N most recent"
// behavior as deploy/backup.sh's `ls -1t | tail -n +N+1 | xargs rm`.
func pruneBackups(sourceDir string, keep int) error {
	matches, err := filepath.Glob(filepath.Join(sourceDir, "backups", "gitfed-*.tar.gz"))
	if err != nil {
		return err
	}
	if len(matches) <= keep {
		return nil
	}

	type fileTime struct {
		path string
		mod  time.Time
	}
	files := make([]fileTime, 0, len(matches))
	for _, m := range matches {
		info, err := os.Stat(m)
		if err != nil {
			continue
		}
		files = append(files, fileTime{path: m, mod: info.ModTime()})
	}
	sort.Slice(files, func(i, j int) bool { return files[i].mod.After(files[j].mod) })

	for _, f := range files[keep:] {
		if err := os.Remove(f.path); err != nil {
			return err
		}
	}
	return nil
}