728 lines
18 KiB
Go
728 lines
18 KiB
Go
package zfs
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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 {
|
|
// 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
|
|
|
|
if directErr := directCmd.Run(); directErr == nil {
|
|
// Direct execution succeeded, return that result
|
|
return strings.TrimSpace(directStdout.String()), nil
|
|
}
|
|
// Both sudo and direct failed, return the original sudo error
|
|
return "", fmt.Errorf("%s: %v: %s", name, err, stderr.String())
|
|
}
|
|
|
|
if err != nil {
|
|
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"}
|
|
|
|
// Add options
|
|
for k, v := range options {
|
|
args = append(args, "-o", fmt.Sprintf("%s=%s", k, v))
|
|
}
|
|
|
|
args = append(args, name)
|
|
args = append(args, vdevs...)
|
|
|
|
_, err := s.execCommand(s.zpoolPath, args...)
|
|
return err
|
|
}
|
|
|
|
// 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"}
|
|
|
|
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)
|
|
}
|