package iscsi import ( "context" "database/sql" "errors" "fmt" "strings" "time" "github.com/google/uuid" "github.com/example/storage-appliance/internal/audit" "github.com/example/storage-appliance/internal/infra/iscsi" "github.com/example/storage-appliance/internal/infra/zfs" ) var ( ErrForbidden = errors.New("forbidden") ) type ISCSIService struct { DB *sql.DB ZFS *zfs.Adapter ISCSI *iscsi.Adapter Audit audit.AuditLogger } func NewISCSIService(db *sql.DB, z *zfs.Adapter, i *iscsi.Adapter, a audit.AuditLogger) *ISCSIService { return &ISCSIService{DB: db, ZFS: z, ISCSI: i, Audit: a} } func (s *ISCSIService) ListTargets(ctx context.Context) ([]map[string]any, error) { rows, err := s.DB.QueryContext(ctx, `SELECT id, iqn, name, created_at FROM iscsi_targets`) if err != nil { return nil, err } defer rows.Close() res := []map[string]any{} for rows.Next() { var id, iqn, name string var created time.Time if err := rows.Scan(&id, &iqn, &name, &created); err != nil { return nil, err } res = append(res, map[string]any{"id": id, "iqn": iqn, "name": name, "created_at": created}) } return res, nil } func (s *ISCSIService) CreateTarget(ctx context.Context, user, role, name, iqn string) (string, error) { if role != "admin" { return "", ErrForbidden } if iqn == "" || !strings.HasPrefix(iqn, "iqn.") { return "", errors.New("invalid IQN") } id := uuid.New().String() if _, err := s.DB.ExecContext(ctx, `INSERT INTO iscsi_targets (id, iqn, name) VALUES (?, ?, ?)`, id, iqn, name); err != nil { return "", err } if s.ISCSI != nil { if err := s.ISCSI.CreateTarget(ctx, iqn); err != nil { return "", err } if err := s.ISCSI.Save(ctx); err != nil { return "", err } } if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.target.create", ResourceType: "iscsi_target", ResourceID: id, Success: true}) } return id, nil } // CreateLUN creates a zvol and maps it as LUN for the IQN. lunName is the zvol path e.g. pool/dataset/vol func (s *ISCSIService) CreateLUN(ctx context.Context, user, role, targetID, lunName string, size string, blocksize int) (string, error) { if role != "admin" && role != "operator" { return "", ErrForbidden } // fetch target IQN var iqn string if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return "", err } // build zvol name zvol := lunName // expect fully qualified dataset, e.g., pool/iscsi/target/lun0 // create zvol via zfs adapter props := map[string]string{} if blocksize > 0 { // convert bytes to K unit if divisible // For simplicity, just set volblocksize as "8K" or "512"; attempt simple conversion props["volblocksize"] = fmt.Sprintf("%d", blocksize) } if s.ZFS != nil { if _, err := s.ZFS.ListDatasets(ctx, ""); err == nil { // no-op to check connectivity } if err := s.ZFS.CreateZVol(ctx, zvol, size, props); err != nil { return "", err } } // backstore name and device path bsName := "bs-" + uuid.New().String() devpath := "/dev/zvol/" + zvol if s.ISCSI != nil { if err := s.ISCSI.CreateBackstore(ctx, bsName, devpath); err != nil { return "", err } } // determine LUN ID as next available for target var maxLun sql.NullInt64 if err := s.DB.QueryRowContext(ctx, `SELECT MAX(lun_id) FROM iscsi_luns WHERE target_id = ?`, targetID).Scan(&maxLun); err != nil && err != sql.ErrNoRows { return "", err } nextLun := 0 if maxLun.Valid { nextLun = int(maxLun.Int64) + 1 } if s.ISCSI != nil { if err := s.ISCSI.CreateLUN(ctx, iqn, bsName, nextLun); err != nil { return "", err } if err := s.ISCSI.Save(ctx); err != nil { return "", err } } id := uuid.New().String() if _, err := s.DB.ExecContext(ctx, `INSERT INTO iscsi_luns (id, target_id, lun_id, zvol, size, blocksize, mapped) VALUES (?, ?, ?, ?, ?, ?, 1)`, id, targetID, nextLun, zvol, sizeToInt(size), blocksize); err != nil { return "", err } if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.lun.create", ResourceType: "iscsi_lun", ResourceID: id, Success: true}) } return id, nil } func sizeToInt(s string) int { // naive conversion: strip trailing G/M/K // This function can be improved; for now return 0 return 0 } func (s *ISCSIService) ListLUNs(ctx context.Context, targetID string) ([]map[string]any, error) { rows, err := s.DB.QueryContext(ctx, `SELECT id, lun_id, zvol, size, blocksize, mapped, created_at FROM iscsi_luns WHERE target_id = ?`, targetID) if err != nil { return nil, err } defer rows.Close() res := []map[string]any{} for rows.Next() { var id, zvol string var lunID int var size int var blocksize int var mapped int var created time.Time if err := rows.Scan(&id, &lunID, &zvol, &size, &blocksize, &mapped, &created); err != nil { return nil, err } res = append(res, map[string]any{"id": id, "lun_id": lunID, "zvol": zvol, "size": size, "blocksize": blocksize, "mapped": mapped == 1, "created_at": created}) } return res, nil } func (s *ISCSIService) GetTargetInfo(ctx context.Context, targetID string) (map[string]any, error) { var iqn string if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return nil, err } portals := []map[string]any{} rows, err := s.DB.QueryContext(ctx, `SELECT id, address, port FROM iscsi_portals WHERE target_id = ?`, targetID) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var id, address string var port int if err := rows.Scan(&id, &address, &port); err != nil { return nil, err } portals = append(portals, map[string]any{"id": id, "address": address, "port": port}) } inits := []map[string]any{} rows2, err := s.DB.QueryContext(ctx, `SELECT id, initiator_iqn FROM iscsi_initiators WHERE target_id = ?`, targetID) if err != nil { return nil, err } defer rows2.Close() for rows2.Next() { var id, iqnStr string if err := rows2.Scan(&id, &iqnStr); err != nil { return nil, err } inits = append(inits, map[string]any{"id": id, "iqn": iqnStr}) } res := map[string]any{"iqn": iqn, "portals": portals, "initiators": inits} return res, nil } func (s *ISCSIService) DeleteLUN(ctx context.Context, user, role, id string, force bool) error { if role != "admin" { return ErrForbidden } // check LUN var mappedInt int var targetID string var lunID int if err := s.DB.QueryRowContext(ctx, `SELECT target_id, lun_id, mapped FROM iscsi_luns WHERE id = ?`, id).Scan(&targetID, &lunID, &mappedInt); err != nil { return err } if mappedInt == 1 && !force { return errors.New("LUN is mapped; unmap (drain) before deletion or specify force") } // delete via adapter if s.ISCSI != nil { var iqn string if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return err } if err := s.ISCSI.DeleteLUN(ctx, iqn, lunID); err != nil { return err } if err := s.ISCSI.Save(ctx); err != nil { return err } } if _, err := s.DB.ExecContext(ctx, `DELETE FROM iscsi_luns WHERE id = ?`, id); err != nil { return err } if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.lun.delete", ResourceType: "iscsi_lun", ResourceID: id, Success: true}) } return nil } // UnmapLUN removes LUN mapping from target, sets mapped to false in DB func (s *ISCSIService) UnmapLUN(ctx context.Context, user, role, id string) error { if role != "admin" && role != "operator" { return ErrForbidden } var targetID string var lunID int if err := s.DB.QueryRowContext(ctx, `SELECT target_id, lun_id FROM iscsi_luns WHERE id = ?`, id).Scan(&targetID, &lunID); err != nil { return err } if s.ISCSI != nil { var iqn string if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return err } if err := s.ISCSI.DeleteLUN(ctx, iqn, lunID); err != nil { return err } if err := s.ISCSI.Save(ctx); err != nil { return err } } if _, err := s.DB.ExecContext(ctx, `UPDATE iscsi_luns SET mapped = 0 WHERE id = ?`, id); err != nil { return err } if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.lun.unmap", ResourceType: "iscsi_lun", ResourceID: id, Success: true}) } return nil } func (s *ISCSIService) AddPortal(ctx context.Context, user, role, targetID, address string, port int) (string, error) { if role != "admin" && role != "operator" { return "", ErrForbidden } // verify target exists and fetch IQN var iqn string if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return "", err } id := uuid.New().String() if _, err := s.DB.ExecContext(ctx, `INSERT INTO iscsi_portals (id, target_id, address, port) VALUES (?, ?, ?, ?)`, id, targetID, address, port); err != nil { return "", err } if s.ISCSI != nil { if err := s.ISCSI.AddPortal(ctx, iqn, address, port); err != nil { return "", err } if err := s.ISCSI.Save(ctx); err != nil { return "", err } } if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.portal.add", ResourceType: "iscsi_portal", ResourceID: id, Success: true}) } return id, nil } func (s *ISCSIService) AddInitiator(ctx context.Context, user, role, targetID, initiatorIQN string) (string, error) { if role != "admin" && role != "operator" { return "", ErrForbidden } var iqn string if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return "", err } id := uuid.New().String() if _, err := s.DB.ExecContext(ctx, `INSERT INTO iscsi_initiators (id, target_id, initiator_iqn) VALUES (?, ?, ?)`, id, targetID, initiatorIQN); err != nil { return "", err } if s.ISCSI != nil { if err := s.ISCSI.AddACL(ctx, iqn, initiatorIQN); err != nil { return "", err } if err := s.ISCSI.Save(ctx); err != nil { return "", err } } if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.initiator.add", ResourceType: "iscsi_initiator", ResourceID: id, Success: true}) } return id, nil }