- 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>
478 lines
14 KiB
Go
478 lines
14 KiB
Go
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,
|
|
)
|
|
}
|
|
|