Files
atlas/internal/services/iscsi.go
Othman Hendy Suseo 6202ef8e83
Some checks failed
CI / test-build (push) Has been cancelled
fixing UI and iscsi sync
2025-12-20 19:16:50 +00:00

554 lines
18 KiB
Go

package services
import (
"fmt"
"os/exec"
"strconv"
"strings"
"sync"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
)
// ISCSIService manages iSCSI target service integration
type ISCSIService struct {
mu sync.RWMutex
targetcliPath string
}
// NewISCSIService creates a new iSCSI service manager
func NewISCSIService() *ISCSIService {
// Try targetcli first, fallback to targetcli-fb
targetcliPath := "targetcli"
if _, err := exec.LookPath("targetcli"); err != nil {
if _, err := exec.LookPath("targetcli-fb"); err == nil {
targetcliPath = "targetcli-fb"
}
}
return &ISCSIService{
targetcliPath: targetcliPath,
}
}
// ApplyConfiguration applies iSCSI target configurations
func (s *ISCSIService) ApplyConfiguration(targets []models.ISCSITarget) error {
s.mu.Lock()
defer s.mu.Unlock()
// For each target, ensure it exists and is configured
for _, target := range targets {
if !target.Enabled {
// Disable target if it exists
if err := s.disableTarget(target.IQN); err != nil {
// Log but continue
fmt.Printf("warning: failed to disable target %s: %v\n", target.IQN, err)
}
continue
}
// Create or update target
if err := s.createTarget(target); err != nil {
return fmt.Errorf("create target %s: %w", target.IQN, err)
}
fmt.Printf("iSCSI target created/verified: %s\n", target.IQN)
// Configure ACLs
if err := s.configureACLs(target); err != nil {
return fmt.Errorf("configure ACLs for %s: %w", target.IQN, err)
}
fmt.Printf("iSCSI ACLs configured for: %s\n", target.IQN)
// Configure LUNs
if err := s.configureLUNs(target); err != nil {
return fmt.Errorf("configure LUNs for %s: %w", target.IQN, err)
}
fmt.Printf("iSCSI LUNs configured for: %s\n", target.IQN)
}
return nil
}
// createTarget creates an iSCSI target
func (s *ISCSIService) createTarget(target models.ISCSITarget) error {
// Use targetcli to create target
// Format: targetcli /iscsi create <IQN>
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi", "create", target.IQN)
output, err := cmd.CombinedOutput()
if err != nil {
// Target might already exist, which is OK
// Check if it actually exists
if !s.targetExists(target.IQN) {
return fmt.Errorf("create target failed: %w (output: %s)", err, string(output))
}
fmt.Printf("target %s already exists, continuing\n", target.IQN)
} else {
fmt.Printf("target %s created successfully\n", target.IQN)
}
// Enable TPG1 (Target Portal Group 1)
// Disable authentication
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "authentication=0")
if output, err := cmd.CombinedOutput(); err != nil {
fmt.Printf("warning: failed to set authentication=0: %v (output: %s)\n", err, string(output))
}
// Enable generate_node_acls (allow all initiators if no ACLs specified)
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "generate_node_acls=1")
if output, err := cmd.CombinedOutput(); err != nil {
fmt.Printf("warning: failed to set generate_node_acls=1: %v (output: %s)\n", err, string(output))
} else {
fmt.Printf("set generate_node_acls=1 for target %s\n", target.IQN)
}
// Create portal if not exists (listen on all interfaces, port 3260)
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/portals", "create")
if err := cmd.Run(); err != nil {
// Portal might already exist, which is OK
// Check if portal exists
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/portals", "ls")
output, err2 := cmd.Output()
if err2 != nil || len(strings.TrimSpace(string(output))) == 0 {
// No portal exists, try to create with specific IP
// Get system IP
systemIP, _ := s.getSystemIP()
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/portals", "create", systemIP)
if err3 := cmd.Run(); err3 != nil {
// Try with 0.0.0.0 (all interfaces)
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/portals", "create", "0.0.0.0")
if err4 := cmd.Run(); err4 != nil {
// Log but don't fail - portal might already exist
fmt.Printf("warning: failed to create portal: %v", err4)
}
}
}
}
// Save configuration
cmd = exec.Command("sudo", "-n", s.targetcliPath, "saveconfig")
cmd.Run() // Ignore errors
return nil
}
// configureACLs configures initiator ACLs for a target
func (s *ISCSIService) configureACLs(target models.ISCSITarget) error {
// If no initiators specified, allow all (generate_node_acls=1)
if len(target.Initiators) == 0 {
// Set to allow all initiators
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "generate_node_acls=1")
if err := cmd.Run(); err != nil {
return fmt.Errorf("set generate_node_acls: %w", err)
}
// Disable authentication for open access
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "authentication=0")
cmd.Run() // Ignore errors
return nil
}
// If initiators specified, use ACL-based access
// Set generate_node_acls=0 to use explicit ACLs
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "generate_node_acls=0")
if err := cmd.Run(); err != nil {
return fmt.Errorf("set generate_node_acls=0: %w", err)
}
// Get current ACLs
currentACLs, _ := s.getACLs(target.IQN)
// Remove ACLs not in desired list
for _, acl := range currentACLs {
if !contains(target.Initiators, acl) {
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/acls", "delete", acl)
cmd.Run() // Ignore errors
}
}
// Add new ACLs
for _, initiator := range target.Initiators {
if !contains(currentACLs, initiator) {
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/acls", "create", initiator)
if err := cmd.Run(); err != nil {
return fmt.Errorf("create ACL %s: %w", initiator, err)
}
}
}
return nil
}
// configureLUNs configures LUNs for a target
func (s *ISCSIService) configureLUNs(target models.ISCSITarget) error {
// Get current LUNs
currentLUNs, _ := s.getLUNs(target.IQN)
// Remove LUNs not in desired list
for _, lun := range currentLUNs {
if !s.hasLUN(target.LUNs, lun) {
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/luns", "delete", fmt.Sprintf("lun/%d", lun))
cmd.Run() // Ignore errors
}
}
// Add/update LUNs
for _, lun := range target.LUNs {
// Determine backstore type (default to block for ZVOL, pscsi for tape devices)
backstoreType := lun.Backstore
if backstoreType == "" {
if lun.Device != "" {
// If device is specified and looks like tape device, use pscsi
if strings.HasPrefix(lun.Device, "/dev/st") || strings.HasPrefix(lun.Device, "/dev/nst") {
backstoreType = "pscsi"
} else {
backstoreType = "block"
}
} else if lun.ZVOL != "" {
// Default to block for ZVOL
backstoreType = "block"
} else {
return fmt.Errorf("LUN must have either ZVOL or Device specified")
}
}
// Determine backstore name
backstoreName := lun.BackstoreName
if backstoreName == "" {
if lun.ZVOL != "" {
backstoreName = strings.ReplaceAll(lun.ZVOL, "/", "-")
} else if lun.Device != "" {
// Use device name (e.g., st0, sdb)
backstoreName = strings.TrimPrefix(strings.TrimPrefix(lun.Device, "/dev/"), "/dev/")
backstoreName = strings.ReplaceAll(backstoreName, "/", "-")
} else {
backstoreName = fmt.Sprintf("lun-%d", lun.ID)
}
}
// Determine device path
var devicePath string
if lun.Device != "" {
devicePath = lun.Device
} else if lun.ZVOL != "" {
devicePath = "/dev/zvol/" + lun.ZVOL
} else {
return fmt.Errorf("LUN must have either ZVOL or Device specified")
}
backstorePath := "/backstores/" + backstoreType + "/" + backstoreName
// Create backstore based on type
switch backstoreType {
case "block":
// Format: targetcli /backstores/block create name=<name> dev=<dev>
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/backstores/block", "create", "name="+backstoreName, "dev="+devicePath)
if output, err := cmd.CombinedOutput(); err != nil {
if !strings.Contains(string(output), "already exists") {
fmt.Printf("warning: failed to create block backstore %s: %v (output: %s)\n", backstoreName, err, string(output))
}
} else {
fmt.Printf("created block backstore %s for %s\n", backstoreName, devicePath)
}
case "pscsi":
// Format: targetcli /backstores/pscsi create name=<name> dev=<dev>
// pscsi is for SCSI pass-through (tape devices, etc.)
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/backstores/pscsi", "create", "name="+backstoreName, "dev="+devicePath)
if output, err := cmd.CombinedOutput(); err != nil {
if !strings.Contains(string(output), "already exists") {
return fmt.Errorf("failed to create pscsi backstore %s: %w (output: %s)", backstoreName, err, string(output))
}
} else {
fmt.Printf("created pscsi backstore %s for %s\n", backstoreName, devicePath)
}
case "fileio":
// Format: targetcli /backstores/fileio create name=<name> file_or_dev=<path> [size=<size>]
// fileio is for file-based storage
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/backstores/fileio", "create", "name="+backstoreName, "file_or_dev="+devicePath)
if output, err := cmd.CombinedOutput(); err != nil {
if !strings.Contains(string(output), "already exists") {
return fmt.Errorf("failed to create fileio backstore %s: %w (output: %s)", backstoreName, err, string(output))
}
} else {
fmt.Printf("created fileio backstore %s for %s\n", backstoreName, devicePath)
}
default:
return fmt.Errorf("unsupported backstore type: %s", backstoreType)
}
// Create LUN
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/luns", "create", backstorePath)
if err := cmd.Run(); err != nil {
// LUN might already exist
if !s.hasLUNID(currentLUNs, lun.ID) {
return fmt.Errorf("create LUN %d: %w", lun.ID, err)
}
}
}
return nil
}
// Helper functions
func (s *ISCSIService) targetExists(iqn string) bool {
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi", "ls")
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(string(output), iqn)
}
func (s *ISCSIService) getACLs(iqn string) ([]string, error) {
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+iqn+"/tpg1/acls", "ls")
output, err := cmd.Output()
if err != nil {
return []string{}, nil // Return empty if can't get ACLs
}
// Parse output to extract ACL names
// Format: o- acls ................................................................................................ [ACLs: 1]
// o- iqn.1994-05.com.redhat:client1 ........................................................ [Mapped LUNs: 1]
lines := strings.Split(string(output), "\n")
acls := []string{}
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "o- iqn.") {
// Extract IQN from line like "o- iqn.1994-05.com.redhat:client1"
parts := strings.Fields(line)
if len(parts) >= 2 && strings.HasPrefix(parts[1], "iqn.") {
acls = append(acls, parts[1])
}
}
}
return acls, nil
}
func (s *ISCSIService) getLUNs(iqn string) ([]int, error) {
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+iqn+"/tpg1/luns", "ls")
output, err := cmd.Output()
if err != nil {
return []int{}, nil // Return empty if can't get LUNs
}
// Parse output to extract LUN IDs
// Format: o- luns ................................................................................................ [LUNs: 1]
// o- lun0 ................................................................................ [zvol/pool-test-02/vol-1(/dev/zvol/pool-test-02/vol-1)]
lines := strings.Split(string(output), "\n")
luns := []int{}
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "o- lun") {
// Extract LUN number from line like "o- lun0"
parts := strings.Fields(line)
if len(parts) >= 2 && strings.HasPrefix(parts[1], "lun") {
lunIDStr := strings.TrimPrefix(parts[1], "lun")
if lunID, err := strconv.Atoi(lunIDStr); err == nil {
luns = append(luns, lunID)
}
}
}
}
return luns, nil
}
func (s *ISCSIService) hasLUN(luns []models.LUN, id int) bool {
for _, lun := range luns {
if lun.ID == id {
return true
}
}
return false
}
func (s *ISCSIService) hasLUNID(luns []int, id int) bool {
for _, lunID := range luns {
if lunID == id {
return true
}
}
return false
}
func (s *ISCSIService) disableTarget(iqn string) error {
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+iqn+"/tpg1", "set", "attribute", "enable=0")
return cmd.Run()
}
// GetStatus returns the status of iSCSI target service
func (s *ISCSIService) GetStatus() (bool, error) {
// Check if targetd is running
cmd := exec.Command("systemctl", "is-active", "target")
if err := cmd.Run(); err == nil {
return true, nil
}
// Fallback: check process
cmd = exec.Command("pgrep", "-x", "targetd")
if err := cmd.Run(); err == nil {
return true, nil
}
return false, nil
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// ConnectionInstructions represents iSCSI connection instructions
type ConnectionInstructions struct {
IQN string `json:"iqn"`
Portal string `json:"portal"` // IP:port
PortalIP string `json:"portal_ip"` // IP address
PortalPort int `json:"portal_port"` // Port (default 3260)
LUNs []LUNInfo `json:"luns"`
Commands Commands `json:"commands"`
}
// LUNInfo represents LUN information for connection
type LUNInfo struct {
ID int `json:"id"`
ZVOL string `json:"zvol"`
Size uint64 `json:"size"`
}
// Commands contains OS-specific connection commands
type Commands struct {
Linux []string `json:"linux"`
Windows []string `json:"windows"`
MacOS []string `json:"macos"`
}
// GetConnectionInstructions generates connection instructions for an iSCSI target
func (s *ISCSIService) GetConnectionInstructions(target models.ISCSITarget, portalIP string, portalPort int) *ConnectionInstructions {
if portalPort == 0 {
portalPort = 3260 // Default iSCSI port
}
portal := fmt.Sprintf("%s:%d", portalIP, portalPort)
// Build LUN information
luns := make([]LUNInfo, len(target.LUNs))
for i, lun := range target.LUNs {
luns[i] = LUNInfo{
ID: lun.ID,
ZVOL: lun.ZVOL,
Size: lun.Size,
}
}
// Generate Linux commands
linuxCmds := []string{
fmt.Sprintf("# Discover target"),
fmt.Sprintf("iscsiadm -m discovery -t sendtargets -p %s", portal),
fmt.Sprintf(""),
fmt.Sprintf("# Login to target"),
fmt.Sprintf("iscsiadm -m node -T %s -p %s --login", target.IQN, portal),
fmt.Sprintf(""),
fmt.Sprintf("# Verify connection"),
fmt.Sprintf("iscsiadm -m session"),
fmt.Sprintf(""),
fmt.Sprintf("# Logout (when done)"),
fmt.Sprintf("iscsiadm -m node -T %s -p %s --logout", target.IQN, portal),
}
// Generate Windows commands
windowsCmds := []string{
fmt.Sprintf("# Open PowerShell as Administrator"),
fmt.Sprintf(""),
fmt.Sprintf("# Add iSCSI target portal"),
fmt.Sprintf("New-IscsiTargetPortal -TargetPortalAddress %s -TargetPortalPortNumber %d", portalIP, portalPort),
fmt.Sprintf(""),
fmt.Sprintf("# Connect to target"),
fmt.Sprintf("Connect-IscsiTarget -NodeAddress %s", target.IQN),
fmt.Sprintf(""),
fmt.Sprintf("# Verify connection"),
fmt.Sprintf("Get-IscsiSession"),
fmt.Sprintf(""),
fmt.Sprintf("# Disconnect (when done)"),
fmt.Sprintf("Disconnect-IscsiTarget -NodeAddress %s", target.IQN),
}
// Generate macOS commands
macosCmds := []string{
fmt.Sprintf("# macOS uses built-in iSCSI support"),
fmt.Sprintf("# Use System Preferences > Network > iSCSI"),
fmt.Sprintf(""),
fmt.Sprintf("# Or use command line (if iscsiutil is available)"),
fmt.Sprintf("iscsiutil -a -t %s -p %s", target.IQN, portal),
fmt.Sprintf(""),
fmt.Sprintf("# Portal: %s", portal),
fmt.Sprintf("# Target IQN: %s", target.IQN),
}
return &ConnectionInstructions{
IQN: target.IQN,
Portal: portal,
PortalIP: portalIP,
PortalPort: portalPort,
LUNs: luns,
Commands: Commands{
Linux: linuxCmds,
Windows: windowsCmds,
MacOS: macosCmds,
},
}
}
// GetPortalIP attempts to detect the portal IP address
func (s *ISCSIService) GetPortalIP() (string, error) {
// Try to get IP from targetcli
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi", "ls")
output, err := cmd.Output()
if err != nil {
// Fallback: try to get system IP
return s.getSystemIP()
}
// Parse output to find portal IP
// This is a simplified version - real implementation would parse targetcli output properly
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "ipv4") || strings.Contains(line, "ipv6") {
// Extract IP from line
parts := strings.Fields(line)
for _, part := range parts {
// Check if it looks like an IP address
if strings.Contains(part, ".") || strings.Contains(part, ":") {
// Remove port if present
if idx := strings.Index(part, ":"); idx > 0 {
return part[:idx], nil
}
return part, nil
}
}
}
}
// Fallback to system IP
return s.getSystemIP()
}
// getSystemIP gets a system IP address (simplified)
func (s *ISCSIService) getSystemIP() (string, error) {
// Try to get IP from hostname -I (Linux)
cmd := exec.Command("hostname", "-I")
output, err := cmd.Output()
if err == nil {
ips := strings.Fields(string(output))
if len(ips) > 0 {
return ips[0], nil
}
}
// Fallback: return localhost
return "127.0.0.1", nil
}