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 --size 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// 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 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]' 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 }