Files
atlas/internal/zfs/service.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)
}