2368 lines
75 KiB
Go
2368 lines
75 KiB
Go
package scst
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/atlasos/calypso/internal/common/database"
|
|
"github.com/atlasos/calypso/internal/common/logger"
|
|
)
|
|
|
|
// Service handles SCST operations
|
|
type Service struct {
|
|
db *database.DB
|
|
logger *logger.Logger
|
|
}
|
|
|
|
// NewService creates a new SCST service
|
|
func NewService(db *database.DB, log *logger.Logger) *Service {
|
|
return &Service{
|
|
db: db,
|
|
logger: log,
|
|
}
|
|
}
|
|
|
|
// Target represents an SCST iSCSI target
|
|
type Target struct {
|
|
ID string `json:"id"`
|
|
IQN string `json:"iqn"`
|
|
Alias string `json:"alias,omitempty"` // Alias for frontend compatibility (uses name)
|
|
TargetType string `json:"target_type"` // 'disk', 'vtl', 'physical_tape'
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
IsActive bool `json:"is_active"`
|
|
SingleInitiatorOnly bool `json:"single_initiator_only"`
|
|
LUNCount int `json:"lun_count"` // Number of LUNs attached to this target
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CreatedBy string `json:"created_by"`
|
|
}
|
|
|
|
// LUN represents an SCST LUN mapping
|
|
type LUN struct {
|
|
ID string `json:"id"`
|
|
TargetID string `json:"target_id"`
|
|
LUNNumber int `json:"lun_number"`
|
|
DeviceName string `json:"device_name"`
|
|
DevicePath string `json:"device_path"`
|
|
HandlerType string `json:"handler_type"`
|
|
Handler string `json:"handler"` // Alias for handler_type for frontend compatibility
|
|
DeviceType string `json:"device_type"` // Derived from handler_type
|
|
IsActive bool `json:"is_active"` // True if LUN exists in SCST
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// InitiatorGroup represents an SCST initiator group
|
|
type InitiatorGroup struct {
|
|
ID string `json:"id"`
|
|
TargetID string `json:"target_id"`
|
|
GroupName string `json:"group_name"`
|
|
Initiators []Initiator `json:"initiators"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Initiator represents an iSCSI initiator
|
|
type Initiator struct {
|
|
ID string `json:"id"`
|
|
GroupID string `json:"group_id"`
|
|
IQN string `json:"iqn"`
|
|
IsActive bool `json:"is_active"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Portal represents an iSCSI network portal (IP address and port)
|
|
type Portal struct {
|
|
ID string `json:"id"`
|
|
IPAddress string `json:"ip_address"` // IPv4 or IPv6 address, or "0.0.0.0" for all interfaces
|
|
Port int `json:"port"` // Default 3260 for iSCSI
|
|
IsActive bool `json:"is_active"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Extent represents an SCST device extent (opened device)
|
|
type Extent struct {
|
|
HandlerType string `json:"handler_type"`
|
|
DeviceName string `json:"device_name"`
|
|
DevicePath string `json:"device_path"` // Path to the actual device/file
|
|
IsInUse bool `json:"is_in_use"` // True if device is used by any LUN
|
|
LUNCount int `json:"lun_count"` // Number of LUNs using this device
|
|
}
|
|
|
|
// CreateTarget creates a new SCST iSCSI target
|
|
func (s *Service) CreateTarget(ctx context.Context, target *Target) error {
|
|
// Validate IQN format
|
|
if !strings.HasPrefix(target.IQN, "iqn.") {
|
|
return fmt.Errorf("invalid IQN format")
|
|
}
|
|
|
|
// Create target in SCST
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_target", target.IQN, "-driver", "iscsi")
|
|
output, err := cmd.CombinedOutput()
|
|
outputStr := string(output)
|
|
|
|
if err != nil {
|
|
// Check if target already exists
|
|
if strings.Contains(outputStr, "already exists") {
|
|
s.logger.Warn("Target already exists in SCST", "iqn", target.IQN)
|
|
} else {
|
|
// Check for common SCST errors
|
|
if strings.Contains(outputStr, "User space process is not connected") ||
|
|
strings.Contains(outputStr, "iscsi-scstd") {
|
|
return fmt.Errorf("iSCSI daemon (iscsi-scstd) is not running. Please start it with: systemctl start iscsi-scstd")
|
|
}
|
|
if strings.Contains(outputStr, "Failed to add target") {
|
|
return fmt.Errorf("failed to add target to SCST: %s. Check dmesg for details", strings.TrimSpace(outputStr))
|
|
}
|
|
return fmt.Errorf("failed to create SCST target: %s: %w", outputStr, err)
|
|
}
|
|
}
|
|
|
|
// Check for warnings in output even if command succeeded
|
|
if strings.Contains(outputStr, "WARNING") || strings.Contains(outputStr, "Failed to add target") {
|
|
s.logger.Warn("SCST warning during target creation", "iqn", target.IQN, "output", outputStr)
|
|
// Check dmesg for more details
|
|
dmesgCmd := exec.CommandContext(ctx, "dmesg", "-T", "-l", "err,warn")
|
|
if dmesgOutput, dmesgErr := dmesgCmd.CombinedOutput(); dmesgErr == nil {
|
|
recentErrors := string(dmesgOutput)
|
|
if strings.Contains(recentErrors, "iscsi-scst") {
|
|
return fmt.Errorf("iSCSI daemon (iscsi-scstd) is not running. Please start it with: systemctl start iscsi-scstd")
|
|
}
|
|
}
|
|
return fmt.Errorf("target creation completed but SCST reported an error: %s. Check dmesg for details", strings.TrimSpace(outputStr))
|
|
}
|
|
|
|
// Insert into database
|
|
query := `
|
|
INSERT INTO scst_targets (
|
|
iqn, target_type, name, description, is_active,
|
|
single_initiator_only, created_by
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, created_at, updated_at
|
|
`
|
|
|
|
err = s.db.QueryRowContext(ctx, query,
|
|
target.IQN, target.TargetType, target.Name, target.Description,
|
|
target.IsActive, target.SingleInitiatorOnly, target.CreatedBy,
|
|
).Scan(&target.ID, &target.CreatedAt, &target.UpdatedAt)
|
|
if err != nil {
|
|
// Rollback: remove from SCST
|
|
exec.CommandContext(ctx, "sudo", "scstadmin", "-remove_target", target.IQN, "-driver", "iscsi").Run()
|
|
return fmt.Errorf("failed to save target to database: %w", err)
|
|
}
|
|
|
|
s.logger.Info("SCST target created", "iqn", target.IQN, "type", target.TargetType)
|
|
|
|
// Apply active portals to the new target
|
|
if err := s.applyPortalsToTarget(ctx, target.IQN); err != nil {
|
|
s.logger.Warn("Failed to apply portals to new target", "iqn", target.IQN, "error", err)
|
|
// Don't fail target creation if portal application fails
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AddLUN assigns an extent (device) to initiator groups as a LUN
|
|
// Note: The device/extent should already be created and opened in SCST
|
|
// This function only assigns the LUN to initiator groups, not to the target itself
|
|
func (s *Service) AddLUN(ctx context.Context, targetIQN, deviceName, devicePath string, lunNumber int, handlerType string) error {
|
|
// Get target ID
|
|
var targetID string
|
|
err := s.db.QueryRowContext(ctx, "SELECT id FROM scst_targets WHERE iqn = $1", targetIQN).Scan(&targetID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get target ID: %w", err)
|
|
}
|
|
|
|
// Get all initiator groups for this target
|
|
groups, err := s.GetTargetInitiatorGroups(ctx, targetID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get initiator groups: %w", err)
|
|
}
|
|
|
|
if len(groups) == 0 {
|
|
return fmt.Errorf("no initiator groups found for target %s. Please create an initiator group first", targetIQN)
|
|
}
|
|
|
|
// Assign LUN to each group
|
|
// Command format: scstadmin -add_lun <lun_number> -driver iscsi -target <target_iqn> -group <group_name> -device <device_name>
|
|
for _, group := range groups {
|
|
assignCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_lun", fmt.Sprintf("%d", lunNumber),
|
|
"-driver", "iscsi",
|
|
"-target", targetIQN,
|
|
"-group", group.GroupName,
|
|
"-device", deviceName)
|
|
output, err := assignCmd.CombinedOutput()
|
|
outputStr := string(output)
|
|
|
|
// Log the command output for debugging
|
|
s.logger.Info("Assigning LUN to initiator group",
|
|
"target", targetIQN,
|
|
"group", group.GroupName,
|
|
"lun", lunNumber,
|
|
"device", deviceName,
|
|
"command", fmt.Sprintf("scstadmin -add_lun %d -driver iscsi -target %s -group %s -device %s", lunNumber, targetIQN, group.GroupName, deviceName),
|
|
"output", outputStr)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to assign LUN to initiator group %s: %s: %w", group.GroupName, outputStr, err)
|
|
}
|
|
|
|
s.logger.Info("LUN assigned to initiator group",
|
|
"target", targetIQN,
|
|
"group", group.GroupName,
|
|
"lun", lunNumber,
|
|
"device", deviceName)
|
|
}
|
|
|
|
// Insert into database
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO scst_luns (target_id, lun_number, device_name, device_path, handler_type)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (target_id, lun_number) DO UPDATE SET
|
|
device_name = EXCLUDED.device_name,
|
|
device_path = EXCLUDED.device_path,
|
|
handler_type = EXCLUDED.handler_type
|
|
`, targetID, lunNumber, deviceName, devicePath, handlerType)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save LUN to database: %w", err)
|
|
}
|
|
|
|
// Write config to persist changes
|
|
configPath := "/etc/calypso/scst/generated.conf"
|
|
if err := s.WriteConfig(ctx, configPath); err != nil {
|
|
s.logger.Warn("Failed to write config after LUN assignment", "error", err)
|
|
// Don't fail the assignment if config write fails
|
|
}
|
|
|
|
s.logger.Info("LUN assigned to initiator groups", "target", targetIQN, "lun", lunNumber, "device", deviceName)
|
|
return nil
|
|
}
|
|
|
|
// RemoveLUN removes a LUN from a target
|
|
func (s *Service) RemoveLUN(ctx context.Context, targetIQN string, lunNumber int) error {
|
|
// Remove LUN from SCST first
|
|
// scstadmin -rem_lun may require interactive confirmation, so we pipe "y" to it
|
|
remCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-rem_lun", fmt.Sprintf("%d", lunNumber),
|
|
"-driver", "iscsi",
|
|
"-target", targetIQN, "-noprompt")
|
|
output, err := remCmd.CombinedOutput()
|
|
outputStr := string(output)
|
|
|
|
// Log the command output for debugging
|
|
s.logger.Info("Removing LUN from SCST", "target", targetIQN, "lun", lunNumber, "output", outputStr)
|
|
|
|
if err != nil {
|
|
// Check if LUN doesn't exist in SCST (not an error, but log it)
|
|
if strings.Contains(outputStr, "not found") || strings.Contains(outputStr, "does not exist") {
|
|
s.logger.Info("LUN not found in SCST, continuing with database deletion", "target", targetIQN, "lun", lunNumber)
|
|
} else {
|
|
// Real error - return error to prevent database deletion
|
|
s.logger.Error("Failed to remove LUN from SCST", "target", targetIQN, "lun", lunNumber, "output", outputStr, "error", err)
|
|
return fmt.Errorf("failed to remove LUN from SCST: %s: %w", outputStr, err)
|
|
}
|
|
} else {
|
|
// Command succeeded - verify by checking output
|
|
if strings.Contains(outputStr, "Removing") || strings.Contains(outputStr, "done") || strings.Contains(outputStr, "All done") {
|
|
s.logger.Info("LUN removed from SCST successfully", "target", targetIQN, "lun", lunNumber, "output", outputStr)
|
|
} else {
|
|
// Output doesn't indicate success, but no error - log warning
|
|
s.logger.Warn("LUN removal command completed but output unclear", "target", targetIQN, "lun", lunNumber, "output", outputStr)
|
|
}
|
|
}
|
|
|
|
// Get target ID
|
|
var targetID string
|
|
err = s.db.QueryRowContext(ctx, "SELECT id FROM scst_targets WHERE iqn = $1", targetIQN).Scan(&targetID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("target not found")
|
|
}
|
|
return fmt.Errorf("failed to get target ID: %w", err)
|
|
}
|
|
|
|
// Remove from database
|
|
_, err = s.db.ExecContext(ctx, `
|
|
DELETE FROM scst_luns
|
|
WHERE target_id = $1 AND lun_number = $2
|
|
`, targetID, lunNumber)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove LUN from database: %w", err)
|
|
}
|
|
|
|
// Write config to persist changes
|
|
configPath := "/etc/calypso/scst/generated.conf"
|
|
if err := s.WriteConfig(ctx, configPath); err != nil {
|
|
s.logger.Warn("Failed to write config after LUN deletion", "error", err)
|
|
// Don't fail the deletion if config write fails
|
|
}
|
|
|
|
s.logger.Info("LUN removed", "target", targetIQN, "lun", lunNumber)
|
|
return nil
|
|
}
|
|
|
|
// AddInitiator adds an initiator to a target
|
|
func (s *Service) AddInitiator(ctx context.Context, targetIQN, initiatorIQN string) error {
|
|
// Get target from database
|
|
var targetID string
|
|
var singleInitiatorOnly bool
|
|
err := s.db.QueryRowContext(ctx,
|
|
"SELECT id, single_initiator_only FROM scst_targets WHERE iqn = $1",
|
|
targetIQN,
|
|
).Scan(&targetID, &singleInitiatorOnly)
|
|
if err != nil {
|
|
return fmt.Errorf("target not found: %w", err)
|
|
}
|
|
|
|
// Check single initiator policy
|
|
if singleInitiatorOnly {
|
|
var existingCount int
|
|
s.db.QueryRowContext(ctx,
|
|
"SELECT COUNT(*) FROM scst_initiators WHERE group_id IN (SELECT id FROM scst_initiator_groups WHERE target_id = $1)",
|
|
targetID,
|
|
).Scan(&existingCount)
|
|
if existingCount > 0 {
|
|
return fmt.Errorf("target enforces single initiator only")
|
|
}
|
|
}
|
|
|
|
// Get or create initiator group
|
|
var groupID string
|
|
groupName := targetIQN + "_acl"
|
|
err = s.db.QueryRowContext(ctx,
|
|
"SELECT id FROM scst_initiator_groups WHERE target_id = $1 AND group_name = $2",
|
|
targetID, groupName,
|
|
).Scan(&groupID)
|
|
|
|
if err == sql.ErrNoRows {
|
|
// Create group in SCST
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_group", groupName,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create initiator group: %s: %w", string(output), err)
|
|
}
|
|
|
|
// Insert into database
|
|
err = s.db.QueryRowContext(ctx,
|
|
"INSERT INTO scst_initiator_groups (target_id, group_name) VALUES ($1, $2) RETURNING id",
|
|
targetID, groupName,
|
|
).Scan(&groupID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save group to database: %w", err)
|
|
}
|
|
} else if err != nil {
|
|
return fmt.Errorf("failed to get initiator group: %w", err)
|
|
}
|
|
|
|
// Add initiator to group in SCST
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_init", initiatorIQN,
|
|
"-group", groupName,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add initiator: %s: %w", string(output), err)
|
|
}
|
|
|
|
// Insert into database
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO scst_initiators (group_id, iqn, is_active)
|
|
VALUES ($1, $2, true)
|
|
ON CONFLICT (group_id, iqn) DO UPDATE SET is_active = true
|
|
`, groupID, initiatorIQN)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save initiator to database: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Initiator added", "target", targetIQN, "initiator", initiatorIQN)
|
|
return nil
|
|
}
|
|
|
|
// GetTargetInitiatorGroups retrieves all initiator groups for a target
|
|
func (s *Service) GetTargetInitiatorGroups(ctx context.Context, targetID string) ([]InitiatorGroup, error) {
|
|
// Get all groups for this target
|
|
query := `
|
|
SELECT id, target_id, group_name, created_at
|
|
FROM scst_initiator_groups
|
|
WHERE target_id = $1
|
|
ORDER BY group_name
|
|
`
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, targetID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get initiator groups: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var groups []InitiatorGroup
|
|
for rows.Next() {
|
|
var group InitiatorGroup
|
|
err := rows.Scan(&group.ID, &group.TargetID, &group.GroupName, &group.CreatedAt)
|
|
if err != nil {
|
|
s.logger.Error("Failed to scan initiator group", "error", err)
|
|
continue
|
|
}
|
|
|
|
// Get initiators for this group
|
|
initiators, err := s.getGroupInitiators(ctx, group.ID)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to get initiators for group", "group_id", group.ID, "error", err)
|
|
group.Initiators = []Initiator{}
|
|
} else {
|
|
group.Initiators = initiators
|
|
}
|
|
|
|
groups = append(groups, group)
|
|
}
|
|
|
|
return groups, rows.Err()
|
|
}
|
|
|
|
// InitiatorWithTarget extends Initiator with target information
|
|
type InitiatorWithTarget struct {
|
|
Initiator
|
|
TargetID string `json:"target_id"`
|
|
TargetIQN string `json:"target_iqn"`
|
|
TargetName string `json:"target_name"`
|
|
GroupName string `json:"group_name"`
|
|
}
|
|
|
|
// ListAllInitiators lists all initiators across all targets
|
|
func (s *Service) ListAllInitiators(ctx context.Context) ([]InitiatorWithTarget, error) {
|
|
query := `
|
|
SELECT i.id, i.group_id, i.iqn, i.is_active, i.created_at,
|
|
ig.target_id, ig.group_name, t.iqn as target_iqn, t.name as target_name
|
|
FROM scst_initiators i
|
|
JOIN scst_initiator_groups ig ON i.group_id = ig.id
|
|
JOIN scst_targets t ON ig.target_id = t.id
|
|
ORDER BY t.iqn, ig.group_name, i.iqn
|
|
`
|
|
|
|
rows, err := s.db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list initiators: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var initiators []InitiatorWithTarget
|
|
for rows.Next() {
|
|
var initiator InitiatorWithTarget
|
|
err := rows.Scan(
|
|
&initiator.ID, &initiator.GroupID, &initiator.IQN,
|
|
&initiator.IsActive, &initiator.CreatedAt,
|
|
&initiator.TargetID, &initiator.GroupName, &initiator.TargetIQN, &initiator.TargetName,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error("Failed to scan initiator", "error", err)
|
|
continue
|
|
}
|
|
initiators = append(initiators, initiator)
|
|
}
|
|
|
|
return initiators, rows.Err()
|
|
}
|
|
|
|
// AddInitiatorToGroup adds an initiator to a specific group
|
|
func (s *Service) AddInitiatorToGroup(ctx context.Context, groupID, initiatorIQN string) error {
|
|
// Get group info
|
|
var groupName, targetIQN string
|
|
var targetID string
|
|
var singleInitiatorOnly bool
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT ig.group_name, t.iqn, t.id, t.single_initiator_only
|
|
FROM scst_initiator_groups ig
|
|
JOIN scst_targets t ON ig.target_id = t.id
|
|
WHERE ig.id = $1
|
|
`, groupID).Scan(&groupName, &targetIQN, &targetID, &singleInitiatorOnly)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("initiator group not found")
|
|
}
|
|
return fmt.Errorf("failed to get initiator group: %w", err)
|
|
}
|
|
|
|
// Check single initiator policy
|
|
if singleInitiatorOnly {
|
|
var existingCount int
|
|
s.db.QueryRowContext(ctx,
|
|
"SELECT COUNT(*) FROM scst_initiators WHERE group_id IN (SELECT id FROM scst_initiator_groups WHERE target_id = $1)",
|
|
targetID,
|
|
).Scan(&existingCount)
|
|
if existingCount > 0 {
|
|
return fmt.Errorf("target enforces single initiator only")
|
|
}
|
|
}
|
|
|
|
// Check if initiator already exists in this group
|
|
var existingID string
|
|
err = s.db.QueryRowContext(ctx,
|
|
"SELECT id FROM scst_initiators WHERE group_id = $1 AND iqn = $2",
|
|
groupID, initiatorIQN,
|
|
).Scan(&existingID)
|
|
if err == nil {
|
|
return fmt.Errorf("initiator '%s' already exists in this group", initiatorIQN)
|
|
} else if err != sql.ErrNoRows {
|
|
return fmt.Errorf("failed to check existing initiator: %w", err)
|
|
}
|
|
|
|
// Add initiator to group in SCST
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_init", initiatorIQN,
|
|
"-group", groupName,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add initiator to SCST: %s: %w", string(output), err)
|
|
}
|
|
|
|
// Insert into database
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO scst_initiators (group_id, iqn, is_active)
|
|
VALUES ($1, $2, true)
|
|
`, groupID, initiatorIQN)
|
|
if err != nil {
|
|
// Try to remove from SCST if database insert fails
|
|
exec.CommandContext(ctx, "sudo", "scstadmin", "-rem_init", initiatorIQN,
|
|
"-group", groupName,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi").Run()
|
|
return fmt.Errorf("failed to save initiator to database: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Initiator added to group", "group", groupName, "initiator", initiatorIQN, "target", targetIQN)
|
|
return nil
|
|
}
|
|
|
|
// RemoveInitiator removes an initiator from a target
|
|
func (s *Service) RemoveInitiator(ctx context.Context, initiatorID string) error {
|
|
// Get initiator info
|
|
var initiatorIQN, groupName, targetIQN string
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT i.iqn, ig.group_name, t.iqn
|
|
FROM scst_initiators i
|
|
JOIN scst_initiator_groups ig ON i.group_id = ig.id
|
|
JOIN scst_targets t ON ig.target_id = t.id
|
|
WHERE i.id = $1
|
|
`, initiatorID).Scan(&initiatorIQN, &groupName, &targetIQN)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("initiator not found")
|
|
}
|
|
return fmt.Errorf("failed to get initiator: %w", err)
|
|
}
|
|
|
|
// Remove from SCST
|
|
// Note: scstadmin uses -rem_init (not -remove_init)
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-rem_init", initiatorIQN,
|
|
"-group", groupName,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
outputStr := string(output)
|
|
if !strings.Contains(outputStr, "not found") {
|
|
return fmt.Errorf("failed to remove initiator from SCST: %s: %w", outputStr, err)
|
|
}
|
|
// If not found in SCST, continue to remove from database
|
|
}
|
|
|
|
// Remove from database
|
|
_, err = s.db.ExecContext(ctx, "DELETE FROM scst_initiators WHERE id = $1", initiatorID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove initiator from database: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Initiator removed", "initiator", initiatorIQN, "target", targetIQN)
|
|
return nil
|
|
}
|
|
|
|
// GetInitiator retrieves an initiator by ID with full details
|
|
func (s *Service) GetInitiator(ctx context.Context, initiatorID string) (*Initiator, error) {
|
|
query := `
|
|
SELECT i.id, i.group_id, i.iqn, i.is_active, i.created_at,
|
|
ig.target_id, ig.group_name, t.iqn as target_iqn, t.name as target_name
|
|
FROM scst_initiators i
|
|
JOIN scst_initiator_groups ig ON i.group_id = ig.id
|
|
JOIN scst_targets t ON ig.target_id = t.id
|
|
WHERE i.id = $1
|
|
`
|
|
|
|
var initiator Initiator
|
|
var targetID, groupName, targetIQN, targetName string
|
|
err := s.db.QueryRowContext(ctx, query, initiatorID).Scan(
|
|
&initiator.ID, &initiator.GroupID, &initiator.IQN,
|
|
&initiator.IsActive, &initiator.CreatedAt,
|
|
&targetID, &groupName, &targetIQN, &targetName,
|
|
)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("initiator not found")
|
|
}
|
|
return nil, fmt.Errorf("failed to get initiator: %w", err)
|
|
}
|
|
|
|
return &initiator, nil
|
|
}
|
|
|
|
// getGroupInitiators retrieves all initiators for a group
|
|
func (s *Service) getGroupInitiators(ctx context.Context, groupID string) ([]Initiator, error) {
|
|
query := `
|
|
SELECT id, group_id, iqn, is_active, created_at
|
|
FROM scst_initiators
|
|
WHERE group_id = $1
|
|
ORDER BY iqn
|
|
`
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, groupID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get initiators: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var initiators []Initiator
|
|
for rows.Next() {
|
|
var initiator Initiator
|
|
err := rows.Scan(&initiator.ID, &initiator.GroupID, &initiator.IQN, &initiator.IsActive, &initiator.CreatedAt)
|
|
if err != nil {
|
|
s.logger.Error("Failed to scan initiator", "error", err)
|
|
continue
|
|
}
|
|
initiators = append(initiators, initiator)
|
|
}
|
|
|
|
return initiators, rows.Err()
|
|
}
|
|
|
|
// CreateInitiatorGroup creates a new initiator group for a target
|
|
func (s *Service) CreateInitiatorGroup(ctx context.Context, targetID, groupName string) (*InitiatorGroup, error) {
|
|
// Get target IQN
|
|
var targetIQN string
|
|
err := s.db.QueryRowContext(ctx, "SELECT iqn FROM scst_targets WHERE id = $1", targetID).Scan(&targetIQN)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("target not found")
|
|
}
|
|
return nil, fmt.Errorf("failed to get target: %w", err)
|
|
}
|
|
|
|
// Validate group name
|
|
if groupName == "" {
|
|
return nil, fmt.Errorf("group name cannot be empty")
|
|
}
|
|
|
|
// Check if group already exists
|
|
var existingID string
|
|
err = s.db.QueryRowContext(ctx,
|
|
"SELECT id FROM scst_initiator_groups WHERE target_id = $1 AND group_name = $2",
|
|
targetID, groupName,
|
|
).Scan(&existingID)
|
|
if err == nil {
|
|
return nil, fmt.Errorf("initiator group with name '%s' already exists for this target", groupName)
|
|
} else if err != sql.ErrNoRows {
|
|
return nil, fmt.Errorf("failed to check existing group: %w", err)
|
|
}
|
|
|
|
// Create group in SCST
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_group", groupName,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create initiator group in SCST: %s: %w", string(output), err)
|
|
}
|
|
|
|
// Insert into database
|
|
var groupID string
|
|
err = s.db.QueryRowContext(ctx,
|
|
"INSERT INTO scst_initiator_groups (target_id, group_name) VALUES ($1, $2) RETURNING id",
|
|
targetID, groupName,
|
|
).Scan(&groupID)
|
|
if err != nil {
|
|
// Try to remove from SCST if database insert fails
|
|
// scstadmin -rem_group requires interactive confirmation, so we pipe "y" to it
|
|
rollbackCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-rem_group", groupName,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi", "-noprompt")
|
|
rollbackCmd.Run()
|
|
return nil, fmt.Errorf("failed to save group to database: %w", err)
|
|
}
|
|
|
|
group := &InitiatorGroup{
|
|
ID: groupID,
|
|
TargetID: targetID,
|
|
GroupName: groupName,
|
|
Initiators: []Initiator{},
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
s.logger.Info("Initiator group created", "target_id", targetID, "group_name", groupName)
|
|
return group, nil
|
|
}
|
|
|
|
// UpdateInitiatorGroup updates an initiator group (rename)
|
|
func (s *Service) UpdateInitiatorGroup(ctx context.Context, groupID, newGroupName string) (*InitiatorGroup, error) {
|
|
// Get group info
|
|
var targetIQN, oldGroupName string
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT ig.group_name, t.iqn
|
|
FROM scst_initiator_groups ig
|
|
JOIN scst_targets t ON ig.target_id = t.id
|
|
WHERE ig.id = $1
|
|
`, groupID).Scan(&oldGroupName, &targetIQN)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("initiator group not found")
|
|
}
|
|
return nil, fmt.Errorf("failed to get initiator group: %w", err)
|
|
}
|
|
|
|
// Validate new name
|
|
if newGroupName == "" {
|
|
return nil, fmt.Errorf("group name cannot be empty")
|
|
}
|
|
|
|
if newGroupName == oldGroupName {
|
|
// No change, just return the group
|
|
return s.GetInitiatorGroup(ctx, groupID)
|
|
}
|
|
|
|
// Check if new name already exists for this target
|
|
var targetID string
|
|
err = s.db.QueryRowContext(ctx, "SELECT target_id FROM scst_initiator_groups WHERE id = $1", groupID).Scan(&targetID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get target_id: %w", err)
|
|
}
|
|
|
|
var existingID string
|
|
err = s.db.QueryRowContext(ctx,
|
|
"SELECT id FROM scst_initiator_groups WHERE target_id = $1 AND group_name = $2 AND id != $3",
|
|
targetID, newGroupName, groupID,
|
|
).Scan(&existingID)
|
|
if err == nil {
|
|
return nil, fmt.Errorf("initiator group with name '%s' already exists for this target", newGroupName)
|
|
} else if err != sql.ErrNoRows {
|
|
return nil, fmt.Errorf("failed to check existing group: %w", err)
|
|
}
|
|
|
|
// Update in SCST (remove old, add new)
|
|
// Note: SCST doesn't support rename directly, so we need to recreate
|
|
// First, get all initiators in this group
|
|
initiators, err := s.getGroupInitiators(ctx, groupID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get initiators: %w", err)
|
|
}
|
|
|
|
// Remove old group from SCST
|
|
// scstadmin -rem_group requires interactive confirmation, so we pipe "y" to it
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-rem_group", oldGroupName,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi", "-noprompt")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
outputStr := string(output)
|
|
if !strings.Contains(outputStr, "not found") && !strings.Contains(outputStr, "does not exist") {
|
|
return nil, fmt.Errorf("failed to remove old group from SCST: %s: %w", outputStr, err)
|
|
}
|
|
}
|
|
|
|
// Create new group in SCST
|
|
cmd = exec.CommandContext(ctx, "sudo", "scstadmin", "-add_group", newGroupName,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi")
|
|
output, err = cmd.CombinedOutput()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create new group in SCST: %s: %w", string(output), err)
|
|
}
|
|
|
|
// Re-add all initiators to the new group
|
|
for _, initiator := range initiators {
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_init", initiator.IQN,
|
|
"-group", newGroupName,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
s.logger.Warn("Failed to re-add initiator to renamed group",
|
|
"initiator", initiator.IQN, "group", newGroupName, "error", string(output))
|
|
}
|
|
}
|
|
|
|
// Update in database
|
|
_, err = s.db.ExecContext(ctx,
|
|
"UPDATE scst_initiator_groups SET group_name = $1 WHERE id = $2",
|
|
newGroupName, groupID)
|
|
if err != nil {
|
|
// Try to restore old group in SCST
|
|
exec.CommandContext(ctx, "sudo", "scstadmin", "-add_group", oldGroupName,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi").Run()
|
|
return nil, fmt.Errorf("failed to update group in database: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Initiator group updated", "group_id", groupID, "old_name", oldGroupName, "new_name", newGroupName)
|
|
return s.GetInitiatorGroup(ctx, groupID)
|
|
}
|
|
|
|
// DeleteInitiatorGroup deletes an initiator group
|
|
func (s *Service) DeleteInitiatorGroup(ctx context.Context, groupID string) error {
|
|
// Get group info
|
|
var targetIQN, groupName string
|
|
var initiatorCount int
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT ig.group_name, t.iqn, COUNT(i.id)
|
|
FROM scst_initiator_groups ig
|
|
JOIN scst_targets t ON ig.target_id = t.id
|
|
LEFT JOIN scst_initiators i ON i.group_id = ig.id
|
|
WHERE ig.id = $1
|
|
GROUP BY ig.group_name, t.iqn
|
|
`, groupID).Scan(&groupName, &targetIQN, &initiatorCount)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("initiator group not found")
|
|
}
|
|
return fmt.Errorf("failed to get initiator group: %w", err)
|
|
}
|
|
|
|
// Prevent deletion if group has initiators
|
|
if initiatorCount > 0 {
|
|
return fmt.Errorf("cannot delete initiator group: group contains %d initiator(s). Please remove all initiators first", initiatorCount)
|
|
}
|
|
|
|
// Remove from SCST first - this must succeed before deleting from database
|
|
// scstadmin -rem_group requires interactive confirmation, so we pipe "y" to it
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-rem_group", groupName,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi", "-noprompt")
|
|
output, err := cmd.CombinedOutput()
|
|
outputStr := string(output)
|
|
|
|
// Log the command output for debugging
|
|
s.logger.Info("Removing group from SCST", "group", groupName, "target", targetIQN, "output", outputStr)
|
|
|
|
if err != nil {
|
|
// Check if group doesn't exist in SCST (not an error, but log it)
|
|
if strings.Contains(outputStr, "not found") || strings.Contains(outputStr, "does not exist") {
|
|
s.logger.Info("Group not found in SCST, continuing with database deletion", "group", groupName, "target", targetIQN)
|
|
} else {
|
|
// Real error - return error to prevent database deletion
|
|
s.logger.Error("Failed to remove group from SCST", "group", groupName, "target", targetIQN, "output", outputStr, "error", err)
|
|
return fmt.Errorf("failed to remove group from SCST: %s: %w", outputStr, err)
|
|
}
|
|
} else {
|
|
// Command succeeded - verify by checking output
|
|
if strings.Contains(outputStr, "Removing group") || strings.Contains(outputStr, "done") || strings.Contains(outputStr, "All done") {
|
|
s.logger.Info("Group removed from SCST successfully", "group", groupName, "target", targetIQN, "output", outputStr)
|
|
} else {
|
|
// Output doesn't indicate success, but no error - log warning
|
|
s.logger.Warn("Group removal command completed but output unclear", "group", groupName, "target", targetIQN, "output", outputStr)
|
|
}
|
|
}
|
|
|
|
// Delete from database (cascade will handle initiators if any)
|
|
_, err = s.db.ExecContext(ctx, "DELETE FROM scst_initiator_groups WHERE id = $1", groupID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete group from database: %w", err)
|
|
}
|
|
|
|
// Write config to persist changes
|
|
configPath := "/etc/calypso/scst/generated.conf"
|
|
if err := s.WriteConfig(ctx, configPath); err != nil {
|
|
s.logger.Warn("Failed to write config after group deletion", "error", err)
|
|
// Don't fail the deletion if config write fails
|
|
}
|
|
|
|
s.logger.Info("Initiator group deleted", "group_id", groupID, "group_name", groupName, "target", targetIQN)
|
|
return nil
|
|
}
|
|
|
|
// GetInitiatorGroup retrieves a single initiator group by ID
|
|
func (s *Service) GetInitiatorGroup(ctx context.Context, groupID string) (*InitiatorGroup, error) {
|
|
var group InitiatorGroup
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT id, target_id, group_name, created_at
|
|
FROM scst_initiator_groups
|
|
WHERE id = $1
|
|
`, groupID).Scan(&group.ID, &group.TargetID, &group.GroupName, &group.CreatedAt)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("initiator group not found")
|
|
}
|
|
return nil, fmt.Errorf("failed to get initiator group: %w", err)
|
|
}
|
|
|
|
// Get initiators for this group
|
|
initiators, err := s.getGroupInitiators(ctx, groupID)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to get initiators for group", "group_id", groupID, "error", err)
|
|
group.Initiators = []Initiator{}
|
|
} else {
|
|
group.Initiators = initiators
|
|
}
|
|
|
|
return &group, nil
|
|
}
|
|
|
|
// ListAllInitiatorGroups lists all initiator groups across all targets
|
|
func (s *Service) ListAllInitiatorGroups(ctx context.Context) ([]InitiatorGroup, error) {
|
|
query := `
|
|
SELECT id, target_id, group_name, created_at
|
|
FROM scst_initiator_groups
|
|
ORDER BY target_id, group_name
|
|
`
|
|
|
|
rows, err := s.db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list initiator groups: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var groups []InitiatorGroup
|
|
for rows.Next() {
|
|
var group InitiatorGroup
|
|
err := rows.Scan(&group.ID, &group.TargetID, &group.GroupName, &group.CreatedAt)
|
|
if err != nil {
|
|
s.logger.Error("Failed to scan initiator group", "error", err)
|
|
continue
|
|
}
|
|
|
|
// Get initiators for this group
|
|
initiators, err := s.getGroupInitiators(ctx, group.ID)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to get initiators for group", "group_id", group.ID, "error", err)
|
|
group.Initiators = []Initiator{}
|
|
} else {
|
|
group.Initiators = initiators
|
|
}
|
|
|
|
groups = append(groups, group)
|
|
}
|
|
|
|
return groups, rows.Err()
|
|
}
|
|
|
|
// ListTargets lists all SCST targets
|
|
func (s *Service) ListTargets(ctx context.Context) ([]Target, error) {
|
|
query := `
|
|
SELECT
|
|
t.id, t.iqn, t.target_type, t.name, t.description, t.is_active,
|
|
t.single_initiator_only, t.created_at, t.updated_at, t.created_by,
|
|
COALESCE(COUNT(l.id), 0) as lun_count
|
|
FROM scst_targets t
|
|
LEFT JOIN scst_luns l ON t.id = l.target_id
|
|
GROUP BY t.id, t.iqn, t.target_type, t.name, t.description, t.is_active,
|
|
t.single_initiator_only, t.created_at, t.updated_at, t.created_by
|
|
ORDER BY t.name
|
|
`
|
|
|
|
rows, err := s.db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list targets: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var targets []Target
|
|
for rows.Next() {
|
|
var target Target
|
|
var description sql.NullString
|
|
err := rows.Scan(
|
|
&target.ID, &target.IQN, &target.TargetType, &target.Name,
|
|
&description, &target.IsActive, &target.SingleInitiatorOnly,
|
|
&target.CreatedAt, &target.UpdatedAt, &target.CreatedBy,
|
|
&target.LUNCount,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error("Failed to scan target", "error", err)
|
|
continue
|
|
}
|
|
if description.Valid {
|
|
target.Description = description.String
|
|
}
|
|
// Set alias to name for frontend compatibility
|
|
target.Alias = target.Name
|
|
targets = append(targets, target)
|
|
}
|
|
|
|
return targets, rows.Err()
|
|
}
|
|
|
|
// GetTarget retrieves a target by ID
|
|
func (s *Service) GetTarget(ctx context.Context, id string) (*Target, error) {
|
|
query := `
|
|
SELECT id, iqn, target_type, name, description, is_active,
|
|
single_initiator_only, created_at, updated_at, created_by
|
|
FROM scst_targets
|
|
WHERE id = $1
|
|
`
|
|
|
|
var target Target
|
|
var description sql.NullString
|
|
err := s.db.QueryRowContext(ctx, query, id).Scan(
|
|
&target.ID, &target.IQN, &target.TargetType, &target.Name,
|
|
&description, &target.IsActive, &target.SingleInitiatorOnly,
|
|
&target.CreatedAt, &target.UpdatedAt, &target.CreatedBy,
|
|
)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("target not found")
|
|
}
|
|
return nil, fmt.Errorf("failed to get target: %w", err)
|
|
}
|
|
|
|
if description.Valid {
|
|
target.Description = description.String
|
|
}
|
|
|
|
// Sync enabled status from SCST
|
|
enabled, err := s.getTargetEnabledStatus(ctx, target.IQN)
|
|
if err == nil {
|
|
// Update database if status differs
|
|
if enabled != target.IsActive {
|
|
_, err = s.db.ExecContext(ctx, "UPDATE scst_targets SET is_active = $1 WHERE id = $2", enabled, target.ID)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to update target status", "error", err)
|
|
} else {
|
|
target.IsActive = enabled
|
|
}
|
|
}
|
|
}
|
|
|
|
return &target, nil
|
|
}
|
|
|
|
// getTargetEnabledStatus reads enabled status from SCST
|
|
func (s *Service) getTargetEnabledStatus(ctx context.Context, targetIQN string) (bool, error) {
|
|
// Read SCST config to check if target is enabled
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-write_config", "/tmp/scst_target_check.conf")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to write config: %s: %w", string(output), err)
|
|
}
|
|
|
|
// Read config file
|
|
configData, err := os.ReadFile("/tmp/scst_target_check.conf")
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to read config: %w", err)
|
|
}
|
|
|
|
// Check if target is enabled in config
|
|
// Format: TARGET iqn.2025-12.id.atlas:lun01 { enabled 1 } or { enabled 0 }
|
|
configStr := string(configData)
|
|
targetSection := fmt.Sprintf("TARGET %s", targetIQN)
|
|
if !strings.Contains(configStr, targetSection) {
|
|
return false, nil
|
|
}
|
|
|
|
// Extract enabled status
|
|
lines := strings.Split(configStr, "\n")
|
|
inTargetSection := false
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.Contains(line, targetSection) {
|
|
inTargetSection = true
|
|
continue
|
|
}
|
|
if inTargetSection {
|
|
if strings.Contains(line, "enabled 1") {
|
|
return true, nil
|
|
}
|
|
if strings.Contains(line, "enabled 0") {
|
|
return false, nil
|
|
}
|
|
if strings.HasPrefix(line, "TARGET") {
|
|
// Next target, stop searching
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Default to enabled if target exists but status not found
|
|
return true, nil
|
|
}
|
|
|
|
// EnableTarget enables a target in SCST
|
|
func (s *Service) EnableTarget(ctx context.Context, targetIQN string) error {
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-enable_target", targetIQN, "-driver", "iscsi")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
outputStr := string(output)
|
|
if strings.Contains(outputStr, "already enabled") {
|
|
return nil // Already enabled, no error
|
|
}
|
|
return fmt.Errorf("failed to enable target: %s: %w", outputStr, err)
|
|
}
|
|
|
|
// Update database
|
|
var targetID string
|
|
err = s.db.QueryRowContext(ctx, "SELECT id FROM scst_targets WHERE iqn = $1", targetIQN).Scan(&targetID)
|
|
if err == nil {
|
|
s.db.ExecContext(ctx, "UPDATE scst_targets SET is_active = true WHERE id = $1", targetID)
|
|
}
|
|
|
|
s.logger.Info("Target enabled", "iqn", targetIQN)
|
|
return nil
|
|
}
|
|
|
|
// DisableTarget disables a target in SCST
|
|
func (s *Service) DisableTarget(ctx context.Context, targetIQN string) error {
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-disable_target", targetIQN, "-driver", "iscsi")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
outputStr := string(output)
|
|
if strings.Contains(outputStr, "already disabled") {
|
|
return nil // Already disabled, no error
|
|
}
|
|
return fmt.Errorf("failed to disable target: %s: %w", outputStr, err)
|
|
}
|
|
|
|
// Update database
|
|
var targetID string
|
|
err = s.db.QueryRowContext(ctx, "SELECT id FROM scst_targets WHERE iqn = $1", targetIQN).Scan(&targetID)
|
|
if err == nil {
|
|
s.db.ExecContext(ctx, "UPDATE scst_targets SET is_active = false WHERE id = $1", targetID)
|
|
}
|
|
|
|
s.logger.Info("Target disabled", "iqn", targetIQN)
|
|
return nil
|
|
}
|
|
|
|
// DeleteTarget deletes a target from SCST and database
|
|
func (s *Service) DeleteTarget(ctx context.Context, targetID string) error {
|
|
// Get target IQN before deletion
|
|
var targetIQN string
|
|
err := s.db.QueryRowContext(ctx, "SELECT iqn FROM scst_targets WHERE id = $1", targetID).Scan(&targetIQN)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("target not found")
|
|
}
|
|
return fmt.Errorf("failed to get target: %w", err)
|
|
}
|
|
|
|
// Check if target has LUNs - warn but allow deletion
|
|
var lunCount int
|
|
s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM scst_luns WHERE target_id = $1", targetID).Scan(&lunCount)
|
|
if lunCount > 0 {
|
|
s.logger.Warn("Deleting target with LUNs", "target", targetIQN, "lun_count", lunCount)
|
|
}
|
|
|
|
// Check if target has initiators - warn but allow deletion
|
|
var initiatorCount int
|
|
s.db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM scst_initiators
|
|
WHERE group_id IN (SELECT id FROM scst_initiator_groups WHERE target_id = $1)
|
|
`, targetID).Scan(&initiatorCount)
|
|
if initiatorCount > 0 {
|
|
s.logger.Warn("Deleting target with initiators", "target", targetIQN, "initiator_count", initiatorCount)
|
|
}
|
|
|
|
// Remove target from SCST first
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-remove_target", targetIQN, "-driver", "iscsi")
|
|
output, err := cmd.CombinedOutput()
|
|
outputStr := string(output)
|
|
|
|
if err != nil {
|
|
// Check if target doesn't exist in SCST (not an error)
|
|
if strings.Contains(outputStr, "not found") || strings.Contains(outputStr, "does not exist") {
|
|
s.logger.Warn("Target not found in SCST, continuing with database deletion", "target", targetIQN)
|
|
} else {
|
|
// Log error but continue with database deletion
|
|
s.logger.Warn("Failed to remove target from SCST", "target", targetIQN, "output", outputStr)
|
|
}
|
|
}
|
|
|
|
// Delete from database (cascade will handle related records)
|
|
_, err = s.db.ExecContext(ctx, "DELETE FROM scst_targets WHERE id = $1", targetID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete target from database: %w", err)
|
|
}
|
|
|
|
// Write config to persist changes
|
|
configPath := "/etc/calypso/scst/generated.conf"
|
|
if err := s.WriteConfig(ctx, configPath); err != nil {
|
|
s.logger.Warn("Failed to write config after target deletion", "error", err)
|
|
// Don't fail the deletion if config write fails
|
|
}
|
|
|
|
s.logger.Info("Target deleted", "iqn", targetIQN, "id", targetID)
|
|
return nil
|
|
}
|
|
|
|
// GetTargetLUNs retrieves all LUNs for a target
|
|
// It reads from SCST first, then syncs with database
|
|
func (s *Service) GetTargetLUNs(ctx context.Context, targetID string) ([]LUN, error) {
|
|
// Get target IQN
|
|
var targetIQN string
|
|
err := s.db.QueryRowContext(ctx, "SELECT iqn FROM scst_targets WHERE id = $1", targetID).Scan(&targetIQN)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get target IQN: %w", err)
|
|
}
|
|
|
|
// Read LUNs from SCST
|
|
scstLUNs, err := s.readLUNsFromSCST(ctx, targetIQN)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to read LUNs from SCST, using database only", "error", err)
|
|
// Fallback to database only
|
|
return s.getLUNsFromDatabase(ctx, targetID)
|
|
}
|
|
|
|
// Sync SCST LUNs with database
|
|
for _, scstLUN := range scstLUNs {
|
|
// Get device info from SCST
|
|
devicePath, handlerType, err := s.getDeviceInfo(ctx, scstLUN.DeviceName)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to get device info", "device", scstLUN.DeviceName, "error", err)
|
|
// Try to get from existing database record if available
|
|
var existingPath, existingHandler string
|
|
s.db.QueryRowContext(ctx,
|
|
"SELECT device_path, handler_type FROM scst_luns WHERE target_id = $1 AND lun_number = $2",
|
|
targetID, scstLUN.LUNNumber,
|
|
).Scan(&existingPath, &existingHandler)
|
|
if existingPath != "" {
|
|
devicePath = existingPath
|
|
}
|
|
if existingHandler != "" {
|
|
handlerType = existingHandler
|
|
}
|
|
}
|
|
|
|
// Upsert into database
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO scst_luns (target_id, lun_number, device_name, device_path, handler_type)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (target_id, lun_number) DO UPDATE SET
|
|
device_name = EXCLUDED.device_name,
|
|
device_path = COALESCE(NULLIF(EXCLUDED.device_path, ''), scst_luns.device_path),
|
|
handler_type = COALESCE(NULLIF(EXCLUDED.handler_type, ''), scst_luns.handler_type)
|
|
`, targetID, scstLUN.LUNNumber, scstLUN.DeviceName, devicePath, handlerType)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to sync LUN to database", "lun", scstLUN.LUNNumber, "error", err)
|
|
}
|
|
}
|
|
|
|
// Return from database (now synced)
|
|
return s.getLUNsFromDatabase(ctx, targetID)
|
|
}
|
|
|
|
// getLUNsFromDatabase retrieves LUNs from database only
|
|
func (s *Service) getLUNsFromDatabase(ctx context.Context, targetID string) ([]LUN, error) {
|
|
query := `
|
|
SELECT id, target_id, lun_number, device_name, device_path, handler_type, created_at
|
|
FROM scst_luns
|
|
WHERE target_id = $1
|
|
ORDER BY lun_number
|
|
`
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, targetID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get LUNs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var luns []LUN
|
|
for rows.Next() {
|
|
var lun LUN
|
|
err := rows.Scan(
|
|
&lun.ID, &lun.TargetID, &lun.LUNNumber, &lun.DeviceName,
|
|
&lun.DevicePath, &lun.HandlerType, &lun.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error("Failed to scan LUN", "error", err)
|
|
continue
|
|
}
|
|
|
|
// Set handler and device_type for frontend compatibility
|
|
lun.Handler = lun.HandlerType
|
|
// Map handler type to user-friendly device type label
|
|
lun.DeviceType = s.getDeviceTypeLabel(lun.HandlerType)
|
|
// LUN is active if it exists in database (we sync from SCST, so if it's here, it's active)
|
|
lun.IsActive = true
|
|
|
|
luns = append(luns, lun)
|
|
}
|
|
|
|
return luns, rows.Err()
|
|
}
|
|
|
|
// readLUNsFromSCST reads LUNs directly from SCST using scstadmin -list_group
|
|
func (s *Service) readLUNsFromSCST(ctx context.Context, targetIQN string) ([]LUN, error) {
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-list_group",
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list group: %s: %w", string(output), err)
|
|
}
|
|
|
|
// Parse output
|
|
// Format:
|
|
// Driver: iscsi
|
|
// Target: iqn.2025-12.id.atlas:lun01
|
|
//
|
|
// Assigned LUNs:
|
|
//
|
|
// LUN Device
|
|
// ----------
|
|
// 1 LUN01
|
|
|
|
lines := strings.Split(string(output), "\n")
|
|
var luns []LUN
|
|
inTargetLUNSection := false
|
|
foundTarget := false
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Check if we found our target
|
|
if strings.HasPrefix(line, "Target:") {
|
|
targetLine := strings.TrimSpace(strings.TrimPrefix(line, "Target:"))
|
|
if targetLine == targetIQN {
|
|
foundTarget = true
|
|
continue
|
|
} else {
|
|
// Different target, reset
|
|
foundTarget = false
|
|
inTargetLUNSection = false
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Only process if we found our target
|
|
if !foundTarget {
|
|
continue
|
|
}
|
|
|
|
// Check if we're in the LUN section for this target
|
|
if strings.Contains(line, "Assigned LUNs:") {
|
|
inTargetLUNSection = true
|
|
continue
|
|
}
|
|
|
|
// Stop if we hit a Group section (LUNs after this are group-specific)
|
|
if strings.HasPrefix(line, "Group:") {
|
|
break
|
|
}
|
|
|
|
// Skip separator line
|
|
if strings.HasPrefix(line, "---") {
|
|
continue
|
|
}
|
|
|
|
// Skip header line "LUN Device"
|
|
if strings.HasPrefix(line, "LUN") && strings.Contains(line, "Device") {
|
|
continue
|
|
}
|
|
|
|
// Parse LUN lines (format: "0 pbs-test")
|
|
if inTargetLUNSection && line != "" {
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 2 {
|
|
var lunNumber int
|
|
if _, err := fmt.Sscanf(parts[0], "%d", &lunNumber); err == nil {
|
|
deviceName := parts[1]
|
|
luns = append(luns, LUN{
|
|
LUNNumber: lunNumber,
|
|
DeviceName: deviceName,
|
|
DevicePath: "", // Will be filled by getDeviceInfo
|
|
HandlerType: "", // Will be filled by getDeviceInfo
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop if we hit "All done"
|
|
if strings.Contains(line, "All done") {
|
|
break
|
|
}
|
|
}
|
|
|
|
return luns, nil
|
|
}
|
|
|
|
// getDeviceInfo gets device path and handler type from SCST
|
|
// Since scstadmin doesn't provide easy access to device attributes,
|
|
// we try to get handler type from device list, and device path from database if available
|
|
func (s *Service) getDeviceInfo(ctx context.Context, deviceName string) (string, string, error) {
|
|
// Find which handler this device belongs to
|
|
listCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-list_device")
|
|
output, err := listCmd.Output()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to list devices: %w", err)
|
|
}
|
|
|
|
// Parse output to find handler
|
|
// Format:
|
|
// Handler Device
|
|
// -----------------------
|
|
// vdisk_fileio LUN01
|
|
lines := strings.Split(string(output), "\n")
|
|
var handlerType string
|
|
inHandlerSection := false
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Check if we're in the handler section
|
|
if strings.Contains(line, "Handler") && strings.Contains(line, "Device") {
|
|
inHandlerSection = true
|
|
continue
|
|
}
|
|
|
|
// Skip separator
|
|
if strings.HasPrefix(line, "---") {
|
|
continue
|
|
}
|
|
|
|
// Parse handler and device lines
|
|
if inHandlerSection && line != "" && !strings.Contains(line, "Collecting") && !strings.Contains(line, "All done") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 2 {
|
|
handler := parts[0]
|
|
device := parts[1]
|
|
if device == deviceName {
|
|
handlerType = handler
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if strings.Contains(line, "All done") {
|
|
break
|
|
}
|
|
}
|
|
|
|
if handlerType == "" {
|
|
return "", "", fmt.Errorf("handler not found for device %s", deviceName)
|
|
}
|
|
|
|
// Try to get device path from SCST config file
|
|
devicePath := s.getDevicePathFromConfig(deviceName, handlerType)
|
|
|
|
return devicePath, handlerType, nil
|
|
}
|
|
|
|
// getDevicePathFromConfig reads device path from SCST config file
|
|
func (s *Service) getDevicePathFromConfig(deviceName, handlerType string) string {
|
|
// Write current config to temp file
|
|
cmd := exec.Command("sudo", "scstadmin", "-write_config", "/tmp/scst_device_info.conf")
|
|
if err := cmd.Run(); err != nil {
|
|
s.logger.Warn("Failed to write SCST config", "error", err)
|
|
return ""
|
|
}
|
|
|
|
// Read config file
|
|
configData, err := os.ReadFile("/tmp/scst_device_info.conf")
|
|
if err != nil {
|
|
s.logger.Warn("Failed to read SCST config", "error", err)
|
|
return ""
|
|
}
|
|
|
|
// Parse config to find device
|
|
// Format:
|
|
// DEVICE deviceName {
|
|
// filename /path/to/device
|
|
// }
|
|
lines := strings.Split(string(configData), "\n")
|
|
inDeviceBlock := false
|
|
var devicePath string
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Check if we're entering the device block
|
|
if strings.Contains(line, "DEVICE") && strings.Contains(line, deviceName) {
|
|
inDeviceBlock = true
|
|
continue
|
|
}
|
|
|
|
// Check if we're leaving the device block
|
|
if inDeviceBlock && strings.HasPrefix(line, "}") {
|
|
break
|
|
}
|
|
|
|
// Look for filename in device block
|
|
if inDeviceBlock && strings.Contains(line, "filename") {
|
|
parts := strings.Fields(line)
|
|
for j, part := range parts {
|
|
if part == "filename" && j+1 < len(parts) {
|
|
devicePath = parts[j+1]
|
|
break
|
|
}
|
|
}
|
|
if devicePath != "" {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return devicePath
|
|
}
|
|
|
|
// ListExtents lists all device extents (opened devices) in SCST
|
|
func (s *Service) ListExtents(ctx context.Context) ([]Extent, error) {
|
|
// List all devices from SCST
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-list_device")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list devices: %w", err)
|
|
}
|
|
|
|
// Parse output
|
|
// Format:
|
|
// Handler Device
|
|
// -----------------------
|
|
// vdisk_fileio LUN01
|
|
lines := strings.Split(string(output), "\n")
|
|
var extents []Extent
|
|
inHandlerSection := false
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Check if we're in the handler section
|
|
if strings.Contains(line, "Handler") && strings.Contains(line, "Device") {
|
|
inHandlerSection = true
|
|
continue
|
|
}
|
|
|
|
// Skip separator
|
|
if strings.HasPrefix(line, "---") {
|
|
continue
|
|
}
|
|
|
|
// Skip header/footer lines
|
|
if strings.Contains(line, "Collecting") || strings.Contains(line, "All done") {
|
|
continue
|
|
}
|
|
|
|
// Parse handler and device lines
|
|
if inHandlerSection && line != "" {
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 2 {
|
|
handlerType := parts[0]
|
|
deviceName := parts[1]
|
|
|
|
// Skip if device is "-" (no device opened for this handler)
|
|
if deviceName == "-" {
|
|
continue
|
|
}
|
|
|
|
// Get device path from config or database
|
|
devicePath := s.getDevicePathFromConfig(deviceName, handlerType)
|
|
if devicePath == "" {
|
|
// Try to get from database
|
|
var dbPath string
|
|
s.db.QueryRowContext(ctx,
|
|
"SELECT device_path FROM scst_luns WHERE device_name = $1 LIMIT 1",
|
|
deviceName,
|
|
).Scan(&dbPath)
|
|
if dbPath != "" {
|
|
devicePath = dbPath
|
|
}
|
|
}
|
|
|
|
// Count how many LUNs use this device
|
|
var lunCount int
|
|
s.db.QueryRowContext(ctx,
|
|
"SELECT COUNT(*) FROM scst_luns WHERE device_name = $1",
|
|
deviceName,
|
|
).Scan(&lunCount)
|
|
|
|
extents = append(extents, Extent{
|
|
HandlerType: handlerType,
|
|
DeviceName: deviceName,
|
|
DevicePath: devicePath,
|
|
IsInUse: lunCount > 0,
|
|
LUNCount: lunCount,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return extents, nil
|
|
}
|
|
|
|
// CreateExtent opens a device in SCST (creates an extent)
|
|
func (s *Service) CreateExtent(ctx context.Context, deviceName, devicePath, handlerType string) error {
|
|
// Validate handler type
|
|
handlers, err := s.DetectHandlers(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to detect handlers: %w", err)
|
|
}
|
|
|
|
handlerValid := false
|
|
for _, h := range handlers {
|
|
if h.Name == handlerType {
|
|
handlerValid = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !handlerValid {
|
|
return fmt.Errorf("invalid handler type: %s", handlerType)
|
|
}
|
|
|
|
// Open device in SCST
|
|
openCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-open_dev", deviceName,
|
|
"-handler", handlerType,
|
|
"-attributes", fmt.Sprintf("filename=%s", devicePath))
|
|
output, err := openCmd.CombinedOutput()
|
|
if err != nil {
|
|
outputStr := string(output)
|
|
if strings.Contains(outputStr, "already exists") {
|
|
return fmt.Errorf("device %s already exists", deviceName)
|
|
}
|
|
return fmt.Errorf("failed to open device: %s: %w", outputStr, err)
|
|
}
|
|
|
|
s.logger.Info("Extent created", "device", deviceName, "handler", handlerType, "path", devicePath)
|
|
return nil
|
|
}
|
|
|
|
// getDeviceHandlerType gets the handler type for a device by querying SCST
|
|
func (s *Service) getDeviceHandlerType(ctx context.Context, deviceName string) (string, error) {
|
|
// First, try to get from database if device was used by a LUN
|
|
var handlerType string
|
|
err := s.db.QueryRowContext(ctx,
|
|
"SELECT handler_type FROM scst_luns WHERE device_name = $1 LIMIT 1",
|
|
deviceName,
|
|
).Scan(&handlerType)
|
|
if err == nil && handlerType != "" {
|
|
return handlerType, nil
|
|
}
|
|
|
|
// If not in database, query from SCST
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-list_device")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to list devices: %w", err)
|
|
}
|
|
|
|
// Parse output to find handler type for this device
|
|
lines := strings.Split(string(output), "\n")
|
|
inHandlerSection := false
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Check if we're in a handler section
|
|
if strings.HasPrefix(line, "HANDLER") {
|
|
inHandlerSection = true
|
|
continue
|
|
}
|
|
|
|
// Skip separator
|
|
if strings.HasPrefix(line, "---") {
|
|
continue
|
|
}
|
|
|
|
// Skip header/footer lines
|
|
if strings.Contains(line, "Collecting") || strings.Contains(line, "All done") {
|
|
continue
|
|
}
|
|
|
|
// Parse handler and device lines
|
|
if inHandlerSection && line != "" {
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 2 {
|
|
parsedHandlerType := parts[0]
|
|
parsedDeviceName := parts[1]
|
|
|
|
// Skip if device is "-" (no device opened for this handler)
|
|
if parsedDeviceName == "-" {
|
|
continue
|
|
}
|
|
|
|
// Found the device, return its handler type
|
|
if parsedDeviceName == deviceName {
|
|
return parsedHandlerType, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("device %s not found in SCST", deviceName)
|
|
}
|
|
|
|
// DeleteExtent closes a device in SCST (removes an extent)
|
|
func (s *Service) DeleteExtent(ctx context.Context, deviceName string) error {
|
|
// Check if device is in use by any LUN
|
|
var lunCount int
|
|
err := s.db.QueryRowContext(ctx,
|
|
"SELECT COUNT(*) FROM scst_luns WHERE device_name = $1",
|
|
deviceName,
|
|
).Scan(&lunCount)
|
|
if err == nil && lunCount > 0 {
|
|
return fmt.Errorf("device %s is in use by %d LUN(s). Remove LUNs first", deviceName, lunCount)
|
|
}
|
|
|
|
// Get handler type for this device
|
|
handlerType, err := s.getDeviceHandlerType(ctx, deviceName)
|
|
if err != nil {
|
|
// If device not found, check if it's already closed
|
|
if strings.Contains(err.Error(), "not found") {
|
|
s.logger.Info("Device not found in SCST, may already be closed", "device", deviceName)
|
|
return nil // Idempotent - device already closed
|
|
}
|
|
return fmt.Errorf("failed to get handler type: %w", err)
|
|
}
|
|
|
|
// Close device in SCST with handler type
|
|
// Command format: scstadmin -close_dev <device> -handler <handler>
|
|
closeCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-close_dev", deviceName,
|
|
"-handler", handlerType)
|
|
output, err := closeCmd.CombinedOutput()
|
|
outputStr := string(output)
|
|
|
|
// Log the command output for debugging
|
|
s.logger.Info("Closing device in SCST", "device", deviceName, "handler", handlerType, "command", fmt.Sprintf("scstadmin -close_dev %s -handler %s", deviceName, handlerType), "output", outputStr)
|
|
|
|
if err != nil {
|
|
// Check if device doesn't exist in SCST (not an error, but log it)
|
|
if strings.Contains(outputStr, "not found") || strings.Contains(outputStr, "does not exist") {
|
|
s.logger.Info("Device not found in SCST, may already be closed", "device", deviceName)
|
|
return nil // Idempotent - device already closed
|
|
}
|
|
return fmt.Errorf("failed to close device: %s: %w", outputStr, err)
|
|
}
|
|
|
|
// Always verify device is actually closed by checking if it still exists
|
|
// This ensures the command really succeeded even if output is unclear
|
|
verifyCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-list_device")
|
|
verifyOutput, verifyErr := verifyCmd.CombinedOutput()
|
|
if verifyErr == nil {
|
|
verifyOutputStr := string(verifyOutput)
|
|
// Parse output to check if device still exists
|
|
lines := strings.Split(verifyOutputStr, "\n")
|
|
inHandlerSection := false
|
|
deviceStillExists := false
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Check if we're in a handler section
|
|
if strings.HasPrefix(line, "HANDLER") {
|
|
inHandlerSection = true
|
|
continue
|
|
}
|
|
|
|
// Skip separator
|
|
if strings.HasPrefix(line, "---") {
|
|
continue
|
|
}
|
|
|
|
// Skip header/footer lines
|
|
if strings.Contains(line, "Collecting") || strings.Contains(line, "All done") {
|
|
continue
|
|
}
|
|
|
|
// Parse handler and device lines
|
|
if inHandlerSection && line != "" {
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 2 {
|
|
parsedDeviceName := parts[1]
|
|
// Skip if device is "-" (no device opened for this handler)
|
|
if parsedDeviceName == "-" {
|
|
continue
|
|
}
|
|
// Check if this is the device we're trying to close
|
|
if parsedDeviceName == deviceName {
|
|
deviceStillExists = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if deviceStillExists {
|
|
// Device still exists - command failed
|
|
return fmt.Errorf("device %s still exists after close command. Command output: %s", deviceName, outputStr)
|
|
}
|
|
s.logger.Info("Device verified as closed", "device", deviceName)
|
|
} else {
|
|
// If verification fails, log warning but don't fail (command may have succeeded)
|
|
s.logger.Warn("Failed to verify device closure", "device", deviceName, "error", verifyErr)
|
|
}
|
|
|
|
// Write config to persist changes
|
|
configPath := "/etc/calypso/scst/generated.conf"
|
|
if err := s.WriteConfig(ctx, configPath); err != nil {
|
|
s.logger.Warn("Failed to write config after extent deletion", "error", err)
|
|
// Don't fail the deletion if config write fails
|
|
}
|
|
|
|
s.logger.Info("Extent deleted successfully", "device", deviceName, "handler", handlerType)
|
|
return nil
|
|
}
|
|
|
|
// getDeviceTypeLabel returns a user-friendly label for device type based on handler type
|
|
func (s *Service) getDeviceTypeLabel(handlerType string) string {
|
|
deviceTypeMap := map[string]string{
|
|
"vdisk_fileio": "ZFS Volume",
|
|
"vdisk_blockio": "Block Device",
|
|
"vdisk_nullio": "Null Device",
|
|
"vcdrom": "CDROM",
|
|
"dev_cdrom": "CDROM",
|
|
"dev_disk": "Physical Disk",
|
|
"dev_disk_perf": "Physical Disk (Performance)",
|
|
}
|
|
|
|
if label, ok := deviceTypeMap[handlerType]; ok {
|
|
return label
|
|
}
|
|
|
|
// Default: return handler type as-is
|
|
return handlerType
|
|
}
|
|
|
|
// WriteConfig writes SCST configuration to file
|
|
func (s *Service) WriteConfig(ctx context.Context, configPath string) error {
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-write_config", configPath)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write SCST config: %s: %w", string(output), err)
|
|
}
|
|
|
|
s.logger.Info("SCST configuration written", "path", configPath)
|
|
return nil
|
|
}
|
|
|
|
// ReadConfigFile reads the SCST configuration file content
|
|
func (s *Service) ReadConfigFile(ctx context.Context, configPath string) (string, error) {
|
|
// First, write current config to temp file to get the actual config
|
|
tempPath := "/tmp/scst_config_read.conf"
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-write_config", tempPath)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to write SCST config: %s: %w", string(output), err)
|
|
}
|
|
|
|
// Read the config file
|
|
configData, err := os.ReadFile(tempPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read config file: %w", err)
|
|
}
|
|
|
|
return string(configData), nil
|
|
}
|
|
|
|
// WriteConfigFile writes content to SCST configuration file
|
|
func (s *Service) WriteConfigFile(ctx context.Context, configPath string, content string) error {
|
|
// Write content to temp file first
|
|
tempPath := "/tmp/scst_config_write.conf"
|
|
if err := os.WriteFile(tempPath, []byte(content), 0644); err != nil {
|
|
return fmt.Errorf("failed to write temp config file: %w", err)
|
|
}
|
|
|
|
// Use scstadmin to load the config (this validates and applies it)
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-config", tempPath)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load SCST config: %s: %w", string(output), err)
|
|
}
|
|
|
|
// Write to the actual config path using sudo
|
|
if configPath != tempPath {
|
|
// Use sudo cp to copy temp file to actual config path
|
|
cpCmd := exec.CommandContext(ctx, "sudo", "cp", tempPath, configPath)
|
|
cpOutput, cpErr := cpCmd.CombinedOutput()
|
|
if cpErr != nil {
|
|
return fmt.Errorf("failed to copy config file: %s: %w", string(cpOutput), cpErr)
|
|
}
|
|
// Set proper permissions
|
|
chmodCmd := exec.CommandContext(ctx, "sudo", "chmod", "644", configPath)
|
|
if chmodErr := chmodCmd.Run(); chmodErr != nil {
|
|
s.logger.Warn("Failed to set config file permissions", "error", chmodErr)
|
|
}
|
|
}
|
|
|
|
s.logger.Info("SCST configuration file written", "path", configPath)
|
|
return nil
|
|
}
|
|
|
|
// HandlerInfo represents SCST handler information
|
|
type HandlerInfo struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label"` // Simple, user-friendly label
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// DetectHandlers detects available SCST handlers
|
|
func (s *Service) DetectHandlers(ctx context.Context) ([]HandlerInfo, error) {
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-list_handler")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list handlers: %w", err)
|
|
}
|
|
|
|
// Parse output - skip header lines and separator
|
|
handlers := []HandlerInfo{}
|
|
lines := strings.Split(string(output), "\n")
|
|
skipHeader := true
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Skip empty lines
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Skip header line "Handler"
|
|
if strings.HasPrefix(line, "Handler") {
|
|
skipHeader = false
|
|
continue
|
|
}
|
|
|
|
// Skip separator line "-------------"
|
|
if strings.HasPrefix(line, "---") {
|
|
continue
|
|
}
|
|
|
|
// Skip lines before header (like "Collecting current configuration: done.")
|
|
if skipHeader {
|
|
continue
|
|
}
|
|
|
|
// Skip footer lines
|
|
if strings.Contains(line, "All done") || strings.Contains(line, "Collecting") {
|
|
continue
|
|
}
|
|
|
|
// Map handler names to labels and descriptions
|
|
label, description := s.getHandlerInfo(line)
|
|
handlers = append(handlers, HandlerInfo{
|
|
Name: line,
|
|
Label: label,
|
|
Description: description,
|
|
})
|
|
}
|
|
|
|
return handlers, nil
|
|
}
|
|
|
|
// getHandlerInfo returns a simple label and description for a handler
|
|
func (s *Service) getHandlerInfo(handlerName string) (string, string) {
|
|
handlerInfo := map[string]struct {
|
|
label string
|
|
description string
|
|
}{
|
|
"dev_disk": {
|
|
label: "Physical Disk",
|
|
description: "Physical disk handler",
|
|
},
|
|
"dev_disk_perf": {
|
|
label: "Physical Disk (Performance)",
|
|
description: "Physical disk handler with performance optimizations",
|
|
},
|
|
"dev_cdrom": {
|
|
label: "CDROM",
|
|
description: "CD/DVD-ROM handler",
|
|
},
|
|
"vdisk_blockio": {
|
|
label: "Block Device",
|
|
description: "Virtual disk block I/O handler (for block devices)",
|
|
},
|
|
"vdisk_fileio": {
|
|
label: "Volume",
|
|
description: "Virtual disk file I/O handler (for ZFS volumes and files)",
|
|
},
|
|
"vdisk_nullio": {
|
|
label: "Null Device",
|
|
description: "Null I/O handler (for testing)",
|
|
},
|
|
"vcdrom": {
|
|
label: "CDROM",
|
|
description: "Virtual CD-ROM handler",
|
|
},
|
|
}
|
|
|
|
if info, ok := handlerInfo[handlerName]; ok {
|
|
return info.label, info.description
|
|
}
|
|
|
|
// Default: use handler name as label
|
|
return handlerName, ""
|
|
}
|
|
|
|
// ListPortals lists all iSCSI portals
|
|
func (s *Service) ListPortals(ctx context.Context) ([]Portal, error) {
|
|
query := `
|
|
SELECT id, ip_address, port, is_active, created_at, updated_at
|
|
FROM scst_portals
|
|
ORDER BY ip_address, port
|
|
`
|
|
|
|
rows, err := s.db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list portals: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var portals []Portal
|
|
for rows.Next() {
|
|
var portal Portal
|
|
err := rows.Scan(
|
|
&portal.ID, &portal.IPAddress, &portal.Port,
|
|
&portal.IsActive, &portal.CreatedAt, &portal.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error("Failed to scan portal", "error", err)
|
|
continue
|
|
}
|
|
portals = append(portals, portal)
|
|
}
|
|
|
|
return portals, rows.Err()
|
|
}
|
|
|
|
// CreatePortal creates a new iSCSI portal
|
|
func (s *Service) CreatePortal(ctx context.Context, portal *Portal) error {
|
|
// Validate IP address format (basic validation)
|
|
if portal.IPAddress == "" {
|
|
return fmt.Errorf("IP address is required")
|
|
}
|
|
|
|
// Validate port range
|
|
if portal.Port < 1 || portal.Port > 65535 {
|
|
return fmt.Errorf("port must be between 1 and 65535")
|
|
}
|
|
|
|
// Default port to 3260 if not specified
|
|
if portal.Port == 0 {
|
|
portal.Port = 3260
|
|
}
|
|
|
|
// Insert into database
|
|
query := `
|
|
INSERT INTO scst_portals (ip_address, port, is_active)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id, created_at, updated_at
|
|
`
|
|
|
|
err := s.db.QueryRowContext(ctx, query,
|
|
portal.IPAddress, portal.Port, portal.IsActive,
|
|
).Scan(&portal.ID, &portal.CreatedAt, &portal.UpdatedAt)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create portal: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Portal created", "ip", portal.IPAddress, "port", portal.Port)
|
|
|
|
// Apply portal to all existing targets if active
|
|
if portal.IsActive {
|
|
if err := s.applyPortalToAllTargets(ctx, portal.IPAddress, portal.Port); err != nil {
|
|
s.logger.Warn("Failed to apply portal to targets", "ip", portal.IPAddress, "port", portal.Port, "error", err)
|
|
// Don't fail portal creation if application fails
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdatePortal updates an existing portal
|
|
func (s *Service) UpdatePortal(ctx context.Context, id string, portal *Portal) error {
|
|
// Validate port range
|
|
if portal.Port < 1 || portal.Port > 65535 {
|
|
return fmt.Errorf("port must be between 1 and 65535")
|
|
}
|
|
|
|
// Get old portal data before update
|
|
var oldIPAddress string
|
|
var oldPort int
|
|
var oldIsActive bool
|
|
err := s.db.QueryRowContext(ctx, "SELECT ip_address, port, is_active FROM scst_portals WHERE id = $1", id).Scan(&oldIPAddress, &oldPort, &oldIsActive)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("portal not found")
|
|
}
|
|
return fmt.Errorf("failed to get portal info: %w", err)
|
|
}
|
|
|
|
// Update in database
|
|
query := `
|
|
UPDATE scst_portals
|
|
SET ip_address = $1, port = $2, is_active = $3, updated_at = NOW()
|
|
WHERE id = $4
|
|
RETURNING updated_at
|
|
`
|
|
|
|
err = s.db.QueryRowContext(ctx, query,
|
|
portal.IPAddress, portal.Port, portal.IsActive, id,
|
|
).Scan(&portal.UpdatedAt)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update portal: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Portal updated", "id", id, "ip", portal.IPAddress, "port", portal.Port)
|
|
|
|
// Handle SCST updates
|
|
if portal.IsActive {
|
|
// Portal is active - apply to all targets
|
|
// Remove old portal if IP or port changed
|
|
if oldIPAddress != portal.IPAddress || oldPort != portal.Port {
|
|
if err := s.removePortalFromAllTargets(ctx, oldIPAddress, oldPort); err != nil {
|
|
s.logger.Warn("Failed to remove old portal from targets", "ip", oldIPAddress, "port", oldPort, "error", err)
|
|
}
|
|
}
|
|
// Apply new portal to all targets
|
|
if err := s.applyPortalToAllTargets(ctx, portal.IPAddress, portal.Port); err != nil {
|
|
s.logger.Warn("Failed to apply portal to targets", "ip", portal.IPAddress, "port", portal.Port, "error", err)
|
|
}
|
|
} else {
|
|
// Portal is inactive - remove from all targets
|
|
// Only remove if it was previously active
|
|
if oldIsActive {
|
|
if err := s.removePortalFromAllTargets(ctx, oldIPAddress, oldPort); err != nil {
|
|
s.logger.Warn("Failed to remove portal from targets", "ip", oldIPAddress, "port", oldPort, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeletePortal deletes a portal
|
|
func (s *Service) DeletePortal(ctx context.Context, id string) error {
|
|
// Get portal info before deletion to remove from SCST
|
|
var ipAddress string
|
|
var port int
|
|
err := s.db.QueryRowContext(ctx, "SELECT ip_address, port FROM scst_portals WHERE id = $1", id).Scan(&ipAddress, &port)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("portal not found")
|
|
}
|
|
return fmt.Errorf("failed to get portal info: %w", err)
|
|
}
|
|
|
|
// Remove portal from all targets before deleting from database
|
|
if err := s.removePortalFromAllTargets(ctx, ipAddress, port); err != nil {
|
|
s.logger.Warn("Failed to remove portal from targets", "ip", ipAddress, "port", port, "error", err)
|
|
// Continue with deletion even if removal fails
|
|
}
|
|
|
|
// Delete from database
|
|
result, err := s.db.ExecContext(ctx, "DELETE FROM scst_portals WHERE id = $1", id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete portal: %w", err)
|
|
}
|
|
|
|
rowsAffected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get rows affected: %w", err)
|
|
}
|
|
|
|
if rowsAffected == 0 {
|
|
return fmt.Errorf("portal not found")
|
|
}
|
|
|
|
s.logger.Info("Portal deleted", "id", id)
|
|
return nil
|
|
}
|
|
|
|
// GetPortal retrieves a portal by ID
|
|
func (s *Service) GetPortal(ctx context.Context, id string) (*Portal, error) {
|
|
query := `
|
|
SELECT id, ip_address, port, is_active, created_at, updated_at
|
|
FROM scst_portals
|
|
WHERE id = $1
|
|
`
|
|
|
|
var portal Portal
|
|
err := s.db.QueryRowContext(ctx, query, id).Scan(
|
|
&portal.ID, &portal.IPAddress, &portal.Port,
|
|
&portal.IsActive, &portal.CreatedAt, &portal.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("portal not found")
|
|
}
|
|
return nil, fmt.Errorf("failed to get portal: %w", err)
|
|
}
|
|
|
|
return &portal, nil
|
|
}
|
|
|
|
// applyPortalsToTarget applies all active portals to a specific target
|
|
func (s *Service) applyPortalsToTarget(ctx context.Context, targetIQN string) error {
|
|
// Get all active portals
|
|
query := `
|
|
SELECT ip_address, port
|
|
FROM scst_portals
|
|
WHERE is_active = true
|
|
ORDER BY ip_address, port
|
|
`
|
|
rows, err := s.db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to query portals: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var portals []struct {
|
|
IPAddress string
|
|
Port int
|
|
}
|
|
for rows.Next() {
|
|
var portal struct {
|
|
IPAddress string
|
|
Port int
|
|
}
|
|
if err := rows.Scan(&portal.IPAddress, &portal.Port); err != nil {
|
|
s.logger.Warn("Failed to scan portal", "error", err)
|
|
continue
|
|
}
|
|
portals = append(portals, portal)
|
|
}
|
|
|
|
// Apply each portal to the target
|
|
for _, portal := range portals {
|
|
if err := s.addPortalToTarget(ctx, targetIQN, portal.IPAddress, portal.Port); err != nil {
|
|
s.logger.Warn("Failed to add portal to target", "target", targetIQN, "ip", portal.IPAddress, "port", portal.Port, "error", err)
|
|
// Continue with other portals even if one fails
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// applyPortalToAllTargets applies a portal to all existing targets
|
|
func (s *Service) applyPortalToAllTargets(ctx context.Context, ipAddress string, port int) error {
|
|
// Ensure the iSCSI target driver is enabled
|
|
enableCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-set_drv_attr", "iscsi", "-attributes", "enabled=1")
|
|
if output, err := enableCmd.CombinedOutput(); err != nil {
|
|
s.logger.Warn("Could not enable iscsi target driver", "error", string(output))
|
|
// Don't fail here, as it might already be enabled or another issue might be present.
|
|
}
|
|
|
|
// Get all iSCSI targets
|
|
query := `
|
|
SELECT iqn
|
|
FROM scst_targets
|
|
WHERE target_type IN ('disk', 'vtl', 'physical_tape')
|
|
ORDER BY iqn
|
|
`
|
|
rows, err := s.db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to query targets: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var targets []string
|
|
for rows.Next() {
|
|
var iqn string
|
|
if err := rows.Scan(&iqn); err != nil {
|
|
s.logger.Warn("Failed to scan target", "error", err)
|
|
continue
|
|
}
|
|
targets = append(targets, iqn)
|
|
}
|
|
|
|
// Apply portal to each target
|
|
for _, targetIQN := range targets {
|
|
if err := s.addPortalToTarget(ctx, targetIQN, ipAddress, port); err != nil {
|
|
s.logger.Warn("Failed to add portal to target", "target", targetIQN, "ip", ipAddress, "port", port, "error", err)
|
|
// Continue with other targets even if one fails
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// removePortalFromAllTargets removes a portal from all existing targets
|
|
func (s *Service) removePortalFromAllTargets(ctx context.Context, ipAddress string, port int) error {
|
|
// Get all iSCSI targets
|
|
query := `
|
|
SELECT iqn
|
|
FROM scst_targets
|
|
WHERE target_type IN ('disk', 'vtl', 'physical_tape')
|
|
ORDER BY iqn
|
|
`
|
|
rows, err := s.db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to query targets: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var targets []string
|
|
for rows.Next() {
|
|
var iqn string
|
|
if err := rows.Scan(&iqn); err != nil {
|
|
s.logger.Warn("Failed to scan target", "error", err)
|
|
continue
|
|
}
|
|
targets = append(targets, iqn)
|
|
}
|
|
|
|
// Remove portal from each target
|
|
for _, targetIQN := range targets {
|
|
if err := s.removePortalFromTarget(ctx, targetIQN, ipAddress, port); err != nil {
|
|
s.logger.Warn("Failed to remove portal from target", "target", targetIQN, "ip", ipAddress, "port", port, "error", err)
|
|
// Continue with other targets even if one fails
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// addPortalToTarget adds a portal to a specific target in SCST
|
|
func (s *Service) addPortalToTarget(ctx context.Context, targetIQN, ipAddress string, port int) error {
|
|
// Format: IP:PORT
|
|
portalAddr := fmt.Sprintf("%s:%d", ipAddress, port)
|
|
|
|
// Use scstadmin to add portal to target
|
|
// Note: SCST uses different syntax depending on version
|
|
// Try the standard syntax first: -add_portal <IP:PORT> -target <IQN> -driver iscsi
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_portal", portalAddr,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi")
|
|
output, err := cmd.CombinedOutput()
|
|
outputStr := string(output)
|
|
|
|
if err != nil {
|
|
// Check if portal already exists (not an error)
|
|
if strings.Contains(outputStr, "already exists") || strings.Contains(outputStr, "already added") {
|
|
s.logger.Debug("Portal already exists on target", "target", targetIQN, "portal", portalAddr)
|
|
return nil
|
|
}
|
|
|
|
// Some SCST versions use different syntax or portal might be configured differently
|
|
// Log warning but don't fail - portal configuration might be handled by iscsi-scstd
|
|
s.logger.Warn("Failed to add portal to target (may be handled by iscsi-scstd)", "target", targetIQN, "portal", portalAddr, "output", outputStr)
|
|
return fmt.Errorf("failed to add portal: %s: %w", outputStr, err)
|
|
}
|
|
|
|
s.logger.Info("Portal added to target", "target", targetIQN, "portal", portalAddr)
|
|
return nil
|
|
}
|
|
|
|
// removePortalFromTarget removes a portal from a specific target in SCST
|
|
func (s *Service) removePortalFromTarget(ctx context.Context, targetIQN, ipAddress string, port int) error {
|
|
// Format: IP:PORT
|
|
portalAddr := fmt.Sprintf("%s:%d", ipAddress, port)
|
|
|
|
// Use scstadmin to remove portal from target
|
|
cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-remove_portal", portalAddr,
|
|
"-target", targetIQN,
|
|
"-driver", "iscsi")
|
|
output, err := cmd.CombinedOutput()
|
|
outputStr := string(output)
|
|
|
|
if err != nil {
|
|
// Check if portal doesn't exist (not an error)
|
|
if strings.Contains(outputStr, "not found") || strings.Contains(outputStr, "does not exist") {
|
|
s.logger.Debug("Portal not found on target", "target", targetIQN, "portal", portalAddr)
|
|
return nil
|
|
}
|
|
|
|
// Log warning but don't fail
|
|
s.logger.Warn("Failed to remove portal from target", "target", targetIQN, "portal", portalAddr, "output", outputStr)
|
|
return fmt.Errorf("failed to remove portal: %s: %w", outputStr, err)
|
|
}
|
|
|
|
s.logger.Info("Portal removed from target", "target", targetIQN, "portal", portalAddr)
|
|
return nil
|
|
}
|