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:
503
backend/internal/tape_vtl/service.go
Normal file
503
backend/internal/tape_vtl/service.go
Normal file
@@ -0,0 +1,503 @@
|
||||
package tape_vtl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
)
|
||||
|
||||
// Service handles virtual tape library (MHVTL) operations
|
||||
type Service struct {
|
||||
db *database.DB
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewService creates a new VTL service
|
||||
func NewService(db *database.DB, log *logger.Logger) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
// VirtualTapeLibrary represents a virtual tape library
|
||||
type VirtualTapeLibrary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
MHVTLibraryID int `json:"mhvtl_library_id"`
|
||||
BackingStorePath string `json:"backing_store_path"`
|
||||
SlotCount int `json:"slot_count"`
|
||||
DriveCount int `json:"drive_count"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
|
||||
// VirtualTapeDrive represents a virtual tape drive
|
||||
type VirtualTapeDrive struct {
|
||||
ID string `json:"id"`
|
||||
LibraryID string `json:"library_id"`
|
||||
DriveNumber int `json:"drive_number"`
|
||||
DevicePath *string `json:"device_path,omitempty"`
|
||||
StablePath *string `json:"stable_path,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CurrentTapeID string `json:"current_tape_id,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// VirtualTape represents a virtual tape
|
||||
type VirtualTape struct {
|
||||
ID string `json:"id"`
|
||||
LibraryID string `json:"library_id"`
|
||||
Barcode string `json:"barcode"`
|
||||
SlotNumber int `json:"slot_number"`
|
||||
ImageFilePath string `json:"image_file_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
TapeType string `json:"tape_type"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateLibrary creates a new virtual tape library
|
||||
func (s *Service) CreateLibrary(ctx context.Context, name, description, backingStorePath string, slotCount, driveCount int, createdBy string) (*VirtualTapeLibrary, error) {
|
||||
// Ensure backing store directory exists
|
||||
fullPath := filepath.Join(backingStorePath, name)
|
||||
if err := os.MkdirAll(fullPath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create backing store directory: %w", err)
|
||||
}
|
||||
|
||||
// Create tapes directory
|
||||
tapesPath := filepath.Join(fullPath, "tapes")
|
||||
if err := os.MkdirAll(tapesPath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create tapes directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate MHVTL library ID (use next available ID)
|
||||
mhvtlID, err := s.getNextMHVTLID(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get next MHVTL ID: %w", err)
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
query := `
|
||||
INSERT INTO virtual_tape_libraries (
|
||||
name, description, mhvtl_library_id, backing_store_path,
|
||||
slot_count, drive_count, is_active, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
var lib VirtualTapeLibrary
|
||||
err = s.db.QueryRowContext(ctx, query,
|
||||
name, description, mhvtlID, fullPath,
|
||||
slotCount, driveCount, true, createdBy,
|
||||
).Scan(&lib.ID, &lib.CreatedAt, &lib.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save library to database: %w", err)
|
||||
}
|
||||
|
||||
lib.Name = name
|
||||
lib.Description = description
|
||||
lib.MHVTLibraryID = mhvtlID
|
||||
lib.BackingStorePath = fullPath
|
||||
lib.SlotCount = slotCount
|
||||
lib.DriveCount = driveCount
|
||||
lib.IsActive = true
|
||||
lib.CreatedBy = createdBy
|
||||
|
||||
// Create virtual drives
|
||||
for i := 1; i <= driveCount; i++ {
|
||||
drive := VirtualTapeDrive{
|
||||
LibraryID: lib.ID,
|
||||
DriveNumber: i,
|
||||
Status: "idle",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := s.createDrive(ctx, &drive); err != nil {
|
||||
s.logger.Error("Failed to create drive", "drive_number", i, "error", err)
|
||||
// Continue creating other drives even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
// Create initial tapes in slots
|
||||
for i := 1; i <= slotCount; i++ {
|
||||
barcode := fmt.Sprintf("V%05d", i)
|
||||
tape := VirtualTape{
|
||||
LibraryID: lib.ID,
|
||||
Barcode: barcode,
|
||||
SlotNumber: i,
|
||||
ImageFilePath: filepath.Join(tapesPath, fmt.Sprintf("%s.img", barcode)),
|
||||
SizeBytes: 800 * 1024 * 1024 * 1024, // 800 GB default (LTO-8)
|
||||
UsedBytes: 0,
|
||||
TapeType: "LTO-8",
|
||||
Status: "idle",
|
||||
}
|
||||
if err := s.createTape(ctx, &tape); err != nil {
|
||||
s.logger.Error("Failed to create tape", "slot", i, "error", err)
|
||||
// Continue creating other tapes even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("Virtual tape library created", "name", name, "id", lib.ID)
|
||||
return &lib, nil
|
||||
}
|
||||
|
||||
// getNextMHVTLID gets the next available MHVTL library ID
|
||||
func (s *Service) getNextMHVTLID(ctx context.Context) (int, error) {
|
||||
var maxID sql.NullInt64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
"SELECT MAX(mhvtl_library_id) FROM virtual_tape_libraries",
|
||||
).Scan(&maxID)
|
||||
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if maxID.Valid {
|
||||
return int(maxID.Int64) + 1, nil
|
||||
}
|
||||
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// createDrive creates a virtual tape drive
|
||||
func (s *Service) createDrive(ctx context.Context, drive *VirtualTapeDrive) error {
|
||||
query := `
|
||||
INSERT INTO virtual_tape_drives (
|
||||
library_id, drive_number, status, is_active
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
err := s.db.QueryRowContext(ctx, query,
|
||||
drive.LibraryID, drive.DriveNumber, drive.Status, drive.IsActive,
|
||||
).Scan(&drive.ID, &drive.CreatedAt, &drive.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create drive: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTape creates a virtual tape
|
||||
func (s *Service) createTape(ctx context.Context, tape *VirtualTape) error {
|
||||
// Create empty tape image file
|
||||
file, err := os.Create(tape.ImageFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tape image: %w", err)
|
||||
}
|
||||
file.Close()
|
||||
|
||||
query := `
|
||||
INSERT INTO virtual_tapes (
|
||||
library_id, barcode, slot_number, image_file_path,
|
||||
size_bytes, used_bytes, tape_type, status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
err = s.db.QueryRowContext(ctx, query,
|
||||
tape.LibraryID, tape.Barcode, tape.SlotNumber, tape.ImageFilePath,
|
||||
tape.SizeBytes, tape.UsedBytes, tape.TapeType, tape.Status,
|
||||
).Scan(&tape.ID, &tape.CreatedAt, &tape.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tape: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListLibraries lists all virtual tape libraries
|
||||
func (s *Service) ListLibraries(ctx context.Context) ([]VirtualTapeLibrary, error) {
|
||||
query := `
|
||||
SELECT id, name, description, mhvtl_library_id, backing_store_path,
|
||||
slot_count, drive_count, is_active, created_at, updated_at, created_by
|
||||
FROM virtual_tape_libraries
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list libraries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var libraries []VirtualTapeLibrary
|
||||
for rows.Next() {
|
||||
var lib VirtualTapeLibrary
|
||||
err := rows.Scan(
|
||||
&lib.ID, &lib.Name, &lib.Description, &lib.MHVTLibraryID, &lib.BackingStorePath,
|
||||
&lib.SlotCount, &lib.DriveCount, &lib.IsActive,
|
||||
&lib.CreatedAt, &lib.UpdatedAt, &lib.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to scan library", "error", err)
|
||||
continue
|
||||
}
|
||||
libraries = append(libraries, lib)
|
||||
}
|
||||
|
||||
return libraries, rows.Err()
|
||||
}
|
||||
|
||||
// GetLibrary retrieves a library by ID
|
||||
func (s *Service) GetLibrary(ctx context.Context, id string) (*VirtualTapeLibrary, error) {
|
||||
query := `
|
||||
SELECT id, name, description, mhvtl_library_id, backing_store_path,
|
||||
slot_count, drive_count, is_active, created_at, updated_at, created_by
|
||||
FROM virtual_tape_libraries
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var lib VirtualTapeLibrary
|
||||
err := s.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&lib.ID, &lib.Name, &lib.Description, &lib.MHVTLibraryID, &lib.BackingStorePath,
|
||||
&lib.SlotCount, &lib.DriveCount, &lib.IsActive,
|
||||
&lib.CreatedAt, &lib.UpdatedAt, &lib.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("library not found")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get library: %w", err)
|
||||
}
|
||||
|
||||
return &lib, nil
|
||||
}
|
||||
|
||||
// GetLibraryDrives retrieves drives for a library
|
||||
func (s *Service) GetLibraryDrives(ctx context.Context, libraryID string) ([]VirtualTapeDrive, error) {
|
||||
query := `
|
||||
SELECT id, library_id, drive_number, device_path, stable_path,
|
||||
status, current_tape_id, is_active, created_at, updated_at
|
||||
FROM virtual_tape_drives
|
||||
WHERE library_id = $1
|
||||
ORDER BY drive_number
|
||||
`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, libraryID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get drives: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var drives []VirtualTapeDrive
|
||||
for rows.Next() {
|
||||
var drive VirtualTapeDrive
|
||||
var tapeID, devicePath, stablePath sql.NullString
|
||||
err := rows.Scan(
|
||||
&drive.ID, &drive.LibraryID, &drive.DriveNumber,
|
||||
&devicePath, &stablePath,
|
||||
&drive.Status, &tapeID, &drive.IsActive,
|
||||
&drive.CreatedAt, &drive.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to scan drive", "error", err)
|
||||
continue
|
||||
}
|
||||
if devicePath.Valid {
|
||||
drive.DevicePath = &devicePath.String
|
||||
}
|
||||
if stablePath.Valid {
|
||||
drive.StablePath = &stablePath.String
|
||||
}
|
||||
if tapeID.Valid {
|
||||
drive.CurrentTapeID = tapeID.String
|
||||
}
|
||||
drives = append(drives, drive)
|
||||
}
|
||||
|
||||
return drives, rows.Err()
|
||||
}
|
||||
|
||||
// GetLibraryTapes retrieves tapes for a library
|
||||
func (s *Service) GetLibraryTapes(ctx context.Context, libraryID string) ([]VirtualTape, error) {
|
||||
query := `
|
||||
SELECT id, library_id, barcode, slot_number, image_file_path,
|
||||
size_bytes, used_bytes, tape_type, status, created_at, updated_at
|
||||
FROM virtual_tapes
|
||||
WHERE library_id = $1
|
||||
ORDER BY slot_number
|
||||
`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, libraryID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get tapes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tapes []VirtualTape
|
||||
for rows.Next() {
|
||||
var tape VirtualTape
|
||||
err := rows.Scan(
|
||||
&tape.ID, &tape.LibraryID, &tape.Barcode, &tape.SlotNumber,
|
||||
&tape.ImageFilePath, &tape.SizeBytes, &tape.UsedBytes,
|
||||
&tape.TapeType, &tape.Status, &tape.CreatedAt, &tape.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to scan tape", "error", err)
|
||||
continue
|
||||
}
|
||||
tapes = append(tapes, tape)
|
||||
}
|
||||
|
||||
return tapes, rows.Err()
|
||||
}
|
||||
|
||||
// CreateTape creates a new virtual tape
|
||||
func (s *Service) CreateTape(ctx context.Context, libraryID, barcode string, slotNumber int, tapeType string, sizeBytes int64) (*VirtualTape, error) {
|
||||
// Get library to find backing store path
|
||||
lib, err := s.GetLibrary(ctx, libraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create tape image file
|
||||
tapesPath := filepath.Join(lib.BackingStorePath, "tapes")
|
||||
imagePath := filepath.Join(tapesPath, fmt.Sprintf("%s.img", barcode))
|
||||
|
||||
file, err := os.Create(imagePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create tape image: %w", err)
|
||||
}
|
||||
file.Close()
|
||||
|
||||
tape := VirtualTape{
|
||||
LibraryID: libraryID,
|
||||
Barcode: barcode,
|
||||
SlotNumber: slotNumber,
|
||||
ImageFilePath: imagePath,
|
||||
SizeBytes: sizeBytes,
|
||||
UsedBytes: 0,
|
||||
TapeType: tapeType,
|
||||
Status: "idle",
|
||||
}
|
||||
|
||||
return s.createTapeRecord(ctx, &tape)
|
||||
}
|
||||
|
||||
// createTapeRecord creates a tape record in the database
|
||||
func (s *Service) createTapeRecord(ctx context.Context, tape *VirtualTape) (*VirtualTape, error) {
|
||||
query := `
|
||||
INSERT INTO virtual_tapes (
|
||||
library_id, barcode, slot_number, image_file_path,
|
||||
size_bytes, used_bytes, tape_type, status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
err := s.db.QueryRowContext(ctx, query,
|
||||
tape.LibraryID, tape.Barcode, tape.SlotNumber, tape.ImageFilePath,
|
||||
tape.SizeBytes, tape.UsedBytes, tape.TapeType, tape.Status,
|
||||
).Scan(&tape.ID, &tape.CreatedAt, &tape.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create tape record: %w", err)
|
||||
}
|
||||
|
||||
return tape, nil
|
||||
}
|
||||
|
||||
// LoadTape loads a tape from slot to drive
|
||||
func (s *Service) LoadTape(ctx context.Context, libraryID string, slotNumber, driveNumber int) error {
|
||||
// Get tape from slot
|
||||
var tapeID, barcode string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
"SELECT id, barcode FROM virtual_tapes WHERE library_id = $1 AND slot_number = $2",
|
||||
libraryID, slotNumber,
|
||||
).Scan(&tapeID, &barcode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tape not found in slot: %w", err)
|
||||
}
|
||||
|
||||
// Update tape status
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
"UPDATE virtual_tapes SET status = 'in_drive', updated_at = NOW() WHERE id = $1",
|
||||
tapeID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update tape status: %w", err)
|
||||
}
|
||||
|
||||
// Update drive status
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
"UPDATE virtual_tape_drives SET status = 'ready', current_tape_id = $1, updated_at = NOW() WHERE library_id = $2 AND drive_number = $3",
|
||||
tapeID, libraryID, driveNumber,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update drive status: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Virtual tape loaded", "library_id", libraryID, "slot", slotNumber, "drive", driveNumber, "barcode", barcode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnloadTape unloads a tape from drive to slot
|
||||
func (s *Service) UnloadTape(ctx context.Context, libraryID string, driveNumber, slotNumber int) error {
|
||||
// Get current tape in drive
|
||||
var tapeID string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
"SELECT current_tape_id FROM virtual_tape_drives WHERE library_id = $1 AND drive_number = $2",
|
||||
libraryID, driveNumber,
|
||||
).Scan(&tapeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no tape in drive: %w", err)
|
||||
}
|
||||
|
||||
// Update tape status and slot
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
"UPDATE virtual_tapes SET status = 'idle', slot_number = $1, updated_at = NOW() WHERE id = $2",
|
||||
slotNumber, tapeID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update tape: %w", err)
|
||||
}
|
||||
|
||||
// Update drive status
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
"UPDATE virtual_tape_drives SET status = 'idle', current_tape_id = NULL, updated_at = NOW() WHERE library_id = $1 AND drive_number = $2",
|
||||
libraryID, driveNumber,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update drive: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Virtual tape unloaded", "library_id", libraryID, "drive", driveNumber, "slot", slotNumber)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteLibrary deletes a virtual tape library
|
||||
func (s *Service) DeleteLibrary(ctx context.Context, id string) error {
|
||||
lib, err := s.GetLibrary(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if lib.IsActive {
|
||||
return fmt.Errorf("cannot delete active library")
|
||||
}
|
||||
|
||||
// Delete from database (cascade will handle drives and tapes)
|
||||
_, err = s.db.ExecContext(ctx, "DELETE FROM virtual_tape_libraries WHERE id = $1", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete library: %w", err)
|
||||
}
|
||||
|
||||
// Optionally remove backing store (commented out for safety)
|
||||
// os.RemoveAll(lib.BackingStorePath)
|
||||
|
||||
s.logger.Info("Virtual tape library deleted", "id", id, "name", lib.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user