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 { // 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"} 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) } } // 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, name) args = append(args, vdevs...) // Create the pool (without mountpoint to avoid mount errors) _, err := s.execCommand(s.zpoolPath, args...) // CRITICAL: Always check if pool exists, even if creation reported an error // ZFS often reports mountpoint errors but pool is still created successfully poolExists := false if existingPools, listErr := s.ListPools(); listErr == nil { for _, pool := range existingPools { if pool.Name == name { poolExists = true 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) } // Clear error since pool was created err = nil } else if err != nil { // Pool doesn't exist and we have an error - return it return err } // 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) }