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:
Warp Agent
2025-12-24 19:01:29 +00:00
parent 0537709576
commit 3aa0169af0
55 changed files with 10445 additions and 0 deletions

View File

@@ -0,0 +1,477 @@
package tape_physical
import (
"context"
"database/sql"
"fmt"
"net/http"
"github.com/atlasos/calypso/internal/common/database"
"github.com/atlasos/calypso/internal/common/logger"
"github.com/atlasos/calypso/internal/tasks"
"github.com/gin-gonic/gin"
)
// Handler handles physical tape library API requests
type Handler struct {
service *Service
taskEngine *tasks.Engine
db *database.DB
logger *logger.Logger
}
// NewHandler creates a new physical tape handler
func NewHandler(db *database.DB, log *logger.Logger) *Handler {
return &Handler{
service: NewService(db, log),
taskEngine: tasks.NewEngine(db, log),
db: db,
logger: log,
}
}
// ListLibraries lists all physical tape libraries
func (h *Handler) ListLibraries(c *gin.Context) {
query := `
SELECT id, name, serial_number, vendor, model,
changer_device_path, changer_stable_path,
slot_count, drive_count, is_active,
discovered_at, last_inventory_at, created_at, updated_at
FROM physical_tape_libraries
ORDER BY name
`
rows, err := h.db.QueryContext(c.Request.Context(), query)
if err != nil {
h.logger.Error("Failed to list libraries", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list libraries"})
return
}
defer rows.Close()
var libraries []TapeLibrary
for rows.Next() {
var lib TapeLibrary
var lastInventory sql.NullTime
err := rows.Scan(
&lib.ID, &lib.Name, &lib.SerialNumber, &lib.Vendor, &lib.Model,
&lib.ChangerDevicePath, &lib.ChangerStablePath,
&lib.SlotCount, &lib.DriveCount, &lib.IsActive,
&lib.DiscoveredAt, &lastInventory, &lib.CreatedAt, &lib.UpdatedAt,
)
if err != nil {
h.logger.Error("Failed to scan library", "error", err)
continue
}
if lastInventory.Valid {
lib.LastInventoryAt = &lastInventory.Time
}
libraries = append(libraries, lib)
}
c.JSON(http.StatusOK, gin.H{"libraries": libraries})
}
// GetLibrary retrieves a library by ID
func (h *Handler) GetLibrary(c *gin.Context) {
libraryID := c.Param("id")
query := `
SELECT id, name, serial_number, vendor, model,
changer_device_path, changer_stable_path,
slot_count, drive_count, is_active,
discovered_at, last_inventory_at, created_at, updated_at
FROM physical_tape_libraries
WHERE id = $1
`
var lib TapeLibrary
var lastInventory sql.NullTime
err := h.db.QueryRowContext(c.Request.Context(), query, libraryID).Scan(
&lib.ID, &lib.Name, &lib.SerialNumber, &lib.Vendor, &lib.Model,
&lib.ChangerDevicePath, &lib.ChangerStablePath,
&lib.SlotCount, &lib.DriveCount, &lib.IsActive,
&lib.DiscoveredAt, &lastInventory, &lib.CreatedAt, &lib.UpdatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "library not found"})
return
}
h.logger.Error("Failed to get library", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get library"})
return
}
if lastInventory.Valid {
lib.LastInventoryAt = &lastInventory.Time
}
// Get drives
drives, _ := h.GetLibraryDrives(c, libraryID)
// Get slots
slots, _ := h.GetLibrarySlots(c, libraryID)
c.JSON(http.StatusOK, gin.H{
"library": lib,
"drives": drives,
"slots": slots,
})
}
// DiscoverLibraries discovers physical tape libraries (async)
func (h *Handler) DiscoverLibraries(c *gin.Context) {
userID, _ := c.Get("user_id")
// Create async task
taskID, err := h.taskEngine.CreateTask(c.Request.Context(),
tasks.TaskTypeRescan, userID.(string), map[string]interface{}{
"operation": "discover_tape_libraries",
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task"})
return
}
// Run discovery in background
go func() {
ctx := c.Request.Context()
h.taskEngine.StartTask(ctx, taskID)
h.taskEngine.UpdateProgress(ctx, taskID, 30, "Discovering tape libraries...")
libraries, err := h.service.DiscoverLibraries(ctx)
if err != nil {
h.taskEngine.FailTask(ctx, taskID, err.Error())
return
}
h.taskEngine.UpdateProgress(ctx, taskID, 60, "Syncing libraries to database...")
// Sync each library to database
for _, lib := range libraries {
if err := h.service.SyncLibraryToDatabase(ctx, &lib); err != nil {
h.logger.Warn("Failed to sync library", "library", lib.Name, "error", err)
continue
}
// Discover drives for this library
if lib.ChangerDevicePath != "" {
drives, err := h.service.DiscoverDrives(ctx, lib.ID, lib.ChangerDevicePath)
if err == nil {
// Sync drives to database
for _, drive := range drives {
h.syncDriveToDatabase(ctx, &drive)
}
}
}
}
h.taskEngine.UpdateProgress(ctx, taskID, 100, "Discovery completed")
h.taskEngine.CompleteTask(ctx, taskID, "Tape libraries discovered successfully")
}()
c.JSON(http.StatusAccepted, gin.H{"task_id": taskID})
}
// GetLibraryDrives lists drives for a library
func (h *Handler) GetLibraryDrives(c *gin.Context, libraryID string) ([]TapeDrive, error) {
query := `
SELECT id, library_id, drive_number, device_path, stable_path,
vendor, model, serial_number, drive_type, status,
current_tape_barcode, is_active, created_at, updated_at
FROM physical_tape_drives
WHERE library_id = $1
ORDER BY drive_number
`
rows, err := h.db.QueryContext(c.Request.Context(), query, libraryID)
if err != nil {
return nil, err
}
defer rows.Close()
var drives []TapeDrive
for rows.Next() {
var drive TapeDrive
var barcode sql.NullString
err := rows.Scan(
&drive.ID, &drive.LibraryID, &drive.DriveNumber, &drive.DevicePath, &drive.StablePath,
&drive.Vendor, &drive.Model, &drive.SerialNumber, &drive.DriveType, &drive.Status,
&barcode, &drive.IsActive, &drive.CreatedAt, &drive.UpdatedAt,
)
if err != nil {
h.logger.Error("Failed to scan drive", "error", err)
continue
}
if barcode.Valid {
drive.CurrentTapeBarcode = barcode.String
}
drives = append(drives, drive)
}
return drives, rows.Err()
}
// GetLibrarySlots lists slots for a library
func (h *Handler) GetLibrarySlots(c *gin.Context, libraryID string) ([]TapeSlot, error) {
query := `
SELECT id, library_id, slot_number, barcode, tape_present,
tape_type, last_updated_at
FROM physical_tape_slots
WHERE library_id = $1
ORDER BY slot_number
`
rows, err := h.db.QueryContext(c.Request.Context(), query, libraryID)
if err != nil {
return nil, err
}
defer rows.Close()
var slots []TapeSlot
for rows.Next() {
var slot TapeSlot
err := rows.Scan(
&slot.ID, &slot.LibraryID, &slot.SlotNumber, &slot.Barcode,
&slot.TapePresent, &slot.TapeType, &slot.LastUpdatedAt,
)
if err != nil {
h.logger.Error("Failed to scan slot", "error", err)
continue
}
slots = append(slots, slot)
}
return slots, rows.Err()
}
// PerformInventory performs inventory of a library (async)
func (h *Handler) PerformInventory(c *gin.Context) {
libraryID := c.Param("id")
// Get library
var changerPath string
err := h.db.QueryRowContext(c.Request.Context(),
"SELECT changer_device_path FROM physical_tape_libraries WHERE id = $1",
libraryID,
).Scan(&changerPath)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "library not found"})
return
}
userID, _ := c.Get("user_id")
// Create async task
taskID, err := h.taskEngine.CreateTask(c.Request.Context(),
tasks.TaskTypeInventory, userID.(string), map[string]interface{}{
"operation": "inventory",
"library_id": libraryID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task"})
return
}
// Run inventory in background
go func() {
ctx := c.Request.Context()
h.taskEngine.StartTask(ctx, taskID)
h.taskEngine.UpdateProgress(ctx, taskID, 50, "Performing inventory...")
slots, err := h.service.PerformInventory(ctx, libraryID, changerPath)
if err != nil {
h.taskEngine.FailTask(ctx, taskID, err.Error())
return
}
// Sync slots to database
for _, slot := range slots {
h.syncSlotToDatabase(ctx, &slot)
}
// Update last inventory time
h.db.ExecContext(ctx,
"UPDATE physical_tape_libraries SET last_inventory_at = NOW() WHERE id = $1",
libraryID,
)
h.taskEngine.UpdateProgress(ctx, taskID, 100, "Inventory completed")
h.taskEngine.CompleteTask(ctx, taskID, fmt.Sprintf("Inventory completed: %d slots", len(slots)))
}()
c.JSON(http.StatusAccepted, gin.H{"task_id": taskID})
}
// LoadTapeRequest represents a load tape request
type LoadTapeRequest struct {
SlotNumber int `json:"slot_number" binding:"required"`
DriveNumber int `json:"drive_number" binding:"required"`
}
// LoadTape loads a tape from slot to drive (async)
func (h *Handler) LoadTape(c *gin.Context) {
libraryID := c.Param("id")
var req LoadTapeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
// Get library
var changerPath string
err := h.db.QueryRowContext(c.Request.Context(),
"SELECT changer_device_path FROM physical_tape_libraries WHERE id = $1",
libraryID,
).Scan(&changerPath)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "library not found"})
return
}
userID, _ := c.Get("user_id")
// Create async task
taskID, err := h.taskEngine.CreateTask(c.Request.Context(),
tasks.TaskTypeLoadUnload, userID.(string), map[string]interface{}{
"operation": "load_tape",
"library_id": libraryID,
"slot_number": req.SlotNumber,
"drive_number": req.DriveNumber,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task"})
return
}
// Run load in background
go func() {
ctx := c.Request.Context()
h.taskEngine.StartTask(ctx, taskID)
h.taskEngine.UpdateProgress(ctx, taskID, 50, "Loading tape...")
if err := h.service.LoadTape(ctx, libraryID, changerPath, req.SlotNumber, req.DriveNumber); err != nil {
h.taskEngine.FailTask(ctx, taskID, err.Error())
return
}
// Update drive status
h.db.ExecContext(ctx,
"UPDATE physical_tape_drives SET status = 'ready', updated_at = NOW() WHERE library_id = $1 AND drive_number = $2",
libraryID, req.DriveNumber,
)
h.taskEngine.UpdateProgress(ctx, taskID, 100, "Tape loaded")
h.taskEngine.CompleteTask(ctx, taskID, "Tape loaded successfully")
}()
c.JSON(http.StatusAccepted, gin.H{"task_id": taskID})
}
// UnloadTapeRequest represents an unload tape request
type UnloadTapeRequest struct {
DriveNumber int `json:"drive_number" binding:"required"`
SlotNumber int `json:"slot_number" binding:"required"`
}
// UnloadTape unloads a tape from drive to slot (async)
func (h *Handler) UnloadTape(c *gin.Context) {
libraryID := c.Param("id")
var req UnloadTapeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
// Get library
var changerPath string
err := h.db.QueryRowContext(c.Request.Context(),
"SELECT changer_device_path FROM physical_tape_libraries WHERE id = $1",
libraryID,
).Scan(&changerPath)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "library not found"})
return
}
userID, _ := c.Get("user_id")
// Create async task
taskID, err := h.taskEngine.CreateTask(c.Request.Context(),
tasks.TaskTypeLoadUnload, userID.(string), map[string]interface{}{
"operation": "unload_tape",
"library_id": libraryID,
"slot_number": req.SlotNumber,
"drive_number": req.DriveNumber,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task"})
return
}
// Run unload in background
go func() {
ctx := c.Request.Context()
h.taskEngine.StartTask(ctx, taskID)
h.taskEngine.UpdateProgress(ctx, taskID, 50, "Unloading tape...")
if err := h.service.UnloadTape(ctx, libraryID, changerPath, req.DriveNumber, req.SlotNumber); err != nil {
h.taskEngine.FailTask(ctx, taskID, err.Error())
return
}
// Update drive status
h.db.ExecContext(ctx,
"UPDATE physical_tape_drives SET status = 'idle', current_tape_barcode = NULL, updated_at = NOW() WHERE library_id = $1 AND drive_number = $2",
libraryID, req.DriveNumber,
)
h.taskEngine.UpdateProgress(ctx, taskID, 100, "Tape unloaded")
h.taskEngine.CompleteTask(ctx, taskID, "Tape unloaded successfully")
}()
c.JSON(http.StatusAccepted, gin.H{"task_id": taskID})
}
// syncDriveToDatabase syncs a drive to the database
func (h *Handler) syncDriveToDatabase(ctx context.Context, drive *TapeDrive) {
query := `
INSERT INTO physical_tape_drives (
library_id, drive_number, device_path, stable_path,
vendor, model, serial_number, drive_type, status, is_active
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (library_id, drive_number) DO UPDATE SET
device_path = EXCLUDED.device_path,
stable_path = EXCLUDED.stable_path,
vendor = EXCLUDED.vendor,
model = EXCLUDED.model,
serial_number = EXCLUDED.serial_number,
drive_type = EXCLUDED.drive_type,
updated_at = NOW()
`
h.db.ExecContext(ctx, query,
drive.LibraryID, drive.DriveNumber, drive.DevicePath, drive.StablePath,
drive.Vendor, drive.Model, drive.SerialNumber, drive.DriveType, drive.Status, drive.IsActive,
)
}
// syncSlotToDatabase syncs a slot to the database
func (h *Handler) syncSlotToDatabase(ctx context.Context, slot *TapeSlot) {
query := `
INSERT INTO physical_tape_slots (
library_id, slot_number, barcode, tape_present, tape_type, last_updated_at
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (library_id, slot_number) DO UPDATE SET
barcode = EXCLUDED.barcode,
tape_present = EXCLUDED.tape_present,
tape_type = EXCLUDED.tape_type,
last_updated_at = EXCLUDED.last_updated_at
`
h.db.ExecContext(ctx, query,
slot.LibraryID, slot.SlotNumber, slot.Barcode, slot.TapePresent, slot.TapeType, slot.LastUpdatedAt,
)
}

View 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
}