- Installed and configured SCST with 7 handlers - Installed and configured mhVTL with 2 Quantum libraries and 8 LTO-8 drives - Implemented all VTL API endpoints (8/9 working) - Fixed NULL device_path handling in drives endpoint - Added comprehensive error handling and validation - Implemented async tape load/unload operations - Created SCST installation guide for Ubuntu 24.04 - Created mhVTL installation and configuration guide - Added VTL testing guide and automated test scripts - All core API tests passing (89% success rate) Infrastructure status: - PostgreSQL: Configured with proper permissions - SCST: Active with kernel module loaded - mhVTL: 2 libraries (Quantum Scalar i500, Scalar i40) - mhVTL: 8 drives (all Quantum ULTRIUM-HH8 LTO-8) - Calypso API: 8/9 VTL endpoints functional Documentation added: - src/srs-technical-spec-documents/scst-installation.md - src/srs-technical-spec-documents/mhvtl-installation.md - VTL-TESTING-GUIDE.md - scripts/test-vtl.sh Co-Authored-By: Warp <agent@warp.dev>
437 lines
13 KiB
Go
437 lines
13 KiB
Go
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
|
|
}
|
|
|