Files
sanity/internal/backup/backup.go
todor ac836d0b67
All checks were successful
Build sanity / build (push) Successful in 11s
init
2026-05-31 18:00:20 +02:00

166 lines
3.9 KiB
Go

package backup
import (
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"
"sanity/internal/config"
)
type Executor struct{}
type Result struct {
Timestamp time.Time
SourceFile string
ArtifactPath string
SizeBytes int64
TotalBackupDirSizeBytes int64
Duration time.Duration
}
func NewExecutor() *Executor {
return &Executor{}
}
func (e *Executor) Run(ctx context.Context, cfg config.Config) (Result, error) {
start := time.Now().UTC()
if err := ctx.Err(); err != nil {
return Result{}, err
}
sourceInfo, err := os.Stat(cfg.Backup.SourceFile)
if err != nil {
return Result{}, fmt.Errorf("stat source file: %w", err)
}
if sourceInfo.IsDir() {
return Result{}, fmt.Errorf("source_file must be a file, got directory")
}
if err := os.MkdirAll(cfg.Backup.OutputDir, 0o755); err != nil {
return Result{}, fmt.Errorf("create output_dir: %w", err)
}
artifactName := fmt.Sprintf("%s-%s.gz", cfg.Backup.FilePrefix, start.Format("20060102-150405"))
artifactPath := filepath.Join(cfg.Backup.OutputDir, artifactName)
tempPath := artifactPath + ".tmp"
result, err := compressFile(ctx, cfg.Backup.SourceFile, tempPath)
if err != nil {
_ = os.Remove(tempPath)
return Result{}, err
}
if err := os.Rename(tempPath, artifactPath); err != nil {
_ = os.Remove(tempPath)
return Result{}, fmt.Errorf("finalize artifact: %w", err)
}
result.Timestamp = start
result.SourceFile = cfg.Backup.SourceFile
result.ArtifactPath = artifactPath
dirSize, dirSizeErr := DirectorySize(cfg.Backup.OutputDir)
if dirSizeErr != nil {
result.TotalBackupDirSizeBytes = -1
} else {
result.TotalBackupDirSizeBytes = dirSize
}
result.Duration = time.Since(start)
return result, nil
}
func DirectorySize(root string) (int64, error) {
var total int64
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
total += info.Size()
return nil
})
if err != nil {
return 0, err
}
return total, nil
}
func compressFile(ctx context.Context, sourcePath string, tempPath string) (Result, error) {
source, err := os.Open(sourcePath)
if err != nil {
return Result{}, fmt.Errorf("open source_file: %w", err)
}
defer source.Close()
target, err := os.OpenFile(tempPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return Result{}, fmt.Errorf("create temp artifact: %w", err)
}
gz, err := gzip.NewWriterLevel(target, gzip.BestCompression)
if err != nil {
target.Close()
return Result{}, fmt.Errorf("create gzip writer: %w", err)
}
copyBuffer := make([]byte, 1024*1024)
written, copyErr := copyWithContext(ctx, gz, source, copyBuffer)
closeErr := gz.Close()
syncErr := target.Sync()
fileCloseErr := target.Close()
if copyErr != nil {
return Result{}, copyErr
}
if closeErr != nil {
return Result{}, fmt.Errorf("close gzip writer: %w", closeErr)
}
if syncErr != nil {
return Result{}, fmt.Errorf("sync temp artifact: %w", syncErr)
}
if fileCloseErr != nil {
return Result{}, fmt.Errorf("close temp artifact: %w", fileCloseErr)
}
return Result{SizeBytes: written}, nil
}
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader, buffer []byte) (int64, error) {
var total int64
for {
if err := ctx.Err(); err != nil {
return total, err
}
n, readErr := src.Read(buffer)
if n > 0 {
written, writeErr := dst.Write(buffer[:n])
total += int64(written)
if writeErr != nil {
return total, fmt.Errorf("write compressed data: %w", writeErr)
}
if written != n {
return total, fmt.Errorf("short write while compressing")
}
}
if readErr == io.EOF {
return total, nil
}
if readErr != nil {
return total, fmt.Errorf("read source data: %w", readErr)
}
}
}