package tape_physical import ( "context" "database/sql" "fmt" "os/exec" "strconv" "strings" "time" "github.com/atlasos/calypso/internal/common/database" "github.com/atlasos/calypso/internal/common/logger" ) // Service handles physical tape library operations type Service struct { db *database.DB logger *logger.Logger } // NewService creates a new physical tape service func NewService(db *database.DB, log *logger.Logger) *Service { return &Service{ db: db, logger: log, } } // TapeLibrary represents a physical tape library type TapeLibrary struct { ID string `json:"id"` Name string `json:"name"` SerialNumber string `json:"serial_number"` Vendor string `json:"vendor"` Model string `json:"model"` ChangerDevicePath string `json:"changer_device_path"` ChangerStablePath string `json:"changer_stable_path"` SlotCount int `json:"slot_count"` DriveCount int `json:"drive_count"` IsActive bool `json:"is_active"` DiscoveredAt time.Time `json:"discovered_at"` LastInventoryAt *time.Time `json:"last_inventory_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // TapeDrive represents a physical tape drive type TapeDrive struct { ID string `json:"id"` LibraryID string `json:"library_id"` DriveNumber int `json:"drive_number"` DevicePath string `json:"device_path"` StablePath string `json:"stable_path"` Vendor string `json:"vendor"` Model string `json:"model"` SerialNumber string `json:"serial_number"` DriveType string `json:"drive_type"` Status string `json:"status"` CurrentTapeBarcode string `json:"current_tape_barcode"` IsActive bool `json:"is_active"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // TapeSlot represents a tape slot in the library type TapeSlot struct { ID string `json:"id"` LibraryID string `json:"library_id"` SlotNumber int `json:"slot_number"` Barcode string `json:"barcode"` TapePresent bool `json:"tape_present"` TapeType string `json:"tape_type"` LastUpdatedAt time.Time `json:"last_updated_at"` } // DiscoverLibraries discovers physical tape libraries on the system func (s *Service) DiscoverLibraries(ctx context.Context) ([]TapeLibrary, error) { // Use lsscsi to find tape changers cmd := exec.CommandContext(ctx, "lsscsi", "-g") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to run lsscsi: %w", err) } var libraries []TapeLibrary lines := strings.Split(strings.TrimSpace(string(output)), "\n") for _, line := range lines { if line == "" { continue } // Parse lsscsi output: [0:0:0:0] disk ATA ... /dev/sda /dev/sg0 parts := strings.Fields(line) if len(parts) < 4 { continue } deviceType := parts[2] devicePath := "" sgPath := "" // Extract device paths for i := 3; i < len(parts); i++ { if strings.HasPrefix(parts[i], "/dev/") { if strings.HasPrefix(parts[i], "/dev/sg") { sgPath = parts[i] } else if strings.HasPrefix(parts[i], "/dev/sch") || strings.HasPrefix(parts[i], "/dev/st") { devicePath = parts[i] } } } // Check for medium changer (tape library) if deviceType == "mediumx" || deviceType == "changer" { // Get changer information via sg_inq changerInfo, err := s.getChangerInfo(ctx, sgPath) if err != nil { s.logger.Warn("Failed to get changer info", "device", sgPath, "error", err) continue } lib := TapeLibrary{ Name: fmt.Sprintf("Library-%s", changerInfo["serial"]), SerialNumber: changerInfo["serial"], Vendor: changerInfo["vendor"], Model: changerInfo["model"], ChangerDevicePath: devicePath, ChangerStablePath: sgPath, IsActive: true, DiscoveredAt: time.Now(), } // Get slot and drive count via mtx if slotCount, driveCount, err := s.getLibraryCounts(ctx, devicePath); err == nil { lib.SlotCount = slotCount lib.DriveCount = driveCount } libraries = append(libraries, lib) } } return libraries, nil } // getChangerInfo retrieves changer information via sg_inq func (s *Service) getChangerInfo(ctx context.Context, sgPath string) (map[string]string, error) { cmd := exec.CommandContext(ctx, "sg_inq", "-i", sgPath) output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to run sg_inq: %w", err) } info := make(map[string]string) lines := strings.Split(string(output), "\n") for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "Vendor identification:") { info["vendor"] = strings.TrimSpace(strings.TrimPrefix(line, "Vendor identification:")) } else if strings.HasPrefix(line, "Product identification:") { info["model"] = strings.TrimSpace(strings.TrimPrefix(line, "Product identification:")) } else if strings.HasPrefix(line, "Unit serial number:") { info["serial"] = strings.TrimSpace(strings.TrimPrefix(line, "Unit serial number:")) } } return info, nil } // getLibraryCounts gets slot and drive count via mtx func (s *Service) getLibraryCounts(ctx context.Context, changerPath string) (slots, drives int, err error) { // Use mtx status to get slot count cmd := exec.CommandContext(ctx, "mtx", "-f", changerPath, "status") output, err := cmd.Output() if err != nil { return 0, 0, err } lines := strings.Split(string(output), "\n") for _, line := range lines { if strings.Contains(line, "Storage Element") { // Parse: Storage Element 1:Full (Storage Element 1:Full) parts := strings.Fields(line) for _, part := range parts { if strings.HasPrefix(part, "Element") { // Extract number numStr := strings.TrimPrefix(part, "Element") if num, err := strconv.Atoi(numStr); err == nil { if num > slots { slots = num } } } } } else if strings.Contains(line, "Data Transfer Element") { drives++ } } return slots, drives, nil } // DiscoverDrives discovers tape drives for a library func (s *Service) DiscoverDrives(ctx context.Context, libraryID, changerPath string) ([]TapeDrive, error) { // Use lsscsi to find tape drives cmd := exec.CommandContext(ctx, "lsscsi", "-g") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to run lsscsi: %w", err) } var drives []TapeDrive lines := strings.Split(strings.TrimSpace(string(output)), "\n") driveNum := 1 for _, line := range lines { if line == "" { continue } parts := strings.Fields(line) if len(parts) < 4 { continue } deviceType := parts[2] devicePath := "" sgPath := "" for i := 3; i < len(parts); i++ { if strings.HasPrefix(parts[i], "/dev/") { if strings.HasPrefix(parts[i], "/dev/sg") { sgPath = parts[i] } else if strings.HasPrefix(parts[i], "/dev/st") || strings.HasPrefix(parts[i], "/dev/nst") { devicePath = parts[i] } } } // Check for tape drive if deviceType == "tape" && devicePath != "" { driveInfo, err := s.getDriveInfo(ctx, sgPath) if err != nil { s.logger.Warn("Failed to get drive info", "device", sgPath, "error", err) continue } drive := TapeDrive{ LibraryID: libraryID, DriveNumber: driveNum, DevicePath: devicePath, StablePath: sgPath, Vendor: driveInfo["vendor"], Model: driveInfo["model"], SerialNumber: driveInfo["serial"], DriveType: driveInfo["type"], Status: "idle", IsActive: true, } drives = append(drives, drive) driveNum++ } } return drives, nil } // getDriveInfo retrieves drive information via sg_inq func (s *Service) getDriveInfo(ctx context.Context, sgPath string) (map[string]string, error) { cmd := exec.CommandContext(ctx, "sg_inq", "-i", sgPath) output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to run sg_inq: %w", err) } info := make(map[string]string) lines := strings.Split(string(output), "\n") for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "Vendor identification:") { info["vendor"] = strings.TrimSpace(strings.TrimPrefix(line, "Vendor identification:")) } else if strings.HasPrefix(line, "Product identification:") { info["model"] = strings.TrimSpace(strings.TrimPrefix(line, "Product identification:")) // Try to extract drive type from model (e.g., "LTO-8") if strings.Contains(strings.ToUpper(info["model"]), "LTO-8") { info["type"] = "LTO-8" } else if strings.Contains(strings.ToUpper(info["model"]), "LTO-9") { info["type"] = "LTO-9" } else { info["type"] = "Unknown" } } else if strings.HasPrefix(line, "Unit serial number:") { info["serial"] = strings.TrimSpace(strings.TrimPrefix(line, "Unit serial number:")) } } return info, nil } // PerformInventory performs a slot inventory of the library func (s *Service) PerformInventory(ctx context.Context, libraryID, changerPath string) ([]TapeSlot, error) { // Use mtx to get inventory cmd := exec.CommandContext(ctx, "mtx", "-f", changerPath, "status") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to run mtx status: %w", err) } var slots []TapeSlot lines := strings.Split(string(output), "\n") for _, line := range lines { line = strings.TrimSpace(line) if strings.Contains(line, "Storage Element") && strings.Contains(line, ":") { // Parse: Storage Element 1:Full (Storage Element 1:Full) [Storage Changer Serial Number] parts := strings.Fields(line) slotNum := 0 barcode := "" tapePresent := false for i, part := range parts { if part == "Element" && i+1 < len(parts) { // Next part should be the number if num, err := strconv.Atoi(strings.TrimSuffix(parts[i+1], ":")); err == nil { slotNum = num } } if part == "Full" { tapePresent = true } // Try to extract barcode from brackets if strings.HasPrefix(part, "[") && strings.HasSuffix(part, "]") { barcode = strings.Trim(part, "[]") } } if slotNum > 0 { slot := TapeSlot{ LibraryID: libraryID, SlotNumber: slotNum, Barcode: barcode, TapePresent: tapePresent, LastUpdatedAt: time.Now(), } slots = append(slots, slot) } } } return slots, nil } // LoadTape loads a tape from a slot into a drive func (s *Service) LoadTape(ctx context.Context, libraryID, changerPath string, slotNumber, driveNumber int) error { // Use mtx to load tape cmd := exec.CommandContext(ctx, "mtx", "-f", changerPath, "load", strconv.Itoa(slotNumber), strconv.Itoa(driveNumber)) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to load tape: %s: %w", string(output), err) } s.logger.Info("Tape loaded", "library_id", libraryID, "slot", slotNumber, "drive", driveNumber) return nil } // UnloadTape unloads a tape from a drive to a slot func (s *Service) UnloadTape(ctx context.Context, libraryID, changerPath string, driveNumber, slotNumber int) error { // Use mtx to unload tape cmd := exec.CommandContext(ctx, "mtx", "-f", changerPath, "unload", strconv.Itoa(slotNumber), strconv.Itoa(driveNumber)) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to unload tape: %s: %w", string(output), err) } s.logger.Info("Tape unloaded", "library_id", libraryID, "drive", driveNumber, "slot", slotNumber) return nil } // SyncLibraryToDatabase syncs discovered library to database func (s *Service) SyncLibraryToDatabase(ctx context.Context, library *TapeLibrary) error { // Check if library exists var existingID string err := s.db.QueryRowContext(ctx, "SELECT id FROM physical_tape_libraries WHERE serial_number = $1", library.SerialNumber, ).Scan(&existingID) if err == sql.ErrNoRows { // Insert new library query := ` INSERT INTO physical_tape_libraries ( name, serial_number, vendor, model, changer_device_path, changer_stable_path, slot_count, drive_count, is_active, discovered_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at ` err = s.db.QueryRowContext(ctx, query, library.Name, library.SerialNumber, library.Vendor, library.Model, library.ChangerDevicePath, library.ChangerStablePath, library.SlotCount, library.DriveCount, library.IsActive, library.DiscoveredAt, ).Scan(&library.ID, &library.CreatedAt, &library.UpdatedAt) if err != nil { return fmt.Errorf("failed to insert library: %w", err) } } else if err == nil { // Update existing library query := ` UPDATE physical_tape_libraries SET name = $1, vendor = $2, model = $3, changer_device_path = $4, changer_stable_path = $5, slot_count = $6, drive_count = $7, updated_at = NOW() WHERE id = $8 ` _, err = s.db.ExecContext(ctx, query, library.Name, library.Vendor, library.Model, library.ChangerDevicePath, library.ChangerStablePath, library.SlotCount, library.DriveCount, existingID, ) if err != nil { return fmt.Errorf("failed to update library: %w", err) } library.ID = existingID } else { return fmt.Errorf("failed to check library existence: %w", err) } return nil }