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"` 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, "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, "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) return nil } // AddLUN adds a LUN to a target func (s *Service) AddLUN(ctx context.Context, targetIQN, deviceName, devicePath string, lunNumber int, handlerType string) error { // Open device in SCST openCmd := exec.CommandContext(ctx, "scstadmin", "-open_dev", deviceName, "-handler", handlerType, "-attributes", fmt.Sprintf("filename=%s", devicePath)) output, err := openCmd.CombinedOutput() if err != nil { if !strings.Contains(string(output), "already exists") { return fmt.Errorf("failed to open device in SCST: %s: %w", string(output), err) } } // Add LUN to target addCmd := exec.CommandContext(ctx, "scstadmin", "-add_lun", fmt.Sprintf("%d", lunNumber), "-target", targetIQN, "-driver", "iscsi", "-device", deviceName) output, err = addCmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to add LUN to target: %s: %w", string(output), err) } // 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) } // 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) } s.logger.Info("LUN added", "target", targetIQN, "lun", lunNumber, "device", deviceName) 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, "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, "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() } // 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 cmd := exec.CommandContext(ctx, "scstadmin", "-remove_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() } // 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 } 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, "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, "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, "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 } // 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, "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 inLUNSection := false for _, line := range lines { line = strings.TrimSpace(line) // Check if we're in the LUN section if strings.Contains(line, "Assigned LUNs:") { inLUNSection = true continue } // 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: "1 LUN01") if inLUNSection && 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, "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("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, "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, "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 } // 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) } // Close device in SCST closeCmd := exec.CommandContext(ctx, "scstadmin", "-close_dev", deviceName) output, err := closeCmd.CombinedOutput() if err != nil { outputStr := string(output) if strings.Contains(outputStr, "not found") { return fmt.Errorf("device %s not found", deviceName) } return fmt.Errorf("failed to close device: %s: %w", outputStr, err) } s.logger.Info("Extent deleted", "device", deviceName) 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, "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 } // 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, "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) 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") } 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 { if err == sql.ErrNoRows { return fmt.Errorf("portal not found") } return fmt.Errorf("failed to update portal: %w", err) } s.logger.Info("Portal updated", "id", id, "ip", portal.IPAddress, "port", portal.Port) return nil } // DeletePortal deletes a portal func (s *Service) DeletePortal(ctx context.Context, id string) error { 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 }