diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bd04b5..c1f0bd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/bin/README.md b/bin/README.md index 47c3ed3..a281623 100644 --- a/bin/README.md +++ b/bin/README.md @@ -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 diff --git a/go.mod b/go.mod index ddd26b9..fd909b7 100755 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 5de978d..8ad1dc4 100755 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/fs/fs.go b/internal/fs/fs.go new file mode 100644 index 0000000..fb6d4c3 --- /dev/null +++ b/internal/fs/fs.go @@ -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 +} diff --git a/internal/fs/fs_test.go b/internal/fs/fs_test.go new file mode 100644 index 0000000..d9d6020 --- /dev/null +++ b/internal/fs/fs_test.go @@ -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) + } + }) +} diff --git a/main.go b/main.go index 6d7ba8b..a7d736b 100755 --- a/main.go +++ b/main.go @@ -16,7 +16,7 @@ import ( // Build information (set by ldflags) var ( - version = "3.42.33" + version = "3.42.34" buildTime = "unknown" gitCommit = "unknown" )