|
|
|
@@ -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
|
|
|
|
|
}
|
|
|
|
|