Files
calypso/backend/internal/scst/service.go
2026-01-04 12:54:25 +07:00

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
}