Files
dbbackup/internal/engine/snapshot/btrfs.go
Alexander Renz f033b02cec
Some checks failed
CI/CD / Test (push) Failing after 4s
CI/CD / Generate SBOM (push) Has been skipped
CI/CD / Lint (push) Failing after 4s
CI/CD / Build (darwin-amd64) (push) Has been skipped
CI/CD / Build (linux-amd64) (push) Has been skipped
CI/CD / Build (darwin-arm64) (push) Has been skipped
CI/CD / Build (linux-arm64) (push) Has been skipped
CI/CD / Release (push) Has been skipped
CI/CD / Build & Push Docker Image (push) Has been skipped
CI/CD / Mirror to GitHub (push) Has been skipped
fix(build): move EstimateBackupSize to platform-independent file
Fixes Windows, OpenBSD, and NetBSD builds by extracting
EstimateBackupSize from disk_check.go (which has build tags
excluding those platforms) to a new estimate.go file.
2025-12-13 21:55:39 +01:00

395 lines
10 KiB
Go

package snapshot
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// BtrfsBackend implements snapshot Backend for Btrfs
type BtrfsBackend struct {
config *BtrfsConfig
}
// NewBtrfsBackend creates a new Btrfs backend
func NewBtrfsBackend(config *BtrfsConfig) *BtrfsBackend {
return &BtrfsBackend{
config: config,
}
}
// Name returns the backend name
func (b *BtrfsBackend) Name() string {
return "btrfs"
}
// Detect checks if the path is on a Btrfs filesystem
func (b *BtrfsBackend) Detect(dataDir string) (bool, error) {
// Check if btrfs tools are available
if _, err := exec.LookPath("btrfs"); err != nil {
return false, nil
}
// Check filesystem type
cmd := exec.Command("df", "-T", dataDir)
output, err := cmd.Output()
if err != nil {
return false, nil
}
if !strings.Contains(string(output), "btrfs") {
return false, nil
}
// Check if path is a subvolume
cmd = exec.Command("btrfs", "subvolume", "show", dataDir)
if err := cmd.Run(); err != nil {
// Path exists on btrfs but may not be a subvolume
// We can still create snapshots of parent subvolume
}
if b.config != nil {
b.config.Subvolume = dataDir
}
return true, nil
}
// CreateSnapshot creates a Btrfs snapshot
func (b *BtrfsBackend) CreateSnapshot(ctx context.Context, opts SnapshotOptions) (*Snapshot, error) {
if b.config == nil || b.config.Subvolume == "" {
return nil, fmt.Errorf("Btrfs subvolume not configured")
}
// Generate snapshot name
snapName := opts.Name
if snapName == "" {
snapName = fmt.Sprintf("dbbackup_%s", time.Now().Format("20060102_150405"))
}
// Determine snapshot path
snapPath := b.config.SnapshotPath
if snapPath == "" {
// Create snapshots in parent directory by default
snapPath = filepath.Join(filepath.Dir(b.config.Subvolume), "snapshots")
}
// Ensure snapshot directory exists
if err := os.MkdirAll(snapPath, 0755); err != nil {
return nil, fmt.Errorf("failed to create snapshot directory: %w", err)
}
fullPath := filepath.Join(snapPath, snapName)
// Optionally sync filesystem first
if opts.Sync {
cmd := exec.CommandContext(ctx, "sync")
cmd.Run()
// Also run btrfs filesystem sync
cmd = exec.CommandContext(ctx, "btrfs", "filesystem", "sync", b.config.Subvolume)
cmd.Run()
}
// Create snapshot
// btrfs subvolume snapshot [-r] <source> <dest>
args := []string{"subvolume", "snapshot"}
if opts.ReadOnly {
args = append(args, "-r")
}
args = append(args, b.config.Subvolume, fullPath)
cmd := exec.CommandContext(ctx, "btrfs", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("btrfs snapshot failed: %s: %w", string(output), err)
}
return &Snapshot{
ID: fullPath,
Backend: "btrfs",
Source: b.config.Subvolume,
Name: snapName,
MountPoint: fullPath, // Btrfs snapshots are immediately accessible
CreatedAt: time.Now(),
Metadata: map[string]string{
"subvolume": b.config.Subvolume,
"snapshot_path": snapPath,
"read_only": strconv.FormatBool(opts.ReadOnly),
},
}, nil
}
// MountSnapshot "mounts" a Btrfs snapshot (already accessible, just returns path)
func (b *BtrfsBackend) MountSnapshot(ctx context.Context, snap *Snapshot, mountPoint string) error {
// Btrfs snapshots are already accessible at their creation path
// If a different mount point is requested, create a bind mount
if mountPoint != snap.ID {
// Create mount point
if err := os.MkdirAll(mountPoint, 0755); err != nil {
return fmt.Errorf("failed to create mount point: %w", err)
}
// Bind mount
cmd := exec.CommandContext(ctx, "mount", "--bind", snap.ID, mountPoint)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("bind mount failed: %s: %w", string(output), err)
}
snap.MountPoint = mountPoint
snap.Metadata["bind_mount"] = "true"
} else {
snap.MountPoint = snap.ID
}
return nil
}
// UnmountSnapshot unmounts a Btrfs snapshot
func (b *BtrfsBackend) UnmountSnapshot(ctx context.Context, snap *Snapshot) error {
// Only unmount if we created a bind mount
if snap.Metadata["bind_mount"] == "true" && snap.MountPoint != "" && snap.MountPoint != snap.ID {
cmd := exec.CommandContext(ctx, "umount", snap.MountPoint)
if err := cmd.Run(); err != nil {
// Try force unmount
cmd = exec.CommandContext(ctx, "umount", "-f", snap.MountPoint)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to unmount: %w", err)
}
}
}
snap.MountPoint = ""
return nil
}
// RemoveSnapshot deletes a Btrfs snapshot
func (b *BtrfsBackend) RemoveSnapshot(ctx context.Context, snap *Snapshot) error {
// Ensure unmounted
if snap.Metadata["bind_mount"] == "true" && snap.MountPoint != "" {
if err := b.UnmountSnapshot(ctx, snap); err != nil {
return fmt.Errorf("failed to unmount before removal: %w", err)
}
}
// Remove snapshot
// btrfs subvolume delete <path>
cmd := exec.CommandContext(ctx, "btrfs", "subvolume", "delete", snap.ID)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("btrfs delete failed: %s: %w", string(output), err)
}
return nil
}
// GetSnapshotSize returns the space used by the snapshot
func (b *BtrfsBackend) GetSnapshotSize(ctx context.Context, snap *Snapshot) (int64, error) {
// btrfs qgroup show -r <path>
// Note: Requires quotas enabled for accurate results
cmd := exec.CommandContext(ctx, "btrfs", "qgroup", "show", "-rf", snap.ID)
output, err := cmd.Output()
if err != nil {
// Quotas might not be enabled, fall back to du
return b.getSnapshotSizeFallback(ctx, snap)
}
// Parse qgroup output
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "0/") { // qgroup format: 0/subvolid
fields := strings.Fields(line)
if len(fields) >= 2 {
size, _ := strconv.ParseInt(fields[1], 10, 64)
snap.Size = size
return size, nil
}
}
}
return b.getSnapshotSizeFallback(ctx, snap)
}
// getSnapshotSizeFallback uses du to estimate snapshot size
func (b *BtrfsBackend) getSnapshotSizeFallback(ctx context.Context, snap *Snapshot) (int64, error) {
cmd := exec.CommandContext(ctx, "du", "-sb", snap.ID)
output, err := cmd.Output()
if err != nil {
return 0, err
}
fields := strings.Fields(string(output))
if len(fields) > 0 {
size, _ := strconv.ParseInt(fields[0], 10, 64)
snap.Size = size
return size, nil
}
return 0, fmt.Errorf("could not determine snapshot size")
}
// ListSnapshots lists all Btrfs snapshots
func (b *BtrfsBackend) ListSnapshots(ctx context.Context) ([]*Snapshot, error) {
snapPath := b.config.SnapshotPath
if snapPath == "" {
snapPath = filepath.Join(filepath.Dir(b.config.Subvolume), "snapshots")
}
// List subvolumes
cmd := exec.CommandContext(ctx, "btrfs", "subvolume", "list", "-s", snapPath)
output, err := cmd.Output()
if err != nil {
// Try listing directory entries if subvolume list fails
return b.listSnapshotsFromDir(ctx, snapPath)
}
var snapshots []*Snapshot
lines := strings.Split(string(output), "\n")
for _, line := range lines {
// Format: ID <id> gen <gen> top level <level> path <path>
if !strings.Contains(line, "path") {
continue
}
fields := strings.Fields(line)
pathIdx := -1
for i, f := range fields {
if f == "path" && i+1 < len(fields) {
pathIdx = i + 1
break
}
}
if pathIdx < 0 {
continue
}
name := filepath.Base(fields[pathIdx])
fullPath := filepath.Join(snapPath, name)
info, _ := os.Stat(fullPath)
createdAt := time.Time{}
if info != nil {
createdAt = info.ModTime()
}
snapshots = append(snapshots, &Snapshot{
ID: fullPath,
Backend: "btrfs",
Name: name,
Source: b.config.Subvolume,
MountPoint: fullPath,
CreatedAt: createdAt,
Metadata: map[string]string{
"subvolume": b.config.Subvolume,
},
})
}
return snapshots, nil
}
// listSnapshotsFromDir lists snapshots by scanning directory
func (b *BtrfsBackend) listSnapshotsFromDir(ctx context.Context, snapPath string) ([]*Snapshot, error) {
entries, err := os.ReadDir(snapPath)
if err != nil {
return nil, err
}
var snapshots []*Snapshot
for _, entry := range entries {
if !entry.IsDir() {
continue
}
fullPath := filepath.Join(snapPath, entry.Name())
// Check if it's a subvolume
cmd := exec.CommandContext(ctx, "btrfs", "subvolume", "show", fullPath)
if err := cmd.Run(); err != nil {
continue // Not a subvolume
}
info, _ := entry.Info()
createdAt := time.Time{}
if info != nil {
createdAt = info.ModTime()
}
snapshots = append(snapshots, &Snapshot{
ID: fullPath,
Backend: "btrfs",
Name: entry.Name(),
Source: b.config.Subvolume,
MountPoint: fullPath,
CreatedAt: createdAt,
Metadata: map[string]string{
"subvolume": b.config.Subvolume,
},
})
}
return snapshots, nil
}
// SendSnapshot sends a Btrfs snapshot (for efficient transfer)
func (b *BtrfsBackend) SendSnapshot(ctx context.Context, snap *Snapshot) (*exec.Cmd, error) {
// btrfs send <snapshot>
cmd := exec.CommandContext(ctx, "btrfs", "send", snap.ID)
return cmd, nil
}
// ReceiveSnapshot receives a Btrfs snapshot stream
func (b *BtrfsBackend) ReceiveSnapshot(ctx context.Context, destPath string) (*exec.Cmd, error) {
// btrfs receive <path>
cmd := exec.CommandContext(ctx, "btrfs", "receive", destPath)
return cmd, nil
}
// GetBtrfsSubvolume returns the subvolume info for a path
func GetBtrfsSubvolume(path string) (string, error) {
cmd := exec.Command("btrfs", "subvolume", "show", path)
output, err := cmd.Output()
if err != nil {
return "", err
}
// First line contains the subvolume path
lines := strings.Split(string(output), "\n")
if len(lines) > 0 {
return strings.TrimSpace(lines[0]), nil
}
return "", fmt.Errorf("could not parse subvolume info")
}
// GetBtrfsDeviceFreeSpace returns free space on the Btrfs device
func GetBtrfsDeviceFreeSpace(path string) (int64, error) {
cmd := exec.Command("btrfs", "filesystem", "usage", "-b", path)
output, err := cmd.Output()
if err != nil {
return 0, err
}
// Look for "Free (estimated)" line
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "Free (estimated)") {
fields := strings.Fields(line)
for _, f := range fields {
// Try to parse as number
if size, err := strconv.ParseInt(f, 10, 64); err == nil {
return size, nil
}
}
}
}
return 0, fmt.Errorf("could not determine free space")
}