Complete VTL implementation with SCST and mhVTL integration
- 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>
This commit is contained in:
436
backend/internal/tape_physical/service.go
Normal file
436
backend/internal/tape_physical/service.go
Normal file
@@ -0,0 +1,436 @@
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user