Files
calypso/backend/internal/scst/service.go
2025-12-29 02:44:52 +07:00

1360 lines
40 KiB
Go

package scst
import (
"context"
"database/sql"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/atlasos/calypso/internal/common/database"
"github.com/atlasos/calypso/internal/common/logger"
)
// Service handles SCST operations
type Service struct {
db *database.DB
logger *logger.Logger
}
// NewService creates a new SCST service
func NewService(db *database.DB, log *logger.Logger) *Service {
return &Service{
db: db,
logger: log,
}
}
// Target represents an SCST iSCSI target
type Target struct {
ID string `json:"id"`
IQN string `json:"iqn"`
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
}