fixing UI and iscsi sync
Some checks failed
CI / test-build (push) Has been cancelled

This commit is contained in:
2025-12-20 19:16:50 +00:00
parent 2bb892dfdc
commit 6202ef8e83
12 changed files with 1436 additions and 116 deletions

View File

@@ -6,6 +6,7 @@ import (
"log"
"net/http"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
@@ -124,6 +125,17 @@ func (a *App) handleGetPool(w http.ResponseWriter, r *http.Request) {
return
}
// Check if detail is requested
if r.URL.Query().Get("detail") == "true" {
detail, err := a.zfs.GetPoolDetail(name)
if err != nil {
writeError(w, errors.ErrNotFound("pool not found").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, detail)
return
}
pool, err := a.zfs.GetPool(name)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
@@ -133,6 +145,43 @@ func (a *App) handleGetPool(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, pool)
}
func (a *App) handleAddSpareDisk(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/pools/")
name = strings.TrimSuffix(name, "/spare")
if name == "" {
writeError(w, errors.ErrBadRequest("pool name required"))
return
}
var req struct {
Disk string `json:"disk"` // Disk path like /dev/sdb or sdb
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, errors.ErrBadRequest("invalid request body"))
return
}
if req.Disk == "" {
writeError(w, errors.ErrValidation("disk path required"))
return
}
// Ensure disk path starts with /dev/ if not already
diskPath := req.Disk
if !strings.HasPrefix(diskPath, "/dev/") {
diskPath = "/dev/" + diskPath
}
if err := a.zfs.AddSpareDisk(name, diskPath); err != nil {
log.Printf("add spare disk error: %v", err)
writeError(w, errors.ErrInternal("failed to add spare disk").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "spare disk added", "pool": name, "disk": diskPath})
}
func (a *App) handleDeletePool(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/pools/")
if name == "" {
@@ -753,6 +802,13 @@ func (a *App) handleDeleteSnapshotPolicy(w http.ResponseWriter, r *http.Request)
// SMB Share Handlers
func (a *App) handleListSMBShares(w http.ResponseWriter, r *http.Request) {
// Sync shares from OS (smb.conf) to store
// This ensures shares created before service restart are visible
if err := a.syncSMBSharesFromOS(); err != nil {
log.Printf("warning: failed to sync SMB shares from OS: %v", err)
// Continue anyway - return what's in store
}
shares := a.smbStore.List()
writeJSON(w, http.StatusOK, shares)
}
@@ -1151,6 +1207,11 @@ func (a *App) handleDeleteNFSExport(w http.ResponseWriter, r *http.Request) {
// iSCSI Handlers
func (a *App) handleListISCSITargets(w http.ResponseWriter, r *http.Request) {
// Sync targets from OS before listing
if err := a.syncISCSITargetsFromOS(); err != nil {
log.Printf("warning: failed to sync iSCSI targets from OS: %v", err)
}
targets := a.iscsiStore.List()
writeJSON(w, http.StatusOK, targets)
}
@@ -1158,37 +1219,63 @@ func (a *App) handleListISCSITargets(w http.ResponseWriter, r *http.Request) {
func (a *App) handleCreateISCSITarget(w http.ResponseWriter, r *http.Request) {
var req struct {
IQN string `json:"iqn"`
Type string `json:"type"` // "disk" or "tape" (default: "disk")
Initiators []string `json:"initiators"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
log.Printf("create iSCSI target: invalid request body: %v", err)
writeError(w, errors.ErrBadRequest("invalid request body").WithDetails(err.Error()))
return
}
// Validate and set target type
targetType := models.ISCSITargetTypeDisk // Default to disk mode
if req.Type != "" {
if req.Type != "disk" && req.Type != "tape" {
writeError(w, errors.ErrValidation("invalid target type: must be 'disk' or 'tape'"))
return
}
targetType = models.ISCSITargetType(req.Type)
}
log.Printf("create iSCSI target: IQN=%s, Type=%s, Initiators=%v", req.IQN, targetType, req.Initiators)
// Validate IQN format
if err := validation.ValidateIQN(req.IQN); err != nil {
log.Printf("IQN validation error: %v (IQN: %s)", err, req.IQN)
writeError(w, errors.ErrValidation(err.Error()))
return
}
target, err := a.iscsiStore.Create(req.IQN, req.Initiators)
target, err := a.iscsiStore.CreateWithType(req.IQN, targetType, req.Initiators)
if err != nil {
if err == storage.ErrISCSITargetExists {
writeJSON(w, http.StatusConflict, map[string]string{"error": "target with this IQN already exists"})
log.Printf("create iSCSI target: target already exists (IQN: %s)", req.IQN)
writeError(w, errors.ErrConflict("target with this IQN already exists"))
return
}
log.Printf("create iSCSI target error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
writeError(w, errors.ErrInternal("failed to create iSCSI target").WithDetails(err.Error()))
return
}
log.Printf("create iSCSI target: target created in store (ID: %s, IQN: %s)", target.ID, target.IQN)
// Apply configuration to iSCSI service
targets := a.iscsiStore.List()
if err := a.iscsiService.ApplyConfiguration(targets); err != nil {
log.Printf("apply iSCSI configuration error: %v", err)
log.Printf("create iSCSI target: apply configuration error: %v", err)
// Don't fail the request if configuration fails - target is already in store
// User can retry configuration later
writeJSON(w, http.StatusCreated, map[string]interface{}{
"target": target,
"warning": "target created but configuration may have failed. check logs.",
})
return
}
log.Printf("create iSCSI target: success (ID: %s, IQN: %s)", target.ID, target.IQN)
writeJSON(w, http.StatusCreated, target)
}
@@ -1325,7 +1412,10 @@ func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
id := parts[0]
var req struct {
ZVOL string `json:"zvol"`
ZVOL string `json:"zvol"` // ZVOL name (for block backstore)
Device string `json:"device"` // Device path (e.g., /dev/st0 for tape)
Backstore string `json:"backstore"` // Backstore type: "block", "pscsi", "fileio" (optional, auto-detected)
BackstoreName string `json:"backstore_name"` // Custom backstore name (optional)
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -1333,35 +1423,55 @@ func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
return
}
if req.ZVOL == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol is required"})
return
}
// Validate ZVOL exists
zvols, err := a.zfs.ListZVOLs("")
if err != nil {
log.Printf("list zvols error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to validate zvol"})
// Validate: must have either ZVOL or Device
if req.ZVOL == "" && req.Device == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "either zvol or device is required"})
return
}
var zvolSize uint64
zvolExists := false
for _, zvol := range zvols {
if zvol.Name == req.ZVOL {
zvolExists = true
zvolSize = zvol.Size
break
var zvolName string
if req.ZVOL != "" {
// Validate ZVOL exists
zvols, err := a.zfs.ListZVOLs("")
if err != nil {
log.Printf("list zvols error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to validate zvol"})
return
}
zvolExists := false
for _, zvol := range zvols {
if zvol.Name == req.ZVOL {
zvolExists = true
zvolSize = zvol.Size
zvolName = zvol.Name
break
}
}
if !zvolExists {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol not found"})
return
}
} else if req.Device != "" {
// Validate device exists
if _, err := os.Stat(req.Device); err != nil {
if os.IsNotExist(err) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("device not found: %s", req.Device)})
return
}
log.Printf("stat device error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to validate device"})
return
}
// For tape devices, size is typically 0 or unknown
zvolSize = 0
}
if !zvolExists {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol not found"})
return
}
lun, err := a.iscsiStore.AddLUN(id, req.ZVOL, zvolSize)
// Use updated AddLUN signature that supports device and backstore
lun, err := a.iscsiStore.AddLUNWithDevice(id, zvolName, req.Device, zvolSize, req.Backstore, req.BackstoreName)
if err != nil {
if err == storage.ErrISCSITargetNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "target not found"})
@@ -1804,3 +1914,326 @@ func (a *App) syncNFSExportsFromOS() error {
return nil
}
// syncSMBSharesFromOS syncs SMB shares from /etc/samba/smb.conf to the store
func (a *App) syncSMBSharesFromOS() error {
configPath := "/etc/samba/smb.conf"
cmd := exec.Command("sudo", "-n", "cat", configPath)
output, err := cmd.Output()
if err != nil {
// If can't read smb.conf, that's okay - might not exist yet
return nil
}
lines := strings.Split(string(output), "\n")
currentShare := ""
inShareSection := false
sharePath := ""
shareReadOnly := false
shareGuestOK := false
shareDescription := ""
shareValidUsers := []string{}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
// Check if this is a share section
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
// Save previous share if exists
if inShareSection && currentShare != "" && sharePath != "" {
// Try to find corresponding dataset
datasets, err := a.zfs.ListDatasets("")
var dataset string
if err == nil {
for _, ds := range datasets {
if ds.Mountpoint == sharePath {
dataset = ds.Name
break
}
}
}
// Check if share already exists
existingShares := a.smbStore.List()
exists := false
for _, share := range existingShares {
if share.Name == currentShare || share.Path == sharePath {
exists = true
break
}
}
if !exists {
_, err = a.smbStore.Create(currentShare, sharePath, dataset, shareDescription, shareReadOnly, shareGuestOK, shareValidUsers)
if err != nil && err != storage.ErrSMBShareExists {
log.Printf("warning: failed to sync SMB share %s: %v", currentShare, err)
}
}
}
// Start new share section
shareName := strings.Trim(line, "[]")
if shareName != "global" && shareName != "printers" && shareName != "print$" {
currentShare = shareName
inShareSection = true
sharePath = ""
shareReadOnly = false
shareGuestOK = false
shareDescription = ""
shareValidUsers = []string{}
} else {
inShareSection = false
currentShare = ""
}
continue
}
// Parse share properties
if inShareSection && currentShare != "" {
if strings.HasPrefix(line, "path = ") {
sharePath = strings.TrimSpace(strings.TrimPrefix(line, "path = "))
} else if strings.HasPrefix(line, "read only = ") {
value := strings.TrimSpace(strings.TrimPrefix(line, "read only = "))
shareReadOnly = (value == "yes" || value == "true")
} else if strings.HasPrefix(line, "guest ok = ") {
value := strings.TrimSpace(strings.TrimPrefix(line, "guest ok = "))
shareGuestOK = (value == "yes" || value == "true")
} else if strings.HasPrefix(line, "comment = ") {
shareDescription = strings.TrimSpace(strings.TrimPrefix(line, "comment = "))
} else if strings.HasPrefix(line, "valid users = ") {
usersStr := strings.TrimSpace(strings.TrimPrefix(line, "valid users = "))
shareValidUsers = strings.Split(usersStr, ",")
for i := range shareValidUsers {
shareValidUsers[i] = strings.TrimSpace(shareValidUsers[i])
}
}
}
}
// Save last share if exists
if inShareSection && currentShare != "" && sharePath != "" {
datasets, err := a.zfs.ListDatasets("")
var dataset string
if err == nil {
for _, ds := range datasets {
if ds.Mountpoint == sharePath {
dataset = ds.Name
break
}
}
}
existingShares := a.smbStore.List()
exists := false
for _, share := range existingShares {
if share.Name == currentShare || share.Path == sharePath {
exists = true
break
}
}
if !exists {
_, err = a.smbStore.Create(currentShare, sharePath, dataset, shareDescription, shareReadOnly, shareGuestOK, shareValidUsers)
if err != nil && err != storage.ErrSMBShareExists {
log.Printf("warning: failed to sync SMB share %s: %v", currentShare, err)
}
}
}
return nil
}
// syncISCSITargetsFromOS syncs iSCSI targets from targetcli to the store
func (a *App) syncISCSITargetsFromOS() error {
log.Printf("debug: starting syncISCSITargetsFromOS")
// Get list of targets from targetcli
// Set TARGETCLI_HOME and TARGETCLI_LOCK_DIR to writable directories
// Create the directories first if they don't exist
os.MkdirAll("/tmp/.targetcli", 0755)
os.MkdirAll("/tmp/targetcli-run", 0755)
// Use sudo to run as root, then set environment variables in the command
cmd := exec.Command("sh", "-c", "sudo -n sh -c 'TARGETCLI_HOME=/tmp/.targetcli TARGETCLI_LOCK_DIR=/tmp/targetcli-run targetcli /iscsi ls'")
output, err := cmd.CombinedOutput()
if err != nil {
// Log the error but don't fail - targetcli might not be configured
log.Printf("warning: failed to list iSCSI targets from targetcli: %v (output: %s)", err, string(output))
return nil
}
log.Printf("debug: targetcli output: %s", string(output))
lines := strings.Split(string(output), "\n")
var currentIQN string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Check if this is a target line (starts with "o- iqn.")
if strings.HasPrefix(line, "o- iqn.") {
log.Printf("debug: found target line: %s", line)
// Extract IQN from line like "o- iqn.2025-12.com.atlas:target-1"
parts := strings.Fields(line)
if len(parts) >= 2 {
currentIQN = parts[1]
// Check if target already exists in store
existingTargets := a.iscsiStore.List()
exists := false
for _, t := range existingTargets {
if t.IQN == currentIQN {
exists = true
break
}
}
if !exists {
// Try to determine target type from IQN
targetType := models.ISCSITargetTypeDisk // Default to disk mode
if strings.Contains(strings.ToLower(currentIQN), "tape") {
targetType = models.ISCSITargetTypeTape
}
// Create target in store
target, err := a.iscsiStore.CreateWithType(currentIQN, targetType, []string{})
if err != nil && err != storage.ErrISCSITargetExists {
log.Printf("warning: failed to sync iSCSI target %s: %v", currentIQN, err)
} else if err == nil {
log.Printf("synced iSCSI target from OS: %s (type: %s)", currentIQN, targetType)
// Now try to sync LUNs for this target
if err := a.syncLUNsFromOS(currentIQN, target.ID, targetType); err != nil {
log.Printf("warning: failed to sync LUNs for target %s: %v", currentIQN, err)
}
}
}
}
}
}
return nil
}
// syncLUNsFromOS syncs LUNs for a specific target from targetcli
func (a *App) syncLUNsFromOS(iqn, targetID string, targetType models.ISCSITargetType) error {
// Get LUNs for this target
// Use sudo to run as root, then set environment variables in the command
cmd := exec.Command("sh", "-c", "sudo -n sh -c 'TARGETCLI_HOME=/tmp/.targetcli TARGETCLI_LOCK_DIR=/tmp/targetcli-run targetcli /iscsi/"+iqn+"/tpg1/luns ls'")
output, err := cmd.CombinedOutput()
if err != nil {
// No LUNs or can't read - that's okay, log for debugging
log.Printf("debug: failed to list LUNs for target %s: %v (output: %s)", iqn, err, string(output))
return nil
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "o- lun") {
// Parse LUN line like "o- lun0 ....................................... [block/pool-test-02-vol01 (/dev/zvol/pool-test-02/vol01) (default_tg_pt_gp)]"
parts := strings.Fields(line)
if len(parts) >= 2 {
// Extract LUN ID from "lun0"
lunIDStr := strings.TrimPrefix(parts[1], "lun")
lunID, err := strconv.Atoi(lunIDStr)
if err != nil {
continue
}
// Extract backstore path and device from the line
var backstorePath string
var devicePath string
var zvolName string
// Find the part with brackets - might span multiple parts
fullLine := strings.Join(parts, " ")
start := strings.Index(fullLine, "[")
end := strings.LastIndex(fullLine, "]")
if start >= 0 && end > start {
content := fullLine[start+1 : end]
// Parse content like "block/pool-test-02-vol01 (/dev/zvol/pool-test-02/vol01)"
if strings.Contains(content, "(") {
// Has device path
parts2 := strings.Split(content, "(")
if len(parts2) >= 2 {
backstorePath = strings.TrimSpace(parts2[0])
devicePath = strings.Trim(strings.TrimSpace(parts2[1]), "()")
// If device is a zvol, extract ZVOL name
if strings.HasPrefix(devicePath, "/dev/zvol/") {
zvolName = strings.TrimPrefix(devicePath, "/dev/zvol/")
}
}
} else {
backstorePath = content
}
}
// Check if LUN already exists
target, err := a.iscsiStore.Get(targetID)
if err != nil {
continue
}
lunExists := false
for _, lun := range target.LUNs {
if lun.ID == lunID {
lunExists = true
break
}
}
if !lunExists {
// Determine backstore type
backstoreType := "block"
if strings.HasPrefix(backstorePath, "pscsi/") {
backstoreType = "pscsi"
} else if strings.HasPrefix(backstorePath, "fileio/") {
backstoreType = "fileio"
}
// Get size if it's a ZVOL
var size uint64
if zvolName != "" {
zvols, err := a.zfs.ListZVOLs("")
if err == nil {
for _, zvol := range zvols {
if zvol.Name == zvolName {
size = zvol.Size
break
}
}
}
}
// Add LUN to store
if targetType == models.ISCSITargetTypeTape && devicePath != "" {
// Tape mode: use device
_, err := a.iscsiStore.AddLUNWithDevice(targetID, "", devicePath, size, backstoreType, "")
if err != nil && err != storage.ErrLUNExists {
log.Printf("warning: failed to sync LUN %d for target %s: %v", lunID, iqn, err)
}
} else if zvolName != "" {
// Disk mode: use ZVOL
_, err := a.iscsiStore.AddLUNWithDevice(targetID, zvolName, "", size, backstoreType, "")
if err != nil && err != storage.ErrLUNExists {
log.Printf("warning: failed to sync LUN %d for target %s: %v", lunID, iqn, err)
}
} else if devicePath != "" {
// Generic device
_, err := a.iscsiStore.AddLUNWithDevice(targetID, "", devicePath, size, backstoreType, "")
if err != nil && err != storage.ErrLUNExists {
log.Printf("warning: failed to sync LUN %d for target %s: %v", lunID, iqn, err)
}
}
}
}
}
}
return nil
}