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 -driver iscsi -target -group -device 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 -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 -target -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 }