Compare commits

...

1 Commits

Author SHA1 Message Date
721e53fe6a v3.42.34: Add spf13/afero for filesystem abstraction
All checks were successful
CI/CD / Test (push) Successful in 1m16s
CI/CD / Lint (push) Successful in 1m26s
CI/CD / Build & Release (push) Successful in 3m13s
- New internal/fs package for testable filesystem operations
- In-memory filesystem support for unit testing without disk I/O
- Swappable global FS: SetFS(afero.NewMemMapFs())
- Wrapper functions: ReadFile, WriteFile, Mkdir, Walk, Glob, etc.
- Testing helpers: WithMemFs(), SetupTestDir()
- Comprehensive test suite demonstrating usage
- Upgraded afero from v1.10.0 to v1.15.0
2026-01-14 16:24:12 +01:00
7 changed files with 444 additions and 4 deletions

View File

@@ -5,6 +5,29 @@ All notable changes to dbbackup will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.42.34] - 2026-01-14 "Filesystem Abstraction"
### Added - spf13/afero for Filesystem Abstraction
- **New `internal/fs` package** for testable filesystem operations
- **In-memory filesystem** for unit testing without disk I/O
- **Global FS interface** that can be swapped for testing:
```go
fs.SetFS(afero.NewMemMapFs()) // Use memory
fs.ResetFS() // Back to real disk
```
- **Wrapper functions** for all common file operations:
- `ReadFile`, `WriteFile`, `Create`, `Open`, `Remove`, `RemoveAll`
- `Mkdir`, `MkdirAll`, `ReadDir`, `Walk`, `Glob`
- `Exists`, `DirExists`, `IsDir`, `IsEmpty`
- `TempDir`, `TempFile`, `CopyFile`, `FileSize`
- **Testing helpers**:
- `WithMemFs(fn)` - Execute function with temp in-memory FS
- `SetupTestDir(files)` - Create test directory structure
- **Comprehensive test suite** demonstrating usage
### Changed
- Upgraded afero from v1.10.0 to v1.15.0
## [3.42.33] - 2026-01-14 "Exponential Backoff Retry"
### Added - cenkalti/backoff for Cloud Operation Retry

View File

@@ -3,9 +3,9 @@
This directory contains pre-compiled binaries for the DB Backup Tool across multiple platforms and architectures.
## Build Information
- **Version**: 3.42.32
- **Build Time**: 2026-01-14_15:13:08_UTC
- **Git Commit**: 6a24ee3
- **Version**: 3.42.33
- **Build Time**: 2026-01-14_15:19:48_UTC
- **Git Commit**: 4e09066
## Recent Updates (v1.1.0)
- ✅ Fixed TUI progress display with line-by-line output

1
go.mod
View File

@@ -97,6 +97,7 @@ require (
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/schollz/progressbar/v3 v3.19.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect

2
go.sum
View File

@@ -211,6 +211,8 @@ github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/i
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=

223
internal/fs/fs.go Normal file
View File

@@ -0,0 +1,223 @@
// Package fs provides filesystem abstraction using spf13/afero for testability.
// It allows swapping the real filesystem with an in-memory mock for unit tests.
package fs
import (
"io"
"os"
"path/filepath"
"time"
"github.com/spf13/afero"
)
// FS is the global filesystem interface used throughout the application.
// By default, it uses the real OS filesystem.
// For testing, use SetFS(afero.NewMemMapFs()) to use an in-memory filesystem.
var FS afero.Fs = afero.NewOsFs()
// SetFS sets the global filesystem (useful for testing)
func SetFS(fs afero.Fs) {
FS = fs
}
// ResetFS resets to the real OS filesystem
func ResetFS() {
FS = afero.NewOsFs()
}
// NewMemMapFs creates a new in-memory filesystem for testing
func NewMemMapFs() afero.Fs {
return afero.NewMemMapFs()
}
// NewReadOnlyFs wraps a filesystem to make it read-only
func NewReadOnlyFs(base afero.Fs) afero.Fs {
return afero.NewReadOnlyFs(base)
}
// NewBasePathFs creates a filesystem rooted at a specific path
func NewBasePathFs(base afero.Fs, path string) afero.Fs {
return afero.NewBasePathFs(base, path)
}
// --- File Operations (use global FS) ---
// Create creates a file
func Create(name string) (afero.File, error) {
return FS.Create(name)
}
// Open opens a file for reading
func Open(name string) (afero.File, error) {
return FS.Open(name)
}
// OpenFile opens a file with specified flags and permissions
func OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
return FS.OpenFile(name, flag, perm)
}
// Remove removes a file or empty directory
func Remove(name string) error {
return FS.Remove(name)
}
// RemoveAll removes a path and any children it contains
func RemoveAll(path string) error {
return FS.RemoveAll(path)
}
// Rename renames (moves) a file
func Rename(oldname, newname string) error {
return FS.Rename(oldname, newname)
}
// Stat returns file info
func Stat(name string) (os.FileInfo, error) {
return FS.Stat(name)
}
// Chmod changes file mode
func Chmod(name string, mode os.FileMode) error {
return FS.Chmod(name, mode)
}
// Chown changes file ownership (may not work on all filesystems)
func Chown(name string, uid, gid int) error {
return FS.Chown(name, uid, gid)
}
// Chtimes changes file access and modification times
func Chtimes(name string, atime, mtime time.Time) error {
return FS.Chtimes(name, atime, mtime)
}
// --- Directory Operations ---
// Mkdir creates a directory
func Mkdir(name string, perm os.FileMode) error {
return FS.Mkdir(name, perm)
}
// MkdirAll creates a directory and all parents
func MkdirAll(path string, perm os.FileMode) error {
return FS.MkdirAll(path, perm)
}
// ReadDir reads a directory
func ReadDir(dirname string) ([]os.FileInfo, error) {
return afero.ReadDir(FS, dirname)
}
// --- File Content Operations ---
// ReadFile reads an entire file
func ReadFile(filename string) ([]byte, error) {
return afero.ReadFile(FS, filename)
}
// WriteFile writes data to a file
func WriteFile(filename string, data []byte, perm os.FileMode) error {
return afero.WriteFile(FS, filename, data, perm)
}
// --- Existence Checks ---
// Exists checks if a file or directory exists
func Exists(path string) (bool, error) {
return afero.Exists(FS, path)
}
// DirExists checks if a directory exists
func DirExists(path string) (bool, error) {
return afero.DirExists(FS, path)
}
// IsDir checks if path is a directory
func IsDir(path string) (bool, error) {
return afero.IsDir(FS, path)
}
// IsEmpty checks if a directory is empty
func IsEmpty(path string) (bool, error) {
return afero.IsEmpty(FS, path)
}
// --- Utility Functions ---
// Walk walks a directory tree
func Walk(root string, walkFn filepath.WalkFunc) error {
return afero.Walk(FS, root, walkFn)
}
// Glob returns the names of all files matching pattern
func Glob(pattern string) ([]string, error) {
return afero.Glob(FS, pattern)
}
// TempDir creates a temporary directory
func TempDir(dir, prefix string) (string, error) {
return afero.TempDir(FS, dir, prefix)
}
// TempFile creates a temporary file
func TempFile(dir, pattern string) (afero.File, error) {
return afero.TempFile(FS, dir, pattern)
}
// CopyFile copies a file from src to dst
func CopyFile(src, dst string) error {
srcFile, err := FS.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
srcInfo, err := srcFile.Stat()
if err != nil {
return err
}
dstFile, err := FS.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode())
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}
// FileSize returns the size of a file
func FileSize(path string) (int64, error) {
info, err := FS.Stat(path)
if err != nil {
return 0, err
}
return info.Size(), nil
}
// --- Testing Helpers ---
// WithMemFs executes a function with an in-memory filesystem, then restores the original
func WithMemFs(fn func(fs afero.Fs)) {
original := FS
memFs := afero.NewMemMapFs()
FS = memFs
defer func() { FS = original }()
fn(memFs)
}
// SetupTestDir creates a test directory structure in-memory
func SetupTestDir(files map[string]string) afero.Fs {
memFs := afero.NewMemMapFs()
for path, content := range files {
dir := filepath.Dir(path)
if dir != "." && dir != "/" {
_ = memFs.MkdirAll(dir, 0755)
}
_ = afero.WriteFile(memFs, path, []byte(content), 0644)
}
return memFs
}

191
internal/fs/fs_test.go Normal file
View File

@@ -0,0 +1,191 @@
package fs
import (
"os"
"testing"
"github.com/spf13/afero"
)
func TestMemMapFs(t *testing.T) {
// Use in-memory filesystem for testing
WithMemFs(func(memFs afero.Fs) {
// Create a file
err := WriteFile("/test/file.txt", []byte("hello world"), 0644)
if err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
// Read it back
content, err := ReadFile("/test/file.txt")
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if string(content) != "hello world" {
t.Errorf("expected 'hello world', got '%s'", string(content))
}
// Check existence
exists, err := Exists("/test/file.txt")
if err != nil {
t.Fatalf("Exists failed: %v", err)
}
if !exists {
t.Error("file should exist")
}
// Check non-existent file
exists, err = Exists("/nonexistent.txt")
if err != nil {
t.Fatalf("Exists failed: %v", err)
}
if exists {
t.Error("file should not exist")
}
})
}
func TestSetupTestDir(t *testing.T) {
// Create test directory structure
testFs := SetupTestDir(map[string]string{
"/backups/db1.dump": "database 1 content",
"/backups/db2.dump": "database 2 content",
"/config/settings.json": `{"key": "value"}`,
})
// Verify files exist
content, err := afero.ReadFile(testFs, "/backups/db1.dump")
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if string(content) != "database 1 content" {
t.Errorf("unexpected content: %s", string(content))
}
// Verify directory structure
files, err := afero.ReadDir(testFs, "/backups")
if err != nil {
t.Fatalf("ReadDir failed: %v", err)
}
if len(files) != 2 {
t.Errorf("expected 2 files, got %d", len(files))
}
}
func TestCopyFile(t *testing.T) {
WithMemFs(func(memFs afero.Fs) {
// Create source file
err := WriteFile("/source.txt", []byte("copy me"), 0644)
if err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
// Copy file
err = CopyFile("/source.txt", "/dest.txt")
if err != nil {
t.Fatalf("CopyFile failed: %v", err)
}
// Verify copy
content, err := ReadFile("/dest.txt")
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if string(content) != "copy me" {
t.Errorf("unexpected content: %s", string(content))
}
})
}
func TestFileSize(t *testing.T) {
WithMemFs(func(memFs afero.Fs) {
data := []byte("12345678901234567890") // 20 bytes
err := WriteFile("/sized.txt", data, 0644)
if err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
size, err := FileSize("/sized.txt")
if err != nil {
t.Fatalf("FileSize failed: %v", err)
}
if size != 20 {
t.Errorf("expected size 20, got %d", size)
}
})
}
func TestTempDir(t *testing.T) {
WithMemFs(func(memFs afero.Fs) {
// Create temp dir
dir, err := TempDir("", "test-")
if err != nil {
t.Fatalf("TempDir failed: %v", err)
}
// Verify it exists
isDir, err := IsDir(dir)
if err != nil {
t.Fatalf("IsDir failed: %v", err)
}
if !isDir {
t.Error("temp dir should be a directory")
}
// Verify it's empty
isEmpty, err := IsEmpty(dir)
if err != nil {
t.Fatalf("IsEmpty failed: %v", err)
}
if !isEmpty {
t.Error("temp dir should be empty")
}
})
}
func TestWalk(t *testing.T) {
WithMemFs(func(memFs afero.Fs) {
// Create directory structure
_ = MkdirAll("/root/a/b", 0755)
_ = WriteFile("/root/file1.txt", []byte("1"), 0644)
_ = WriteFile("/root/a/file2.txt", []byte("2"), 0644)
_ = WriteFile("/root/a/b/file3.txt", []byte("3"), 0644)
var files []string
err := Walk("/root", func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
files = append(files, path)
}
return nil
})
if err != nil {
t.Fatalf("Walk failed: %v", err)
}
if len(files) != 3 {
t.Errorf("expected 3 files, got %d: %v", len(files), files)
}
})
}
func TestGlob(t *testing.T) {
WithMemFs(func(memFs afero.Fs) {
_ = WriteFile("/data/backup1.dump", []byte("1"), 0644)
_ = WriteFile("/data/backup2.dump", []byte("2"), 0644)
_ = WriteFile("/data/config.json", []byte("{}"), 0644)
matches, err := Glob("/data/*.dump")
if err != nil {
t.Fatalf("Glob failed: %v", err)
}
if len(matches) != 2 {
t.Errorf("expected 2 matches, got %d: %v", len(matches), matches)
}
})
}

View File

@@ -16,7 +16,7 @@ import (
// Build information (set by ldflags)
var (
version = "3.42.33"
version = "3.42.34"
buildTime = "unknown"
gitCommit = "unknown"
)