505 lines
11 KiB
Go
505 lines
11 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 {
|
|
return &Service{
|
|
zfsPath: "zfs",
|
|
zpoolPath: "zpool",
|
|
}
|
|
}
|
|
|
|
// execCommand executes a shell command and returns output
|
|
func (s *Service) execCommand(name string, args ...string) (string, error) {
|
|
cmd := exec.Command(name, args...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); 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 nil, err
|
|
}
|
|
|
|
var 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
|
|
}
|
|
|
|
// ScrubPool starts a scrub operation on a pool
|
|
func (s *Service) ScrubPool(name string) error {
|
|
_, err := s.execCommand(s.zpoolPath, "scrub", name)
|
|
return err
|
|
}
|
|
|
|
// GetScrubStatus returns the current scrub status
|
|
func (s *Service) GetScrubStatus(name string) (string, error) {
|
|
output, err := s.execCommand(s.zpoolPath, "status", name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if strings.Contains(output, "scrub in progress") {
|
|
return "in_progress", nil
|
|
}
|
|
if strings.Contains(output, "scrub repaired") {
|
|
return "completed", nil
|
|
}
|
|
return "idle", 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 nil, err
|
|
}
|
|
|
|
var 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 nil, err
|
|
}
|
|
|
|
var 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 nil, err
|
|
}
|
|
|
|
var 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)
|
|
}
|