Files
atlas/internal/zfs/service.go
Faiz Kaysan Hidayat e36c855bf4
Some checks failed
CI / test-build (push) Has been cancelled
Patch the CreatePool function
The CreatePool function have some bug when creating an pool it mounting an vdev properties but in the reality it show that the option is the dataset properties
2025-12-18 10:29:24 +00:00

932 lines
26 KiB
Go

package zfs
import (
"bytes"
"encoding/json"
"fmt"
"log"
"os/exec"
"strconv"
"strings"
"time"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
)
// Service provides ZFS operations
type Service struct {
zfsPath string
zpoolPath string
}
// New creates a new ZFS service
func New() *Service {
// Find full paths to zfs and zpool commands
zfsPath := findCommandPath("zfs")
zpoolPath := findCommandPath("zpool")
return &Service{
zfsPath: zfsPath,
zpoolPath: zpoolPath,
}
}
// findCommandPath finds the full path to a command
func findCommandPath(cmd string) string {
// Try which first
if output, err := exec.Command("which", cmd).Output(); err == nil {
path := strings.TrimSpace(string(output))
if path != "" {
return path
}
}
// Try LookPath
if path, err := exec.LookPath(cmd); err == nil {
return path
}
// Fallback to command name (will use PATH)
return cmd
}
// execCommand executes a shell command and returns output
// For ZFS operations that require elevated privileges, it uses sudo
func (s *Service) execCommand(name string, args ...string) (string, error) {
// Commands that require root privileges
privilegedCommands := []string{"zpool", "zfs"}
useSudo := false
for _, cmd := range privilegedCommands {
if strings.Contains(name, cmd) {
useSudo = true
break
}
}
var cmd *exec.Cmd
if useSudo {
// Use sudo -n (non-interactive) for privileged commands
// This prevents password prompts and will fail if sudoers is not configured
sudoArgs := append([]string{"-n", name}, args...)
cmd = exec.Command("sudo", sudoArgs...)
} else {
cmd = exec.Command(name, args...)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil && useSudo {
// Log that sudo failed
log.Printf("sudo command failed, trying direct execution: %s %v (error: %v, stderr: %s)", name, args, err, stderr.String())
// If sudo failed, try running the command directly
// (user might already have permissions or be root)
directCmd := exec.Command(name, args...)
var directStdout, directStderr bytes.Buffer
directCmd.Stdout = &directStdout
directCmd.Stderr = &directStderr
directErr := directCmd.Run()
if directErr == nil {
// Direct execution succeeded, return that result
log.Printf("direct command execution succeeded (without sudo)")
return strings.TrimSpace(directStdout.String()), nil
}
// Both sudo and direct failed, return detailed error
log.Printf("both sudo and direct execution failed - sudo error: %v, direct error: %v", err, directErr)
log.Printf("sudo stderr: %s, direct stderr: %s", stderr.String(), directStderr.String())
return "", fmt.Errorf("%s: sudo failed (%v: %s), direct execution also failed (%v: %s)", name, err, stderr.String(), directErr, directStderr.String())
}
if err != nil {
log.Printf("command execution failed: %s %v (error: %v, stderr: %s)", name, args, err, stderr.String())
return "", fmt.Errorf("%s: %v: %s", name, err, stderr.String())
}
return strings.TrimSpace(stdout.String()), nil
}
// ListPools returns all ZFS pools
func (s *Service) ListPools() ([]models.Pool, error) {
output, err := s.execCommand(s.zpoolPath, "list", "-H", "-o", "name,size,allocated,free,health")
if err != nil {
// Return empty slice instead of nil to ensure JSON encodes as [] not null
return []models.Pool{}, err
}
pools := []models.Pool{}
lines := strings.Split(output, "\n")
for _, line := range lines {
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
pool := models.Pool{
Name: fields[0],
Status: "ONLINE", // Default, will be updated from health
Health: fields[4],
}
// Parse sizes (handles K, M, G, T suffixes)
if size, err := parseSize(fields[1]); err == nil {
pool.Size = size
}
if allocated, err := parseSize(fields[2]); err == nil {
pool.Allocated = allocated
}
if free, err := parseSize(fields[3]); err == nil {
pool.Free = free
}
// Get pool status
status, _ := s.execCommand(s.zpoolPath, "status", "-x", pool.Name)
if strings.Contains(status, "all pools are healthy") {
pool.Status = "ONLINE"
} else if strings.Contains(status, "DEGRADED") {
pool.Status = "DEGRADED"
} else if strings.Contains(status, "FAULTED") {
pool.Status = "FAULTED"
}
// Get creation time
created, _ := s.execCommand(s.zfsPath, "get", "-H", "-o", "value", "creation", pool.Name)
if t, err := time.Parse("Mon Jan 2 15:04:05 2006", created); err == nil {
pool.CreatedAt = t
}
pools = append(pools, pool)
}
return pools, nil
}
// GetPool returns a specific pool
func (s *Service) GetPool(name string) (*models.Pool, error) {
pools, err := s.ListPools()
if err != nil {
return nil, err
}
for _, pool := range pools {
if pool.Name == name {
return &pool, nil
}
}
return nil, fmt.Errorf("pool %s not found", name)
}
// CreatePool creates a new ZFS pool
func (s *Service) CreatePool(name string, vdevs []string, options map[string]string) error {
args := []string{"create"}
if options == nil {
options = make(map[string]string)
}
// Add -f flag to force creation even if devices have existing filesystems
// This handles cases where devices are "in use" or contain "unknown filesystem"
args = append(args, "-f")
// If mountpoint is not explicitly set, use dedicated storage directory
mountpoint := options["mountpoint"]
if mountpoint == "" {
// Default mountpoint: /storage/pools/{poolname}
mountpoint = "/storage/pools/" + name
options["mountpoint"] = mountpoint
}
// Pre-create the mountpoint directory with sudo (non-blocking - log errors but continue)
if mountpoint != "none" {
if err := s.createMountpointWithSudo(mountpoint); err != nil {
// Log the error but don't fail - ZFS might still create the pool
// The mountpoint can be fixed later if needed
log.Printf("warning: failed to pre-create mountpoint %s: %v (continuing anyway)", mountpoint, err)
}
}
// handle canmount as DATASET property not the vdev property
canmount := "noauto"
if v, ok := options["canmount"]; ok && v != "" {
canmount = v
}
delete(options, "canmount")
// Set canmount=noauto to prevent automatic mounting during creation
// This allows pool creation to succeed even if mountpoint can't be created
// if _, hasCanmount := options["canmount"]; !hasCanmount {
// options["canmount"] = "noauto"
// }
// IMPORTANT: Don't set mountpoint during pool creation
// ZFS tries to mount immediately during creation, which can fail
// We'll set mountpoint after pool is created
mountpointOption := options["mountpoint"]
delete(options, "mountpoint") // Remove from options temporarily
// Add remaining options
for k, v := range options {
args = append(args, "-o", fmt.Sprintf("%s=%s", k, v))
}
args = append(args, "-O", fmt.Sprintf("canmount=%s", canmount))
args = append(args, name)
// Normalize vdev paths - ensure they start with /dev/ if they don't already
normalizedVdevs := make([]string, 0, len(vdevs))
for _, vdev := range vdevs {
vdev = strings.TrimSpace(vdev)
if vdev == "" {
continue
}
// If vdev doesn't start with /dev/ or /, assume it's a device name and add /dev/
if !strings.HasPrefix(vdev, "/dev/") && !strings.HasPrefix(vdev, "/") {
vdev = "/dev/" + vdev
}
normalizedVdevs = append(normalizedVdevs, vdev)
}
if len(normalizedVdevs) == 0 {
return fmt.Errorf("no valid vdevs provided after normalization")
}
args = append(args, normalizedVdevs...)
// Log the command we're about to run for debugging
// Note: execCommand will use sudo automatically for zpool commands
log.Printf("executing zpool create: %s %v", s.zpoolPath, args)
log.Printf("pool name: %s, original vdevs: %v, normalized vdevs: %v", name, vdevs, normalizedVdevs)
// Create the pool (without mountpoint to avoid mount errors)
createOutput, err := s.execCommand(s.zpoolPath, args...)
// Log the command output for debugging
if err != nil {
log.Printf("zpool create command failed - output: %s, error: %v", createOutput, err)
log.Printf("this error might be a false positive - checking if pool was actually created...")
} else {
log.Printf("zpool create command succeeded - output: %s", createOutput)
}
// CRITICAL: Always check if pool exists, even if creation reported an error
// ZFS often reports mountpoint errors but pool is still created successfully
// Retry checking pool existence up to 3 times with delays
poolExists := false
for i := 0; i < 3; i++ {
if i > 0 {
// Wait before retry (100ms, 200ms, 300ms)
time.Sleep(time.Duration(i*100) * time.Millisecond)
}
if existingPools, listErr := s.ListPools(); listErr == nil {
log.Printf("checking pool existence (attempt %d/%d): found %d pools", i+1, 3, len(existingPools))
for _, pool := range existingPools {
if pool.Name == name {
poolExists = true
log.Printf("pool %s found after %d check(s)", name, i+1)
break
}
}
} else {
log.Printf("warning: failed to list pools during existence check (attempt %d): %v", i+1, listErr)
}
if poolExists {
break
}
}
if poolExists {
// Pool exists! This is success, regardless of any reported errors
if err != nil {
log.Printf("info: pool %s created successfully despite reported error: %v", name, err)
} else {
log.Printf("info: pool %s created successfully", name)
}
// Clear error since pool was created
err = nil
} else if err != nil {
// Pool doesn't exist and we have an error - return it with full context
log.Printf("error: pool %s creation failed and pool does not exist", name)
log.Printf("error details: %v", err)
log.Printf("command that failed: %s %v", s.zpoolPath, args)
log.Printf("command output: %s", createOutput)
return fmt.Errorf("failed to create pool %s: %v (command: %s %v)", name, err, s.zpoolPath, args)
} else {
// No error reported but pool doesn't exist - this shouldn't happen
log.Printf("warning: pool %s creation reported no error but pool does not exist", name)
log.Printf("command output: %s", createOutput)
return fmt.Errorf("pool %s creation reported success but pool was not found (command: %s %v)", name, s.zpoolPath, args)
}
// Pool created successfully - now set mountpoint and mount if needed
if mountpoint != "none" && mountpointOption != "" && poolExists {
// Ensure mountpoint directory exists
if err := s.createMountpointWithSudo(mountpoint); err != nil {
log.Printf("warning: failed to create mountpoint %s: %v", mountpoint, err)
}
// Set mountpoint property on the root filesystem of the pool
setMountpointArgs := []string{"set", fmt.Sprintf("mountpoint=%s", mountpoint), name}
if _, setErr := s.execCommand(s.zfsPath, setMountpointArgs...); setErr != nil {
log.Printf("warning: failed to set mountpoint property: %v (pool created but not mounted)", setErr)
// Don't return error - pool is created successfully
} else {
// Try to mount the pool
mountArgs := []string{"mount", name}
if _, mountErr := s.execCommand(s.zfsPath, mountArgs...); mountErr != nil {
log.Printf("warning: failed to mount pool: %v (pool created but not mounted)", mountErr)
// Don't return error - pool is created successfully, just not mounted
}
}
}
return nil
}
// createMountpointWithSudo creates a mountpoint directory using sudo
// This allows ZFS to mount pools even if root filesystem appears read-only
func (s *Service) createMountpointWithSudo(path string) error {
// Use sudo to create the directory with proper permissions
cmd := exec.Command("sudo", "-n", "mkdir", "-p", path)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// If sudo mkdir fails, try without sudo (might already be root or have permissions)
directCmd := exec.Command("mkdir", "-p", path)
if directErr := directCmd.Run(); directErr != nil {
// Both failed, but don't return error - ZFS might handle it
// Log but continue, as ZFS might create it or mountpoint might already exist
return fmt.Errorf("failed to create mountpoint %s: %v: %s", path, err, stderr.String())
}
}
return nil
}
// DestroyPool destroys a ZFS pool
func (s *Service) DestroyPool(name string) error {
_, err := s.execCommand(s.zpoolPath, "destroy", name)
return err
}
// ImportPool imports a ZFS pool
func (s *Service) ImportPool(name string, options map[string]string) error {
args := []string{"import"}
// Add options
for k, v := range options {
args = append(args, "-o", fmt.Sprintf("%s=%s", k, v))
}
args = append(args, name)
_, err := s.execCommand(s.zpoolPath, args...)
return err
}
// ExportPool exports a ZFS pool
func (s *Service) ExportPool(name string, force bool) error {
args := []string{"export"}
if force {
args = append(args, "-f")
}
args = append(args, name)
_, err := s.execCommand(s.zpoolPath, args...)
return err
}
// ListAvailablePools returns pools that can be imported
func (s *Service) ListAvailablePools() ([]string, error) {
output, err := s.execCommand(s.zpoolPath, "import")
if err != nil {
return nil, err
}
var pools []string
lines := strings.Split(output, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Parse pool name from output like "pool: tank"
if strings.HasPrefix(line, "pool:") {
parts := strings.Fields(line)
if len(parts) >= 2 {
pools = append(pools, parts[1])
}
}
}
return pools, nil
}
// ScrubPool starts a scrub operation on a pool
func (s *Service) ScrubPool(name string) error {
_, err := s.execCommand(s.zpoolPath, "scrub", name)
return err
}
// ScrubStatus represents detailed scrub operation status
type ScrubStatus struct {
Status string `json:"status"` // idle, in_progress, completed, error
Progress float64 `json:"progress"` // 0-100
TimeElapsed string `json:"time_elapsed"` // e.g., "2h 15m"
TimeRemain string `json:"time_remain"` // e.g., "30m"
Speed string `json:"speed"` // e.g., "100M/s"
Errors int `json:"errors"` // number of errors found
Repaired int `json:"repaired"` // number of errors repaired
LastScrub string `json:"last_scrub"` // timestamp of last completed scrub
}
// GetScrubStatus returns detailed scrub status with progress
func (s *Service) GetScrubStatus(name string) (*ScrubStatus, error) {
status := &ScrubStatus{
Status: "idle",
}
// Get pool status
output, err := s.execCommand(s.zpoolPath, "status", name)
if err != nil {
return nil, err
}
// Parse scrub information
lines := strings.Split(output, "\n")
inScrubSection := false
for _, line := range lines {
line = strings.TrimSpace(line)
// Check if scrub is in progress
if strings.Contains(line, "scrub in progress") {
status.Status = "in_progress"
inScrubSection = true
continue
}
// Check if scrub completed
if strings.Contains(line, "scrub repaired") || strings.Contains(line, "scrub completed") {
status.Status = "completed"
status.Progress = 100.0
// Extract repair information
if strings.Contains(line, "repaired") {
// Try to extract number of repairs
parts := strings.Fields(line)
for i, part := range parts {
if part == "repaired" && i > 0 {
// Previous part might be the number
if repaired, err := strconv.Atoi(parts[i-1]); err == nil {
status.Repaired = repaired
}
}
}
}
continue
}
// Parse progress percentage
if strings.Contains(line, "%") && inScrubSection {
// Extract percentage from line like "scan: 45.2% done"
parts := strings.Fields(line)
for _, part := range parts {
if strings.HasSuffix(part, "%") {
if pct, err := strconv.ParseFloat(strings.TrimSuffix(part, "%"), 64); err == nil {
status.Progress = pct
}
}
}
}
// Parse time elapsed
if strings.Contains(line, "elapsed") && inScrubSection {
// Extract time like "elapsed: 2h15m"
parts := strings.Fields(line)
for i, part := range parts {
if part == "elapsed:" && i+1 < len(parts) {
status.TimeElapsed = parts[i+1]
}
}
}
// Parse time remaining
if strings.Contains(line, "remaining") && inScrubSection {
parts := strings.Fields(line)
for i, part := range parts {
if part == "remaining:" && i+1 < len(parts) {
status.TimeRemain = parts[i+1]
}
}
}
// Parse speed
if strings.Contains(line, "scan rate") && inScrubSection {
parts := strings.Fields(line)
for i, part := range parts {
if part == "rate" && i+1 < len(parts) {
status.Speed = parts[i+1]
}
}
}
// Parse errors
if strings.Contains(line, "errors:") && inScrubSection {
parts := strings.Fields(line)
for i, part := range parts {
if part == "errors:" && i+1 < len(parts) {
if errs, err := strconv.Atoi(parts[i+1]); err == nil {
status.Errors = errs
}
}
}
}
}
// Get last scrub time from pool properties
lastScrub, err := s.execCommand(s.zfsPath, "get", "-H", "-o", "value", "lastscrub", name)
if err == nil && lastScrub != "-" && lastScrub != "" {
status.LastScrub = strings.TrimSpace(lastScrub)
}
return status, nil
}
// ListDatasets returns all datasets in a pool (or all if pool is empty)
func (s *Service) ListDatasets(pool string) ([]models.Dataset, error) {
args := []string{"list", "-H", "-o", "name,type,used,avail,mountpoint"}
if pool != "" {
args = append(args, "-r", pool)
} else {
args = append(args, "-r")
}
output, err := s.execCommand(s.zfsPath, args...)
if err != nil {
// Return empty slice instead of nil to ensure JSON encodes as [] not null
return []models.Dataset{}, err
}
datasets := []models.Dataset{}
lines := strings.Split(output, "\n")
for _, line := range lines {
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
fullName := fields[0]
parts := strings.Split(fullName, "/")
poolName := parts[0]
dataset := models.Dataset{
Name: fullName,
Pool: poolName,
Type: fields[1],
Mountpoint: fields[4],
}
if used, err := parseSize(fields[2]); err == nil {
dataset.Used = used
}
if avail, err := parseSize(fields[3]); err == nil {
dataset.Available = avail
}
dataset.Size = dataset.Used + dataset.Available
// Get creation time
created, _ := s.execCommand(s.zfsPath, "get", "-H", "-o", "value", "creation", fullName)
if t, err := time.Parse("Mon Jan 2 15:04:05 2006", created); err == nil {
dataset.CreatedAt = t
}
datasets = append(datasets, dataset)
}
return datasets, nil
}
// CreateDataset creates a new ZFS dataset
func (s *Service) CreateDataset(name string, options map[string]string) error {
args := []string{"create"}
if options == nil {
options = make(map[string]string)
}
// If mountpoint is not explicitly set, use dedicated storage directory
mountpoint := options["mountpoint"]
if mountpoint == "" {
// Extract dataset name (last part after /)
parts := strings.Split(name, "/")
datasetName := parts[len(parts)-1]
// Default mountpoint: /storage/datasets/{datasetname}
mountpoint = "/storage/datasets/" + datasetName
options["mountpoint"] = mountpoint
// Pre-create the mountpoint directory with sudo
_ = s.createMountpointWithSudo(mountpoint)
} else if mountpoint != "none" {
// Ensure mountpoint directory exists with sudo
_ = s.createMountpointWithSudo(mountpoint)
}
// Add options
for k, v := range options {
args = append(args, "-o", fmt.Sprintf("%s=%s", k, v))
}
args = append(args, name)
_, err := s.execCommand(s.zfsPath, args...)
return err
}
// DestroyDataset destroys a ZFS dataset
func (s *Service) DestroyDataset(name string, recursive bool) error {
args := []string{"destroy"}
if recursive {
args = append(args, "-r")
}
args = append(args, name)
_, err := s.execCommand(s.zfsPath, args...)
return err
}
// ListZVOLs returns all ZVOLs
func (s *Service) ListZVOLs(pool string) ([]models.ZVOL, error) {
args := []string{"list", "-H", "-o", "name,volsize,used", "-t", "volume"}
if pool != "" {
args = append(args, "-r", pool)
} else {
args = append(args, "-r")
}
output, err := s.execCommand(s.zfsPath, args...)
if err != nil {
// Return empty slice instead of nil to ensure JSON encodes as [] not null
return []models.ZVOL{}, err
}
zvols := []models.ZVOL{}
lines := strings.Split(output, "\n")
for _, line := range lines {
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
fullName := fields[0]
parts := strings.Split(fullName, "/")
poolName := parts[0]
zvol := models.ZVOL{
Name: fullName,
Pool: poolName,
}
if size, err := parseSize(fields[1]); err == nil {
zvol.Size = size
}
if used, err := parseSize(fields[2]); err == nil {
zvol.Used = used
}
// Get creation time
created, _ := s.execCommand(s.zfsPath, "get", "-H", "-o", "value", "creation", fullName)
if t, err := time.Parse("Mon Jan 2 15:04:05 2006", created); err == nil {
zvol.CreatedAt = t
}
zvols = append(zvols, zvol)
}
return zvols, nil
}
// CreateZVOL creates a new ZVOL
func (s *Service) CreateZVOL(name string, size uint64, options map[string]string) error {
args := []string{"create", "-V", fmt.Sprintf("%d", size)}
for k, v := range options {
args = append(args, "-o", fmt.Sprintf("%s=%s", k, v))
}
args = append(args, name)
_, err := s.execCommand(s.zfsPath, args...)
return err
}
// DestroyZVOL destroys a ZVOL
func (s *Service) DestroyZVOL(name string) error {
_, err := s.execCommand(s.zfsPath, "destroy", name)
return err
}
// ListDisks returns available disks (read-only)
func (s *Service) ListDisks() ([]map[string]string, error) {
// Use lsblk to list block devices
output, err := s.execCommand("lsblk", "-J", "-o", "name,size,type,fstype,mountpoint")
if err != nil {
return nil, err
}
var result struct {
BlockDevices []struct {
Name string `json:"name"`
Size string `json:"size"`
Type string `json:"type"`
FSType string `json:"fstype"`
Mountpoint string `json:"mountpoint"`
Children []interface{} `json:"children"`
} `json:"blockdevices"`
}
if err := json.Unmarshal([]byte(output), &result); err != nil {
return nil, err
}
var disks []map[string]string
for _, dev := range result.BlockDevices {
if dev.Type == "disk" && dev.FSType == "" && dev.Mountpoint == "" {
disks = append(disks, map[string]string{
"name": dev.Name,
"size": dev.Size,
"path": "/dev/" + dev.Name,
})
}
}
return disks, nil
}
// parseSize converts human-readable size to bytes
func parseSize(s string) (uint64, error) {
s = strings.TrimSpace(s)
if s == "-" || s == "" {
return 0, nil
}
multiplier := uint64(1)
suffix := strings.ToUpper(s[len(s)-1:])
switch suffix {
case "K":
multiplier = 1024
s = s[:len(s)-1]
case "M":
multiplier = 1024 * 1024
s = s[:len(s)-1]
case "G":
multiplier = 1024 * 1024 * 1024
s = s[:len(s)-1]
case "T":
multiplier = 1024 * 1024 * 1024 * 1024
s = s[:len(s)-1]
case "P":
multiplier = 1024 * 1024 * 1024 * 1024 * 1024
s = s[:len(s)-1]
default:
// Check if last char is a digit
if suffix[0] < '0' || suffix[0] > '9' {
return 0, fmt.Errorf("unknown suffix: %s", suffix)
}
}
// Handle decimal values (e.g., "1.5G")
if strings.Contains(s, ".") {
val, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, err
}
return uint64(val * float64(multiplier)), nil
}
val, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, err
}
return val * multiplier, nil
}
// ListSnapshots returns all snapshots for a dataset (or all if dataset is empty)
func (s *Service) ListSnapshots(dataset string) ([]models.Snapshot, error) {
args := []string{"list", "-H", "-o", "name,used,creation", "-t", "snapshot", "-s", "creation"}
if dataset != "" {
args = append(args, "-r", dataset)
} else {
args = append(args, "-r")
}
output, err := s.execCommand(s.zfsPath, args...)
if err != nil {
// Return empty slice instead of nil to ensure JSON encodes as [] not null
return []models.Snapshot{}, err
}
snapshots := []models.Snapshot{}
lines := strings.Split(output, "\n")
for _, line := range lines {
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
fullName := fields[0]
// Snapshot name format: dataset@snapshot
parts := strings.Split(fullName, "@")
if len(parts) != 2 {
continue
}
datasetName := parts[0]
snapshot := models.Snapshot{
Name: fullName,
Dataset: datasetName,
}
// Parse size
if used, err := parseSize(fields[1]); err == nil {
snapshot.Size = used
}
// Parse creation time
// ZFS creation format: "Mon Jan 2 15:04:05 2006"
createdStr := strings.Join(fields[2:], " ")
if t, err := time.Parse("Mon Jan 2 15:04:05 2006", createdStr); err == nil {
snapshot.CreatedAt = t
} else {
// Try RFC3339 format if available
if t, err := time.Parse(time.RFC3339, createdStr); err == nil {
snapshot.CreatedAt = t
}
}
snapshots = append(snapshots, snapshot)
}
return snapshots, nil
}
// CreateSnapshot creates a new snapshot
func (s *Service) CreateSnapshot(dataset, name string, recursive bool) error {
args := []string{"snapshot"}
if recursive {
args = append(args, "-r")
}
snapshotName := fmt.Sprintf("%s@%s", dataset, name)
args = append(args, snapshotName)
_, err := s.execCommand(s.zfsPath, args...)
return err
}
// DestroySnapshot destroys a snapshot
func (s *Service) DestroySnapshot(name string, recursive bool) error {
args := []string{"destroy"}
if recursive {
args = append(args, "-r")
}
args = append(args, name)
_, err := s.execCommand(s.zfsPath, args...)
return err
}
// GetSnapshot returns snapshot details
func (s *Service) GetSnapshot(name string) (*models.Snapshot, error) {
snapshots, err := s.ListSnapshots("")
if err != nil {
return nil, err
}
for _, snap := range snapshots {
if snap.Name == name {
return &snap, nil
}
}
return nil, fmt.Errorf("snapshot %s not found", name)
}