Files

356 lines
8.9 KiB
Go

package snapshot
import (
"context"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
// LVMBackend implements snapshot Backend for LVM
type LVMBackend struct {
config *LVMConfig
}
// NewLVMBackend creates a new LVM backend
func NewLVMBackend(config *LVMConfig) *LVMBackend {
return &LVMBackend{
config: config,
}
}
// Name returns the backend name
func (l *LVMBackend) Name() string {
return "lvm"
}
// Detect checks if the path is on an LVM volume
func (l *LVMBackend) Detect(dataDir string) (bool, error) {
// Check if lvm tools are available
if _, err := exec.LookPath("lvs"); err != nil {
return false, nil
}
// Get the device for the path
device, err := getDeviceForPath(dataDir)
if err != nil {
return false, nil
}
// Check if device is an LVM logical volume
cmd := exec.Command("lvs", "--noheadings", "-o", "vg_name,lv_name", device)
output, err := cmd.Output()
if err != nil {
return false, nil
}
result := strings.TrimSpace(string(output))
if result == "" {
return false, nil
}
// Parse VG and LV names
fields := strings.Fields(result)
if len(fields) >= 2 && l.config != nil {
l.config.VolumeGroup = fields[0]
l.config.LogicalVolume = fields[1]
}
return true, nil
}
// CreateSnapshot creates an LVM snapshot
func (l *LVMBackend) CreateSnapshot(ctx context.Context, opts SnapshotOptions) (*Snapshot, error) {
if l.config == nil {
return nil, fmt.Errorf("LVM config not set")
}
if l.config.VolumeGroup == "" || l.config.LogicalVolume == "" {
return nil, fmt.Errorf("volume group and logical volume required")
}
// Generate snapshot name
snapName := opts.Name
if snapName == "" {
snapName = fmt.Sprintf("%s_snap_%s", l.config.LogicalVolume, time.Now().Format("20060102_150405"))
}
// Determine snapshot size (default: 10G)
snapSize := opts.Size
if snapSize == "" {
snapSize = l.config.SnapshotSize
}
if snapSize == "" {
snapSize = "10G"
}
// Source LV path
sourceLV := fmt.Sprintf("/dev/%s/%s", l.config.VolumeGroup, l.config.LogicalVolume)
// Create snapshot
// lvcreate --snapshot --name <snap_name> --size <size> <source_lv>
args := []string{
"--snapshot",
"--name", snapName,
"--size", snapSize,
sourceLV,
}
if opts.ReadOnly {
args = append([]string{"--permission", "r"}, args...)
}
cmd := exec.CommandContext(ctx, "lvcreate", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("lvcreate failed: %s: %w", string(output), err)
}
return &Snapshot{
ID: snapName,
Backend: "lvm",
Source: sourceLV,
Name: snapName,
CreatedAt: time.Now(),
Metadata: map[string]string{
"volume_group": l.config.VolumeGroup,
"logical_volume": snapName,
"source_lv": l.config.LogicalVolume,
"snapshot_size": snapSize,
},
}, nil
}
// MountSnapshot mounts an LVM snapshot
func (l *LVMBackend) MountSnapshot(ctx context.Context, snap *Snapshot, mountPoint string) error {
// Snapshot device path
snapDevice := fmt.Sprintf("/dev/%s/%s", l.config.VolumeGroup, snap.Name)
// Create mount point
if err := exec.CommandContext(ctx, "mkdir", "-p", mountPoint).Run(); err != nil {
return fmt.Errorf("failed to create mount point: %w", err)
}
// Mount (read-only, nouuid for XFS)
args := []string{"-o", "ro,nouuid", snapDevice, mountPoint}
cmd := exec.CommandContext(ctx, "mount", args...)
if _, err := cmd.CombinedOutput(); err != nil {
// Try without nouuid (for non-XFS)
args = []string{"-o", "ro", snapDevice, mountPoint}
cmd = exec.CommandContext(ctx, "mount", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("mount failed: %s: %w", string(output), err)
}
}
snap.MountPoint = mountPoint
return nil
}
// UnmountSnapshot unmounts an LVM snapshot
func (l *LVMBackend) UnmountSnapshot(ctx context.Context, snap *Snapshot) error {
if snap.MountPoint == "" {
return nil
}
// Try to unmount, retry a few times
for i := 0; i < 3; i++ {
cmd := exec.CommandContext(ctx, "umount", snap.MountPoint)
if err := cmd.Run(); err == nil {
snap.MountPoint = ""
return nil
}
// Wait before retry
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
}
// Force unmount as last resort
cmd := exec.CommandContext(ctx, "umount", "-f", snap.MountPoint)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to unmount snapshot: %w", err)
}
snap.MountPoint = ""
return nil
}
// RemoveSnapshot deletes an LVM snapshot
func (l *LVMBackend) RemoveSnapshot(ctx context.Context, snap *Snapshot) error {
// Ensure unmounted
if snap.MountPoint != "" {
if err := l.UnmountSnapshot(ctx, snap); err != nil {
return fmt.Errorf("failed to unmount before removal: %w", err)
}
}
// Remove snapshot
// lvremove -f /dev/<vg>/<snap>
snapDevice := fmt.Sprintf("/dev/%s/%s", l.config.VolumeGroup, snap.Name)
cmd := exec.CommandContext(ctx, "lvremove", "-f", snapDevice)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lvremove failed: %s: %w", string(output), err)
}
return nil
}
// GetSnapshotSize returns the actual COW data size
func (l *LVMBackend) GetSnapshotSize(ctx context.Context, snap *Snapshot) (int64, error) {
// lvs --noheadings -o data_percent,lv_size <snap_device>
snapDevice := fmt.Sprintf("/dev/%s/%s", l.config.VolumeGroup, snap.Name)
cmd := exec.CommandContext(ctx, "lvs", "--noheadings", "-o", "snap_percent,lv_size", "--units", "b", snapDevice)
output, err := cmd.Output()
if err != nil {
return 0, err
}
fields := strings.Fields(string(output))
if len(fields) < 2 {
return 0, fmt.Errorf("unexpected lvs output")
}
// Parse percentage and size
percentStr := strings.TrimSuffix(fields[0], "%")
sizeStr := strings.TrimSuffix(fields[1], "B")
percent, _ := strconv.ParseFloat(percentStr, 64)
size, _ := strconv.ParseInt(sizeStr, 10, 64)
// Calculate actual used size
usedSize := int64(float64(size) * percent / 100)
snap.Size = usedSize
return usedSize, nil
}
// ListSnapshots lists all LVM snapshots in the volume group
func (l *LVMBackend) ListSnapshots(ctx context.Context) ([]*Snapshot, error) {
if l.config == nil || l.config.VolumeGroup == "" {
return nil, fmt.Errorf("volume group not configured")
}
// lvs --noheadings -o lv_name,origin,lv_time --select 'lv_attr=~[^s]' <vg>
cmd := exec.CommandContext(ctx, "lvs", "--noheadings",
"-o", "lv_name,origin,lv_time",
"--select", "lv_attr=~[^s]",
l.config.VolumeGroup)
output, err := cmd.Output()
if err != nil {
return nil, err
}
var snapshots []*Snapshot
lines := strings.Split(string(output), "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
snapshots = append(snapshots, &Snapshot{
ID: fields[0],
Backend: "lvm",
Name: fields[0],
Source: fields[1],
CreatedAt: parseTime(fields[2]),
Metadata: map[string]string{
"volume_group": l.config.VolumeGroup,
},
})
}
return snapshots, nil
}
// getDeviceForPath returns the device path for a given filesystem path
func getDeviceForPath(path string) (string, error) {
cmd := exec.Command("df", "--output=source", path)
output, err := cmd.Output()
if err != nil {
return "", err
}
lines := strings.Split(string(output), "\n")
if len(lines) < 2 {
return "", fmt.Errorf("unexpected df output")
}
device := strings.TrimSpace(lines[1])
// Resolve any symlinks (e.g., /dev/mapper/* -> /dev/vg/lv)
resolved, err := exec.Command("readlink", "-f", device).Output()
if err == nil {
device = strings.TrimSpace(string(resolved))
}
return device, nil
}
// parseTime parses LVM time format
func parseTime(s string) time.Time {
// LVM uses format like "2024-01-15 10:30:00 +0000"
layouts := []string{
"2006-01-02 15:04:05 -0700",
"2006-01-02 15:04:05",
time.RFC3339,
}
for _, layout := range layouts {
if t, err := time.Parse(layout, s); err == nil {
return t
}
}
return time.Time{}
}
// GetLVMInfo returns VG and LV names for a device
func GetLVMInfo(device string) (vg, lv string, err error) {
cmd := exec.Command("lvs", "--noheadings", "-o", "vg_name,lv_name", device)
output, err := cmd.Output()
if err != nil {
return "", "", err
}
fields := strings.Fields(string(output))
if len(fields) < 2 {
return "", "", fmt.Errorf("device is not an LVM volume")
}
return fields[0], fields[1], nil
}
// GetVolumeGroupFreeSpace returns free space in volume group
func GetVolumeGroupFreeSpace(vg string) (int64, error) {
cmd := exec.Command("vgs", "--noheadings", "-o", "vg_free", "--units", "b", vg)
output, err := cmd.Output()
if err != nil {
return 0, err
}
sizeStr := strings.TrimSpace(string(output))
sizeStr = strings.TrimSuffix(sizeStr, "B")
// Remove any non-numeric prefix/suffix
re := regexp.MustCompile(`[\d.]+`)
match := re.FindString(sizeStr)
if match == "" {
return 0, fmt.Errorf("could not parse size: %s", sizeStr)
}
size, err := strconv.ParseInt(match, 10, 64)
if err != nil {
return 0, err
}
return size, nil
}