Some checks failed
CI / test-build (push) Has been cancelled
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
932 lines
26 KiB
Go
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)
|
|
}
|