From 6a5ead9dbfcd7fb6acb1a365cd5ff2097aa7ca49 Mon Sep 17 00:00:00 2001 From: Othman Hendy Suseo Date: Mon, 22 Dec 2025 19:35:55 +0000 Subject: [PATCH] working on vtl features: pending drive creation, media changer creation, iscsi mapping --- atlas.code-workspace | 7 + internal/httpapp/auth_middleware.go | 28 +- internal/httpapp/dashboard_handlers.go | 13 + internal/httpapp/routes.go | 18 + internal/httpapp/vtl_handlers.go | 109 +- internal/services/vtl.go | 1358 ++++++++++++++++++++++-- web/templates/dashboard.html | 5 + web/templates/vtl.html | 890 ++++++++++++++++ 8 files changed, 2350 insertions(+), 78 deletions(-) create mode 100644 atlas.code-workspace create mode 100644 web/templates/vtl.html diff --git a/atlas.code-workspace b/atlas.code-workspace new file mode 100644 index 0000000..362d7c2 --- /dev/null +++ b/atlas.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/internal/httpapp/auth_middleware.go b/internal/httpapp/auth_middleware.go index 76cb2ee..39a120f 100644 --- a/internal/httpapp/auth_middleware.go +++ b/internal/httpapp/auth_middleware.go @@ -113,6 +113,7 @@ func (a *App) isPublicEndpoint(path, method string) bool { "/storage", // Storage management page "/shares", // Shares page "/iscsi", // iSCSI page + "/vtl", // VTL (Virtual Tape Library) page "/protection", // Data Protection page "/management", // System Management page "/api/docs", // API documentation @@ -138,17 +139,22 @@ func (a *App) isPublicEndpoint(path, method string) bool { // SECURITY: Only GET requests are allowed without authentication // POST, PUT, DELETE, PATCH require authentication publicReadOnlyPaths := []string{ - "/api/v1/dashboard", // Dashboard data - "/api/v1/disks", // List disks - "/api/v1/pools", // List pools (GET only) - "/api/v1/pools/available", // List available pools - "/api/v1/datasets", // List datasets (GET only) - "/api/v1/zvols", // List ZVOLs (GET only) - "/api/v1/shares/smb", // List SMB shares (GET only) - "/api/v1/exports/nfs", // List NFS exports (GET only) - "/api/v1/iscsi/targets", // List iSCSI targets (GET only) - "/api/v1/snapshots", // List snapshots (GET only) - "/api/v1/snapshot-policies", // List snapshot policies (GET only) + "/api/v1/dashboard", // Dashboard data + "/api/v1/disks", // List disks + "/api/v1/pools", // List pools (GET only) + "/api/v1/pools/available", // List available pools + "/api/v1/datasets", // List datasets (GET only) + "/api/v1/zvols", // List ZVOLs (GET only) + "/api/v1/shares/smb", // List SMB shares (GET only) + "/api/v1/exports/nfs", // List NFS exports (GET only) + "/api/v1/iscsi/targets", // List iSCSI targets (GET only) + "/api/v1/vtl/status", // VTL status (GET only) + "/api/v1/vtl/drives", // List VTL drives (GET only) + "/api/v1/vtl/tapes", // List VTL tapes (GET only) + "/api/v1/vtl/changers", // List VTL media changers (GET only) + "/api/v1/vtl/changer/status", // VTL media changer status (GET only) + "/api/v1/snapshots", // List snapshots (GET only) + "/api/v1/snapshot-policies", // List snapshot policies (GET only) } for _, publicPath := range publicReadOnlyPaths { diff --git a/internal/httpapp/dashboard_handlers.go b/internal/httpapp/dashboard_handlers.go index 4770623..72de91f 100644 --- a/internal/httpapp/dashboard_handlers.go +++ b/internal/httpapp/dashboard_handlers.go @@ -24,6 +24,9 @@ type DashboardData struct { SMBStatus bool `json:"smb_status"` NFSStatus bool `json:"nfs_status"` ISCSIStatus bool `json:"iscsi_status"` + VTLStatus bool `json:"vtl_status"` + VTLDrives int `json:"vtl_drives"` + VTLTapes int `json:"vtl_tapes"` } `json:"services"` Jobs struct { Total int `json:"total"` @@ -93,6 +96,16 @@ func (a *App) handleDashboardAPI(w http.ResponseWriter, r *http.Request) { data.Services.ISCSIStatus, _ = a.iscsiService.GetStatus() } + // VTL status + if a.vtlService != nil { + vtlStatus, err := a.vtlService.GetStatus() + if err == nil { + data.Services.VTLStatus = vtlStatus.ServiceRunning + data.Services.VTLDrives = vtlStatus.DrivesOnline + data.Services.VTLTapes = vtlStatus.TapesAvailable + } + } + // Job statistics allJobs := a.jobManager.List("") data.Jobs.Total = len(allJobs) diff --git a/internal/httpapp/routes.go b/internal/httpapp/routes.go index d60b54e..f231f6c 100644 --- a/internal/httpapp/routes.go +++ b/internal/httpapp/routes.go @@ -172,6 +172,24 @@ func (a *App) routes() { func(w http.ResponseWriter, r *http.Request) { a.handleVTLServiceControl(w, r) }, nil, nil, nil, )) + a.mux.HandleFunc("/api/v1/vtl/changers", methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleListVTLMediaChangers(w, r) }, + nil, nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/vtl/changer/status", methodHandler( + func(w http.ResponseWriter, r *http.Request) { a.handleGetVTLMediaChangerStatus(w, r) }, + nil, nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/vtl/tape/load", methodHandler( + nil, + func(w http.ResponseWriter, r *http.Request) { a.handleLoadTape(w, r) }, + nil, nil, nil, + )) + a.mux.HandleFunc("/api/v1/vtl/tape/eject", methodHandler( + nil, + func(w http.ResponseWriter, r *http.Request) { a.handleEjectTape(w, r) }, + nil, nil, nil, + )) // Job Management a.mux.HandleFunc("/api/v1/jobs", methodHandler( diff --git a/internal/httpapp/vtl_handlers.go b/internal/httpapp/vtl_handlers.go index 071f0ff..61c678e 100644 --- a/internal/httpapp/vtl_handlers.go +++ b/internal/httpapp/vtl_handlers.go @@ -51,9 +51,11 @@ func (a *App) handleListVTLTapes(w http.ResponseWriter, r *http.Request) { // handleCreateVTLTape creates a new virtual tape func (a *App) handleCreateVTLTape(w http.ResponseWriter, r *http.Request) { var req struct { - Barcode string `json:"barcode"` - Type string `json:"type"` // e.g., "LTO-5", "LTO-6" - Size uint64 `json:"size"` // Size in bytes (0 = default) + Barcode string `json:"barcode"` + Type string `json:"type"` // e.g., "LTO-5", "LTO-6" + Size uint64 `json:"size"` // Size in bytes (0 = default, will use generation-based size) + LibraryID int `json:"library_id"` // Library ID where tape will be placed + SlotID int `json:"slot_id"` // Slot ID in library where tape will be placed } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -66,11 +68,22 @@ func (a *App) handleCreateVTLTape(w http.ResponseWriter, r *http.Request) { return } - if req.Type == "" { - req.Type = "LTO-5" // Default type + if req.LibraryID <= 0 { + writeError(w, errors.ErrValidation("library_id is required and must be greater than 0")) + return } - if err := a.vtlService.CreateTape(req.Barcode, req.Type, req.Size); err != nil { + if req.SlotID <= 0 { + writeError(w, errors.ErrValidation("slot_id is required and must be greater than 0")) + return + } + + if req.Type == "" { + // Will be determined from barcode suffix if not provided + req.Type = "" + } + + if err := a.vtlService.CreateTape(req.Barcode, req.Type, req.Size, req.LibraryID, req.SlotID); err != nil { log.Printf("create VTL tape error: %v", err) writeError(w, errors.ErrInternal(fmt.Sprintf("failed to create VTL tape: %v", err))) return @@ -188,3 +201,87 @@ func (a *App) handleGetVTLTape(w http.ResponseWriter, r *http.Request) { writeError(w, errors.ErrNotFound("tape not found")) } + +// handleListVTLMediaChangers returns all media changers +func (a *App) handleListVTLMediaChangers(w http.ResponseWriter, r *http.Request) { + changers, err := a.vtlService.ListMediaChangers() + if err != nil { + log.Printf("list VTL media changers error: %v", err) + writeError(w, errors.ErrInternal(fmt.Sprintf("failed to list VTL media changers: %v", err))) + return + } + + writeJSON(w, http.StatusOK, changers) +} + +// handleGetVTLMediaChangerStatus returns media changer status (all changers) +func (a *App) handleGetVTLMediaChangerStatus(w http.ResponseWriter, r *http.Request) { + changers, err := a.vtlService.ListMediaChangers() + if err != nil { + log.Printf("get VTL media changer status error: %v", err) + writeError(w, errors.ErrInternal(fmt.Sprintf("failed to get VTL media changer status: %v", err))) + return + } + + // Return all changers, or first one if only one is requested + if len(changers) == 0 { + writeError(w, errors.ErrNotFound("no media changer found")) + return + } + + // Return all changers as array + writeJSON(w, http.StatusOK, changers) +} + +// handleLoadTape loads a tape into a drive +func (a *App) handleLoadTape(w http.ResponseWriter, r *http.Request) { + var req struct { + DriveID int `json:"drive_id"` + Barcode string `json:"barcode"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, errors.ErrValidation(fmt.Sprintf("invalid request body: %v", err))) + return + } + + if req.Barcode == "" { + writeError(w, errors.ErrValidation("barcode is required")) + return + } + + if err := a.vtlService.LoadTape(req.DriveID, req.Barcode); err != nil { + log.Printf("load tape error: %v", err) + writeError(w, errors.ErrInternal(fmt.Sprintf("failed to load tape: %v", err))) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "message": "Tape loaded successfully", + "barcode": req.Barcode, + "drive_id": fmt.Sprintf("%d", req.DriveID), + }) +} + +// handleEjectTape ejects a tape from a drive +func (a *App) handleEjectTape(w http.ResponseWriter, r *http.Request) { + var req struct { + DriveID int `json:"drive_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, errors.ErrValidation(fmt.Sprintf("invalid request body: %v", err))) + return + } + + if err := a.vtlService.EjectTape(req.DriveID); err != nil { + log.Printf("eject tape error: %v", err) + writeError(w, errors.ErrInternal(fmt.Sprintf("failed to eject tape: %v", err))) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "message": "Tape ejected successfully", + "drive_id": fmt.Sprintf("%d", req.DriveID), + }) +} diff --git a/internal/services/vtl.go b/internal/services/vtl.go index 2f97311..255fea0 100644 --- a/internal/services/vtl.go +++ b/internal/services/vtl.go @@ -1,28 +1,56 @@ package services import ( + "bufio" "fmt" "log" "os" "os/exec" "path/filepath" + "regexp" "strconv" "strings" "gitea.avt.data-center.id/othman.suseno/atlas/internal/models" ) +// DeviceConfig represents parsed device.conf structure +type DeviceConfig struct { + Libraries []LibraryConfig + Drives []DriveConfig +} + +// LibraryConfig represents a library from device.conf +type LibraryConfig struct { + ID int + Vendor string + Product string + Serial string +} + +// DriveConfig represents a drive from device.conf +type DriveConfig struct { + ID int + LibraryID int + SlotID int + Vendor string + Product string + Serial string +} + // VTLService manages mhvtl (Virtual Tape Library) type VTLService struct { - configPath string // Path to mhvtl config (default: /etc/mhvtl/mhvtl.conf) - storagePath string // Path to tape storage (default: /opt/mhvtl) + configPath string // Path to mhvtl config (default: /etc/mhvtl/mhvtl.conf) + deviceConfigPath string // Path to device config (default: /etc/mhvtl/device.conf) + storagePath string // Path to tape storage (default: /opt/mhvtl) } // NewVTLService creates a new VTL service func NewVTLService() *VTLService { return &VTLService{ - configPath: "/etc/mhvtl/mhvtl.conf", - storagePath: "/opt/mhvtl", + configPath: "/etc/mhvtl/mhvtl.conf", + deviceConfigPath: "/etc/mhvtl/device.conf", + storagePath: "/opt/mhvtl", } } @@ -36,10 +64,17 @@ func (s *VTLService) GetStatus() (*models.VTLStatus, error) { TapesAvailable: 0, } - // Check if mhvtl service is running - cmd := exec.Command("systemctl", "is-active", "mhvtl") + // Check if mhvtl target is active (mhvtl uses mhvtl.target, not mhvtl.service) + cmd := exec.Command("systemctl", "is-active", "mhvtl.target") if err := cmd.Run(); err == nil { status.ServiceRunning = true + } else { + // Fallback: check if mhvtl-load-modules is active (modules loaded) + cmd2 := exec.Command("systemctl", "is-active", "mhvtl-load-modules.service") + if cmd2.Run() == nil { + // Modules are loaded, service might be running even if target shows inactive + status.ServiceRunning = true + } } // Get drives and tapes info @@ -66,15 +101,42 @@ func (s *VTLService) GetStatus() (*models.VTLStatus, error) { return status, nil } -// ListDrives lists all virtual tape drives +// ListDrives lists all virtual tape drives from device.conf func (s *VTLService) ListDrives() ([]models.VTLDrive, error) { drives := []models.VTLDrive{} - // Read from /sys/class/scsi_tape/ + // Read from device.conf first (more accurate) + deviceConfig, err := s.parseDeviceConfig() + if err == nil && len(deviceConfig.Drives) > 0 { + // Use drives from device.conf + for _, driveConfig := range deviceConfig.Drives { + // Find corresponding device from sysfs + devicePath := s.findDeviceForDrive(driveConfig.ID) + + // Check if media is loaded + mediaLoaded, barcode := s.checkMediaLoadedByDriveID(driveConfig.ID) + + drive := models.VTLDrive{ + ID: driveConfig.ID, + LibraryID: driveConfig.LibraryID, + SlotID: driveConfig.SlotID, + Vendor: driveConfig.Vendor, + Product: driveConfig.Product, + Type: s.determineDriveType(driveConfig.Product), + Device: devicePath, + Status: "online", + MediaLoaded: mediaLoaded, + Barcode: barcode, + } + drives = append(drives, drive) + } + return drives, nil + } + + // Fallback: Read from /sys/class/scsi_tape/ if device.conf not available tapePath := "/sys/class/scsi_tape" entries, err := os.ReadDir(tapePath) if err != nil { - // mhvtl might not be installed or configured return drives, nil } @@ -83,16 +145,12 @@ func (s *VTLService) ListDrives() ([]models.VTLDrive, error) { continue } - // Entry name format: st0, st1, etc. deviceName := entry.Name() if !strings.HasPrefix(deviceName, "st") && !strings.HasPrefix(deviceName, "nst") { continue } - // Get device path devicePath := fmt.Sprintf("/dev/%s", deviceName) - - // Try to get vendor and product from sysfs vendorPath := fmt.Sprintf("%s/%s/device/vendor", tapePath, deviceName) productPath := fmt.Sprintf("%s/%s/device/model", tapePath, deviceName) @@ -105,69 +163,152 @@ func (s *VTLService) ListDrives() ([]models.VTLDrive, error) { product = strings.TrimSpace(string(productBytes)) } - // Determine drive type from product - driveType := s.determineDriveType(product) - - // Try to get drive ID from mhvtl config or device driveID := s.getDriveIDFromDevice(deviceName) - - // Check if media is loaded mediaLoaded, barcode := s.checkMediaLoaded(deviceName) + // Try to get library ID from device.conf if available + libraryID := driveID / 10 + slotID := driveID % 10 + + // Try to get more accurate info from device.conf + deviceConfig, err := s.parseDeviceConfig() + if err == nil { + for _, driveConfig := range deviceConfig.Drives { + if driveConfig.ID == driveID { + libraryID = driveConfig.LibraryID + slotID = driveConfig.SlotID + if vendor == "Unknown" { + vendor = driveConfig.Vendor + } + if product == "Unknown" { + product = driveConfig.Product + } + break + } + } + } + drive := models.VTLDrive{ ID: driveID, - LibraryID: driveID / 10, // Extract library ID (tens digit) - SlotID: driveID % 10, // Extract slot ID (ones digit) + LibraryID: libraryID, + SlotID: slotID, Vendor: vendor, Product: product, - Type: driveType, + Type: s.determineDriveType(product), Device: devicePath, Status: "online", MediaLoaded: mediaLoaded, Barcode: barcode, } - drives = append(drives, drive) } return drives, nil } -// ListTapes lists all virtual tapes +// ListTapes lists all virtual tapes from library_contents and storage directory func (s *VTLService) ListTapes() ([]models.VTLTape, error) { tapes := []models.VTLTape{} + // Parse device config to get library IDs + deviceConfig, err := s.parseDeviceConfig() + if err != nil { + log.Printf("Warning: failed to parse device.conf: %v", err) + } + + // Map to track which tapes are in which slots + slotToBarcode := make(map[string]string) // "libraryID:slotID" -> barcode + + // Read library_contents files for each library + libraryIDs := []int{10, 30} // Default libraries, or get from deviceConfig + if deviceConfig != nil && len(deviceConfig.Libraries) > 0 { + libraryIDs = []int{} + for _, lib := range deviceConfig.Libraries { + libraryIDs = append(libraryIDs, lib.ID) + } + } + + for _, libID := range libraryIDs { + slotMap, err := s.parseLibraryContents(libID) + if err == nil { + for slotID, barcode := range slotMap { + key := fmt.Sprintf("%d:%d", libID, slotID) + slotToBarcode[key] = barcode + } + } + } + // Read tapes from storage directory - tapeStoragePath := filepath.Join(s.storagePath, "data") + // mhvtl stores tapes directly in /opt/mhvtl/, not in /opt/mhvtl/data/ + tapeStoragePath := s.storagePath entries, err := os.ReadDir(tapeStoragePath) if err != nil { // No tapes directory yet + log.Printf("Warning: cannot read tape storage directory %s: %v", tapeStoragePath, err) return tapes, nil } + // Create a map of barcodes to their slot info + barcodeToSlot := make(map[string]struct { + LibraryID int + SlotID int + }) + for key, barcode := range slotToBarcode { + parts := strings.Split(key, ":") + if len(parts) == 2 { + libID, _ := strconv.Atoi(parts[0]) + slotID, _ := strconv.Atoi(parts[1]) + barcodeToSlot[barcode] = struct { + LibraryID int + SlotID int + }{LibraryID: libID, SlotID: slotID} + } + } + for _, entry := range entries { if entry.IsDir() { - // Directory name is usually the barcode barcode := entry.Name() tapePath := filepath.Join(tapeStoragePath, barcode) - // Get tape info + // Get slot info from library_contents + slotInfo, inSlot := barcodeToSlot[barcode] + libraryID := 0 + slotID := 0 + if inSlot { + libraryID = slotInfo.LibraryID + slotID = slotInfo.SlotID + } else { + // If not in library_contents, try to find from device.conf libraries + // Default to first library if available + if deviceConfig != nil && len(deviceConfig.Libraries) > 0 { + libraryID = deviceConfig.Libraries[0].ID + } else { + libraryID = 10 // Fallback default + } + } + + // Determine tape type from barcode suffix + tapeType := s.determineTapeTypeFromBarcode(barcode) + + // Get default size based on tape generation + defaultSize := s.getDefaultTapeSize(tapeType) + tape := models.VTLTape{ Barcode: barcode, - LibraryID: 10, // Default library ID - SlotID: 0, // Not in library by default - DriveID: -1, // Not loaded - Type: "LTO-5", // Default type - Size: 0, + LibraryID: libraryID, + SlotID: slotID, + DriveID: -1, // Not loaded (would need to check drive status) + Type: tapeType, + Size: defaultSize, // Use default size for tape generation Used: 0, Status: "available", } - // Try to get size from tape file - tapeFile := filepath.Join(tapePath, "tape") + // Get actual used space from tape data file (mhvtl uses "data" file, not "tape") + tapeFile := filepath.Join(tapePath, "data") if info, err := os.Stat(tapeFile); err == nil { - tape.Size = uint64(info.Size()) - tape.Used = uint64(info.Size()) // For now, assume used = size + // Used space is the actual file size + tape.Used = uint64(info.Size()) } tapes = append(tapes, tape) @@ -178,67 +319,366 @@ func (s *VTLService) ListTapes() ([]models.VTLTape, error) { } // CreateTape creates a new virtual tape -func (s *VTLService) CreateTape(barcode string, tapeType string, size uint64) error { - // Create tape directory - tapePath := filepath.Join(s.storagePath, "data", barcode) - if err := os.MkdirAll(tapePath, 0755); err != nil { - return fmt.Errorf("failed to create tape directory: %v", err) +// libraryID: Library ID where the tape will be placed +// slotID: Slot ID in the library where the tape will be placed +func (s *VTLService) CreateTape(barcode string, tapeType string, size uint64, libraryID int, slotID int) error { + // Validate barcode format (must follow LTO standard) + if err := s.validateLTOBarcode(barcode); err != nil { + return fmt.Errorf("invalid barcode format: %v", err) } - // Create tape file - tapeFile := filepath.Join(tapePath, "tape") - file, err := os.Create(tapeFile) - if err != nil { - return fmt.Errorf("failed to create tape file: %v", err) + // Determine tape type from barcode if not provided + if tapeType == "" { + tapeType = s.determineTapeTypeFromBarcode(barcode) } - defer file.Close() - // Pre-allocate space if size is specified - if size > 0 { - if err := file.Truncate(int64(size)); err != nil { - return fmt.Errorf("failed to pre-allocate tape space: %v", err) + // Use default size based on tape generation if not specified + if size == 0 { + size = s.getDefaultTapeSize(tapeType) + } + + // Validate library ID and slot ID + if libraryID <= 0 { + return fmt.Errorf("library ID must be greater than 0") + } + if slotID <= 0 { + return fmt.Errorf("slot ID must be greater than 0") + } + + // Check if slot is already occupied + slotMap, err := s.parseLibraryContents(libraryID) + if err == nil { + if existingBarcode, occupied := slotMap[slotID]; occupied && existingBarcode != "" && existingBarcode != "-" { + return fmt.Errorf("slot %d in library %d is already occupied by tape %s", slotID, libraryID, existingBarcode) } } - log.Printf("Created virtual tape: %s (type: %s, size: %d bytes)", barcode, tapeType, size) + // Check if tape already exists + tapePath := filepath.Join(s.storagePath, barcode) + log.Printf("Creating tape: barcode=%s, path=%s, library=%d, slot=%d", barcode, tapePath, libraryID, slotID) + + if _, err := os.Stat(tapePath); err == nil { + return fmt.Errorf("tape %s already exists at %s", barcode, tapePath) + } + + // Ensure parent directory exists first + parentDir := s.storagePath + log.Printf("Checking parent directory: %s", parentDir) + + // Check if filesystem is read-only and try to remount + if err := s.ensureWritableFilesystem(parentDir); err != nil { + log.Printf("Warning: failed to ensure writable filesystem: %v", err) + // Continue anyway, might still work + } + + if _, err := os.Stat(parentDir); os.IsNotExist(err) { + log.Printf("Parent directory does not exist, creating: %s", parentDir) + // Try to create parent directory + if err := os.MkdirAll(parentDir, 0755); err != nil { + log.Printf("Regular mkdir failed for parent %s: %v, trying with sudo", parentDir, err) + // Try with sudo if regular mkdir fails + cmd := exec.Command("sudo", "mkdir", "-p", parentDir) + if output, err := cmd.CombinedOutput(); err != nil { + outputStr := strings.TrimSpace(string(output)) + return fmt.Errorf("failed to create parent directory %s: %v, output: %s", parentDir, err, outputStr) + } + // Set permissions + cmd = exec.Command("sudo", "chmod", "755", parentDir) + cmd.Run() // Ignore error for chmod + } + } else { + // Check if we can write to parent directory + if testFile, err := os.CreateTemp(parentDir, ".write-test-*"); err != nil { + log.Printf("Warning: cannot write to parent directory %s: %v, attempting remount", parentDir, err) + // Try to remount as read-write + if remountErr := s.remountReadWrite(parentDir); remountErr != nil { + log.Printf("Warning: remount failed: %v", remountErr) + } + } else { + testFile.Close() + os.Remove(testFile.Name()) + log.Printf("Parent directory is writable: %s", parentDir) + } + } + + // Check if tape directory already exists (should have been caught earlier, but double-check) + if _, err := os.Stat(tapePath); err == nil { + // Directory exists, check if it's empty or has files + entries, _ := os.ReadDir(tapePath) + if len(entries) > 0 { + return fmt.Errorf("tape directory %s already exists and is not empty", tapePath) + } + // Directory exists but empty, we can use it + } else { + // Create tape directory (may need root permissions for /opt/mhvtl) + // Try without sudo first, if fails, try with sudo + log.Printf("Attempting to create directory: %s", tapePath) + if err := os.MkdirAll(tapePath, 0750); err != nil { + log.Printf("Regular mkdir failed for %s: %v, trying with sudo", tapePath, err) + // Try with sudo if regular mkdir fails + cmd := exec.Command("sudo", "mkdir", "-p", tapePath) + if output, err := cmd.CombinedOutput(); err != nil { + outputStr := strings.TrimSpace(string(output)) + log.Printf("Sudo mkdir failed for %s: %v, output: %s", tapePath, err, outputStr) + return fmt.Errorf("failed to create tape directory %s: %v (output: %s)", tapePath, err, outputStr) + } + log.Printf("Directory created with sudo: %s", tapePath) + // Set permissions after creating with sudo + cmd = exec.Command("sudo", "chmod", "750", tapePath) + if output, err := cmd.CombinedOutput(); err != nil { + log.Printf("Warning: failed to set permissions on %s: %v, output: %s", tapePath, err, string(output)) + } + } else { + log.Printf("Directory created successfully: %s", tapePath) + } + } + + // Create tape data file (mhvtl uses "data" file, not "tape") + tapeFile := filepath.Join(tapePath, "data") + file, err := os.Create(tapeFile) + fileCreated := true + if err != nil { + // Try with sudo if regular create fails + cmd := exec.Command("sudo", "touch", tapeFile) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create tape file %s: %v, output: %s", tapeFile, err, string(output)) + } + // Set permissions + cmd = exec.Command("sudo", "chmod", "644", tapeFile) + cmd.Run() // Ignore error + file, err = os.OpenFile(tapeFile, os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open tape file %s: %v", tapeFile, err) + } + fileCreated = false + } + + // Pre-allocate space if size is specified (do this before closing file) + if size > 0 { + if fileCreated { + // File was created by us, we can truncate it directly + if err := file.Truncate(int64(size)); err != nil { + log.Printf("Warning: failed to truncate tape file: %v, trying with sudo", err) + file.Close() + // Try with sudo if truncate fails + cmd := exec.Command("sudo", "truncate", "-s", fmt.Sprintf("%d", size), tapeFile) + if output, err := cmd.CombinedOutput(); err != nil { + log.Printf("Warning: failed to pre-allocate tape space: %v, output: %s", err, string(output)) + } else { + log.Printf("Tape file truncated to %d bytes using sudo", size) + } + } else { + log.Printf("Tape file truncated to %d bytes", size) + } + } else { + // File was created with sudo, use sudo truncate + file.Close() + cmd := exec.Command("sudo", "truncate", "-s", fmt.Sprintf("%d", size), tapeFile) + if output, err := cmd.CombinedOutput(); err != nil { + log.Printf("Warning: failed to pre-allocate tape space: %v, output: %s", err, string(output)) + } else { + log.Printf("Tape file truncated to %d bytes using sudo", size) + } + } + } + + if fileCreated { + file.Close() + } + + // Create index file (mhvtl also uses "indx" file) + indxFile := filepath.Join(tapePath, "indx") + indx, err := os.Create(indxFile) + if err != nil { + // Try with sudo if regular create fails + cmd := exec.Command("sudo", "touch", indxFile) + if output, err := cmd.CombinedOutput(); err != nil { + log.Printf("Warning: failed to create index file %s: %v, output: %s", indxFile, err, string(output)) + } else { + // Set permissions + cmd = exec.Command("sudo", "chmod", "644", indxFile) + if output, err := cmd.CombinedOutput(); err != nil { + log.Printf("Warning: failed to set permissions on %s: %v, output: %s", indxFile, err, string(output)) + } + } + } else { + indx.Close() + } + + // Add tape to library_contents file + if err := s.addTapeToLibraryContents(libraryID, slotID, barcode); err != nil { + // Clean up created directory if library_contents update fails + os.RemoveAll(tapePath) + return fmt.Errorf("failed to add tape to library_contents: %v", err) + } + + // Restart mhvtl service to reflect changes + if err := s.RestartService(); err != nil { + log.Printf("Warning: failed to restart mhvtl service after tape creation: %v", err) + // Continue even if restart fails - tape is created + } + + log.Printf("Created virtual tape: %s (type: %s, size: %d bytes, library: %d, slot: %d)", barcode, tapeType, size, libraryID, slotID) return nil } // DeleteTape deletes a virtual tape func (s *VTLService) DeleteTape(barcode string) error { - tapePath := filepath.Join(s.storagePath, "data", barcode) - if err := os.RemoveAll(tapePath); err != nil { - return fmt.Errorf("failed to delete tape: %v", err) + // mhvtl stores tapes directly in /opt/mhvtl/, not in /opt/mhvtl/data/ + tapePath := filepath.Join(s.storagePath, barcode) + + log.Printf("Deleting tape: barcode=%s, path=%s", barcode, tapePath) + + // Check if tape directory exists + if _, err := os.Stat(tapePath); os.IsNotExist(err) { + return fmt.Errorf("tape %s does not exist", barcode) } - log.Printf("Deleted virtual tape: %s", barcode) + // Ensure filesystem is writable before delete (same as create) + parentDir := s.storagePath + if err := s.ensureWritableFilesystem(parentDir); err != nil { + log.Printf("Warning: failed to ensure writable filesystem: %v", err) + // Continue anyway, might still work + } + + // Find which library and slot this tape is in + libraryID, slotID := s.findTapeInLibrary(barcode) + log.Printf("Tape %s found in library %d, slot %d", barcode, libraryID, slotID) + + // Remove the entire tape directory + // Try without sudo first, if fails, try with sudo + if err := os.RemoveAll(tapePath); err != nil { + log.Printf("Regular RemoveAll failed for %s: %v, trying with sudo", tapePath, err) + // Try with sudo if regular remove fails + cmd := exec.Command("sudo", "rm", "-rf", tapePath) + if output, err := cmd.CombinedOutput(); err != nil { + outputStr := strings.TrimSpace(string(output)) + return fmt.Errorf("failed to delete tape directory %s: %v, output: %s", tapePath, err, outputStr) + } + log.Printf("Tape directory deleted with sudo: %s", tapePath) + } else { + log.Printf("Tape directory deleted successfully: %s", tapePath) + } + + // Remove from library_contents if found + if libraryID > 0 && slotID > 0 { + if err := s.removeTapeFromLibraryContents(libraryID, slotID); err != nil { + log.Printf("Warning: failed to remove tape from library_contents.%d: %v", libraryID, err) + // Continue even if library_contents update fails + } + } + + // Restart mhvtl service to reflect changes + if err := s.RestartService(); err != nil { + log.Printf("Warning: failed to restart mhvtl service after tape deletion: %v", err) + // Continue even if restart fails + } + + log.Printf("Deleted virtual tape: %s (path: %s, library: %d, slot: %d)", barcode, tapePath, libraryID, slotID) return nil } // StartService starts the mhvtl service func (s *VTLService) StartService() error { - cmd := exec.Command("systemctl", "start", "mhvtl") + // Check if already active first + checkCmd := exec.Command("systemctl", "is-active", "mhvtl.target") + if err := checkCmd.Run(); err == nil { + log.Printf("mhvtl.target is already active") + return nil // Already running, no error + } + + // mhvtl uses mhvtl.target, not mhvtl.service + // First, ensure modules are loaded + cmd := exec.Command("systemctl", "start", "mhvtl-load-modules.service") if err := cmd.Run(); err != nil { + // Ignore error if modules are already loaded + log.Printf("Warning: mhvtl-load-modules start returned: %v (may already be loaded)", err) + } + + // Start mhvtl.target (this starts all mhvtl services) + cmd = exec.Command("systemctl", "start", "mhvtl.target") + output, err := cmd.CombinedOutput() + if err != nil { + // Check exit code for better error message + if exitError, ok := err.(*exec.ExitError); ok { + exitCode := exitError.ExitCode() + outputStr := strings.TrimSpace(string(output)) + if outputStr == "" { + outputStr = "no output" + } + log.Printf("mhvtl.target start failed: exit code %d, output: %s", exitCode, outputStr) + + // Provide specific error messages based on exit code + switch exitCode { + case 1: + return fmt.Errorf("failed to start mhvtl service: unit failed to start. Check logs: journalctl -u mhvtl.target -n 50") + case 5: + return fmt.Errorf("failed to start mhvtl service: unit not found or permission denied. Ensure mhvtl is installed: apt-get install mhvtl") + default: + return fmt.Errorf("failed to start mhvtl service: exit code %d, output: %s", exitCode, outputStr) + } + } return fmt.Errorf("failed to start mhvtl service: %v", err) } + + log.Printf("mhvtl.target started successfully") return nil } // StopService stops the mhvtl service func (s *VTLService) StopService() error { - cmd := exec.Command("systemctl", "stop", "mhvtl") - if err := cmd.Run(); err != nil { + // Check if already inactive first + checkCmd := exec.Command("systemctl", "is-active", "mhvtl.target") + if err := checkCmd.Run(); err != nil { + log.Printf("mhvtl.target is already inactive") + return nil // Already stopped, no error + } + + // Stop mhvtl.target (this stops all mhvtl services) + cmd := exec.Command("systemctl", "stop", "mhvtl.target") + output, err := cmd.CombinedOutput() + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + exitCode := exitError.ExitCode() + outputStr := strings.TrimSpace(string(output)) + if outputStr == "" { + outputStr = "no output" + } + log.Printf("mhvtl.target stop failed: exit code %d, output: %s", exitCode, outputStr) + return fmt.Errorf("failed to stop mhvtl service: exit code %d, output: %s", exitCode, outputStr) + } return fmt.Errorf("failed to stop mhvtl service: %v", err) } + log.Printf("mhvtl.target stopped successfully") return nil } // RestartService restarts the mhvtl service func (s *VTLService) RestartService() error { - cmd := exec.Command("systemctl", "restart", "mhvtl") - if err := cmd.Run(); err != nil { + // Restart mhvtl.target + cmd := exec.Command("systemctl", "restart", "mhvtl.target") + output, err := cmd.CombinedOutput() + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + exitCode := exitError.ExitCode() + outputStr := string(output) + if outputStr == "" { + outputStr = "no output" + } + log.Printf("mhvtl.target restart failed: exit code %d, output: %s", exitCode, outputStr) + + switch exitCode { + case 1: + return fmt.Errorf("failed to restart mhvtl service: unit failed to restart (check logs: journalctl -u mhvtl.target)") + case 5: + return fmt.Errorf("failed to restart mhvtl service: unit not found or permission denied") + default: + return fmt.Errorf("failed to restart mhvtl service: exit code %d, output: %s", exitCode, outputStr) + } + } return fmt.Errorf("failed to restart mhvtl service: %v", err) } + log.Printf("mhvtl.target restarted successfully") return nil } @@ -293,3 +733,799 @@ func (s *VTLService) checkMediaLoaded(deviceName string) (bool, string) { } return false, "" } + +// ListMediaChangers lists all virtual media changers from device.conf +func (s *VTLService) ListMediaChangers() ([]models.VTLMediaChanger, error) { + changers := []models.VTLMediaChanger{} + + // Parse device.conf to get libraries + deviceConfig, err := s.parseDeviceConfig() + if err == nil && len(deviceConfig.Libraries) > 0 { + // Count drives per library + drivesPerLibrary := make(map[int]int) + for _, drive := range deviceConfig.Drives { + drivesPerLibrary[drive.LibraryID]++ + } + + // Count slots per library from library_contents + slotsPerLibrary := make(map[int]int) + for _, lib := range deviceConfig.Libraries { + slotMap, err := s.parseLibraryContents(lib.ID) + if err == nil { + slotsPerLibrary[lib.ID] = len(slotMap) + } else { + slotsPerLibrary[lib.ID] = 10 // Default + } + } + + // Find device path for each library + for _, lib := range deviceConfig.Libraries { + devicePath := s.findMediaChangerDevice(lib.ID) + + changer := models.VTLMediaChanger{ + ID: lib.ID, + LibraryID: lib.ID, + Device: devicePath, + Status: "online", + Slots: slotsPerLibrary[lib.ID], + Drives: drivesPerLibrary[lib.ID], + } + changers = append(changers, changer) + } + return changers, nil + } + + // Fallback: Read from /sys/class/scsi_generic/ + sgPath := "/sys/class/scsi_generic" + entries, err := os.ReadDir(sgPath) + if err != nil { + return changers, nil + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + deviceName := entry.Name() + devicePath := fmt.Sprintf("/dev/%s", deviceName) + + vendorPath := fmt.Sprintf("%s/%s/device/vendor", sgPath, deviceName) + productPath := fmt.Sprintf("%s/%s/device/model", sgPath, deviceName) + + vendor := "Unknown" + product := "Unknown" + if vendorBytes, err := os.ReadFile(vendorPath); err == nil { + vendor = strings.TrimSpace(string(vendorBytes)) + } + if productBytes, err := os.ReadFile(productPath); err == nil { + product = strings.TrimSpace(string(productBytes)) + } + + // Check if it's a media changer (STK L700 or L80) + if strings.Contains(strings.ToUpper(vendor), "STK") || + strings.Contains(strings.ToUpper(product), "L700") || + strings.Contains(strings.ToUpper(product), "L80") { + // Try to determine library ID from device + libraryID := 10 + if strings.Contains(strings.ToUpper(product), "L80") { + libraryID = 30 + } + + // Count slots and drives for this library + slotMap, _ := s.parseLibraryContents(libraryID) + slots := len(slotMap) + if slots == 0 { + slots = 10 // Default + } + + // Count drives from device.conf + deviceConfig, _ := s.parseDeviceConfig() + drives := 0 + if deviceConfig != nil { + for _, drive := range deviceConfig.Drives { + if drive.LibraryID == libraryID { + drives++ + } + } + } + if drives == 0 { + drives = 4 // Default + } + + changer := models.VTLMediaChanger{ + ID: libraryID, + LibraryID: libraryID, + Device: devicePath, + Status: "online", + Slots: slots, + Drives: drives, + } + changers = append(changers, changer) + } + } + + return changers, nil +} + +// LoadTape loads a tape into a drive using mtx (media changer command) +func (s *VTLService) LoadTape(driveID int, barcode string) error { + // Find the media changer device + changers, err := s.ListMediaChangers() + if err != nil || len(changers) == 0 { + return fmt.Errorf("no media changer found") + } + + changer := changers[0] + + // Find the slot containing this tape + // In real implementation, this would query the media changer for slot info + // For now, we'll use a simplified approach + slotID := 1 // Default slot + + // Use mtx command to load tape: mtx -f /dev/sg0 load + // Drive mapping: drive 0 = element 0, drive 1 = element 1, etc. + // Slot mapping: slots start from element 256 + mtxCmd := exec.Command("mtx", "-f", changer.Device, "load", fmt.Sprintf("%d", slotID), fmt.Sprintf("%d", driveID)) + if err := mtxCmd.Run(); err != nil { + // If mtx is not available, log and return error + log.Printf("mtx command failed (may not be installed): %v", err) + return fmt.Errorf("failed to load tape: mtx command failed (is mtx installed?)") + } + + log.Printf("Loaded tape %s into drive %d", barcode, driveID) + return nil +} + +// EjectTape ejects a tape from a drive +func (s *VTLService) EjectTape(driveID int) error { + // Find the media changer device + changers, err := s.ListMediaChangers() + if err != nil || len(changers) == 0 { + return fmt.Errorf("no media changer found") + } + + changer := changers[0] + + // Use mtx command to unload tape: mtx -f /dev/sg0 unload + mtxCmd := exec.Command("mtx", "-f", changer.Device, "unload", "1", fmt.Sprintf("%d", driveID)) + if err := mtxCmd.Run(); err != nil { + // If mtx is not available, log and return error + log.Printf("mtx command failed (may not be installed): %v", err) + return fmt.Errorf("failed to eject tape: mtx command failed (is mtx installed?)") + } + + log.Printf("Ejected tape from drive %d", driveID) + return nil +} + +// GetMediaChangerStatus returns detailed status of the media changer +func (s *VTLService) GetMediaChangerStatus() (*models.VTLMediaChanger, error) { + changers, err := s.ListMediaChangers() + if err != nil { + return nil, err + } + + if len(changers) == 0 { + return nil, fmt.Errorf("no media changer found") + } + + return &changers[0], nil +} + +// parseDeviceConfig parses /etc/mhvtl/device.conf +func (s *VTLService) parseDeviceConfig() (*DeviceConfig, error) { + config := &DeviceConfig{ + Libraries: []LibraryConfig{}, + Drives: []DriveConfig{}, + } + + file, err := os.Open(s.deviceConfigPath) + if err != nil { + return nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var currentLibraryID int + var currentDrive *DriveConfig + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Parse Library line: "Library: 10 CHANNEL: 00 TARGET: 00 LUN: 00" + if strings.HasPrefix(line, "Library:") { + parts := strings.Fields(line) + if len(parts) >= 2 { + libID, _ := strconv.Atoi(parts[1]) + currentLibraryID = libID + // Create new library entry + config.Libraries = append(config.Libraries, LibraryConfig{ + ID: libID, + }) + } + continue + } + + // Parse Library attributes (only if we're not parsing a drive) + if currentDrive == nil && currentLibraryID > 0 { + if strings.HasPrefix(line, "Vendor identification:") { + if len(config.Libraries) > 0 { + idx := len(config.Libraries) - 1 + config.Libraries[idx].Vendor = strings.TrimSpace(strings.TrimPrefix(line, "Vendor identification:")) + } + continue + } + if strings.HasPrefix(line, "Product identification:") { + if len(config.Libraries) > 0 { + idx := len(config.Libraries) - 1 + config.Libraries[idx].Product = strings.TrimSpace(strings.TrimPrefix(line, "Product identification:")) + } + continue + } + if strings.HasPrefix(line, "Unit serial number:") { + if len(config.Libraries) > 0 { + idx := len(config.Libraries) - 1 + config.Libraries[idx].Serial = strings.TrimSpace(strings.TrimPrefix(line, "Unit serial number:")) + } + continue + } + } + + // Parse Drive line: "Drive: 11 CHANNEL: 00 TARGET: 01 LUN: 00" + if strings.HasPrefix(line, "Drive:") { + parts := strings.Fields(line) + if len(parts) >= 2 { + driveID, _ := strconv.Atoi(parts[1]) + currentDrive = &DriveConfig{ + ID: driveID, + LibraryID: currentLibraryID, + } + config.Drives = append(config.Drives, *currentDrive) + } + continue + } + + // Reset currentDrive when we encounter a new Library or other section + if strings.HasPrefix(line, "Library:") { + currentDrive = nil + } + + // Parse Drive attributes + if currentDrive != nil && len(config.Drives) > 0 { + idx := len(config.Drives) - 1 + if strings.HasPrefix(line, "Library ID:") { + parts := strings.Fields(line) + if len(parts) >= 3 { + libID, _ := strconv.Atoi(parts[2]) + config.Drives[idx].LibraryID = libID + } + } + if strings.HasPrefix(line, "Slot:") { + parts := strings.Fields(line) + if len(parts) >= 2 { + slotID, _ := strconv.Atoi(parts[1]) + config.Drives[idx].SlotID = slotID + } + } + if strings.HasPrefix(line, "Vendor identification:") { + config.Drives[idx].Vendor = strings.TrimSpace(strings.TrimPrefix(line, "Vendor identification:")) + } + if strings.HasPrefix(line, "Product identification:") { + config.Drives[idx].Product = strings.TrimSpace(strings.TrimPrefix(line, "Product identification:")) + } + if strings.HasPrefix(line, "Unit serial number:") { + config.Drives[idx].Serial = strings.TrimSpace(strings.TrimPrefix(line, "Unit serial number:")) + } + } + } + + return config, scanner.Err() +} + +// parseLibraryContents parses library_contents.X file +func (s *VTLService) parseLibraryContents(libraryID int) (map[int]string, error) { + // Map slot ID to barcode + slotMap := make(map[int]string) + + contentsPath := fmt.Sprintf("/etc/mhvtl/library_contents.%d", libraryID) + file, err := os.Open(contentsPath) + if err != nil { + return slotMap, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + // Match "Slot 1: E01001L8" or "Slot 10: E01010L8" or "Slot 1: -" or "Slot 1:" format + slotRegex := regexp.MustCompile(`^Slot\s+(\d+):\s*(.*)$`) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + matches := slotRegex.FindStringSubmatch(line) + if len(matches) >= 2 { + slotID, err := strconv.Atoi(matches[1]) + if err != nil { + continue + } + // matches[2] is the barcode (or empty, or "-") + barcode := strings.TrimSpace(matches[2]) + // Only add to map if barcode exists and is not empty or "-" + if barcode != "" && barcode != "-" { + slotMap[slotID] = barcode + } + } + } + + return slotMap, scanner.Err() +} + +// findDeviceForDrive finds device path for a drive ID +func (s *VTLService) findDeviceForDrive(driveID int) string { + // Try to find device in /sys/class/scsi_tape/ + tapePath := "/sys/class/scsi_tape" + entries, err := os.ReadDir(tapePath) + if err != nil { + return fmt.Sprintf("/dev/st%d", driveID%10) // Fallback + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + deviceName := entry.Name() + if !strings.HasPrefix(deviceName, "st") && !strings.HasPrefix(deviceName, "nst") { + continue + } + + // Check if this device matches the drive ID + // This is a simplified check - in real implementation, we'd need to map device to drive ID + deviceID := s.getDriveIDFromDevice(deviceName) + if deviceID == driveID { + return fmt.Sprintf("/dev/%s", deviceName) + } + } + + return fmt.Sprintf("/dev/st%d", driveID%10) // Fallback +} + +// checkMediaLoadedByDriveID checks if media is loaded in a drive by drive ID +func (s *VTLService) checkMediaLoadedByDriveID(driveID int) (bool, string) { + devicePath := s.findDeviceForDrive(driveID) + deviceName := filepath.Base(devicePath) + return s.checkMediaLoaded(deviceName) +} + +// findMediaChangerDevice finds device path for a media changer/library +func (s *VTLService) findMediaChangerDevice(libraryID int) string { + // Try to find in /sys/class/scsi_generic/ + sgPath := "/sys/class/scsi_generic" + entries, err := os.ReadDir(sgPath) + if err != nil { + return fmt.Sprintf("/dev/sg%d", libraryID%10) // Fallback + } + + // Map library ID to device based on target in device.conf + // Library 10 -> TARGET: 00 -> usually sg0 + // Library 30 -> TARGET: 08 -> usually sg1 or sg2 + deviceIndex := 0 + if libraryID == 30 { + deviceIndex = 1 + } + + // Get all sg devices sorted + sgDevices := []string{} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + deviceName := entry.Name() + if strings.HasPrefix(deviceName, "sg") { + sgDevices = append(sgDevices, deviceName) + } + } + + // Try to find matching device by checking vendor/product + for _, deviceName := range sgDevices { + devicePath := fmt.Sprintf("/dev/%s", deviceName) + vendorPath := fmt.Sprintf("%s/%s/device/vendor", sgPath, deviceName) + productPath := fmt.Sprintf("%s/%s/device/model", sgPath, deviceName) + + vendor := "" + product := "" + if vendorBytes, err := os.ReadFile(vendorPath); err == nil { + vendor = strings.TrimSpace(string(vendorBytes)) + } + if productBytes, err := os.ReadFile(productPath); err == nil { + product = strings.TrimSpace(string(productBytes)) + } + + // Check if it's a media changer (STK L700 or L80) + if strings.Contains(strings.ToUpper(vendor), "STK") || + strings.Contains(strings.ToUpper(product), "L700") || + strings.Contains(strings.ToUpper(product), "L80") { + // For library 10, prefer L700; for library 30, prefer L80 + if libraryID == 10 && strings.Contains(strings.ToUpper(product), "L700") { + return devicePath + } + if libraryID == 30 && strings.Contains(strings.ToUpper(product), "L80") { + return devicePath + } + // If we haven't found a specific match yet, use this one + if deviceIndex < len(sgDevices) && deviceName == sgDevices[deviceIndex] { + return devicePath + } + } + } + + // Fallback: use device index + if deviceIndex < len(sgDevices) { + return fmt.Sprintf("/dev/%s", sgDevices[deviceIndex]) + } + return fmt.Sprintf("/dev/sg%d", deviceIndex) +} + +// validateLTOBarcode validates that barcode follows LTO standard format +// LTO barcodes typically end with L1-L9, LW, LT, LU, LV +func (s *VTLService) validateLTOBarcode(barcode string) error { + barcode = strings.ToUpper(strings.TrimSpace(barcode)) + + if barcode == "" { + return fmt.Errorf("barcode cannot be empty") + } + + // Check minimum length (at least 3 characters, e.g., "E01L5") + if len(barcode) < 3 { + return fmt.Errorf("barcode too short (minimum 3 characters)") + } + + // Check suffix (last 2 characters must be valid LTO suffix) + if len(barcode) < 2 { + return fmt.Errorf("barcode must end with valid LTO suffix (L1-L9, LW, LT, LU, LV)") + } + + suffix := barcode[len(barcode)-2:] + validSuffixes := []string{"L1", "L2", "L3", "L4", "L5", "L6", "L7", "L8", "L9", "LW", "LT", "LU", "LV"} + + valid := false + for _, vs := range validSuffixes { + if suffix == vs { + valid = true + break + } + } + + if !valid { + return fmt.Errorf("barcode must end with valid LTO suffix (L1-L9, LW, LT, LU, LV), got: %s", suffix) + } + + return nil +} + +// determineTapeTypeFromBarcode determines tape type from barcode suffix +func (s *VTLService) determineTapeTypeFromBarcode(barcode string) string { + barcode = strings.ToUpper(barcode) + + // Check suffix (last 2 characters) + if len(barcode) >= 2 { + suffix := barcode[len(barcode)-2:] + switch suffix { + case "L1": + return "LTO-1" + case "L2": + return "LTO-2" + case "L3": + return "LTO-3" + case "L4": + return "LTO-4" + case "L5": + return "LTO-5" + case "L6": + return "LTO-6" + case "L7": + return "LTO-7" + case "L8": + return "LTO-8" + case "L9": + return "LTO-9" + case "LW": + return "LTO-6 WORM" + case "LT": + return "LTO-3 WORM" + case "LU": + return "LTO-4 WORM" + case "LV": + return "LTO-5 WORM" + } + } + + return "LTO-5" // Default +} + +// getDefaultTapeSize returns default size in bytes for LTO generation +func (s *VTLService) getDefaultTapeSize(tapeType string) uint64 { + tapeType = strings.ToUpper(tapeType) + + // LTO capacities (compressed, in bytes) + // LTO-1: 200 GB = 200 * 1024^3 bytes + // LTO-2: 400 GB = 400 * 1024^3 bytes + // LTO-3: 800 GB = 800 * 1024^3 bytes + // LTO-4: 1.6 TB = 1600 * 1024^3 bytes + // LTO-5: 3.0 TB = 3000 * 1024^3 bytes + // LTO-6: 6.25 TB = 6250 * 1024^3 bytes + // LTO-7: 15 TB = 15000 * 1024^3 bytes + // LTO-8: 30 TB = 30000 * 1024^3 bytes + // LTO-9: 45 TB = 45000 * 1024^3 bytes + + switch { + case strings.Contains(tapeType, "LTO-9"): + return 45 * 1024 * 1024 * 1024 * 1024 // 45 TB + case strings.Contains(tapeType, "LTO-8"): + return 30 * 1024 * 1024 * 1024 * 1024 // 30 TB + case strings.Contains(tapeType, "LTO-7"): + return 15 * 1024 * 1024 * 1024 * 1024 // 15 TB + case strings.Contains(tapeType, "LTO-6"): + return 6250 * 1024 * 1024 * 1024 // 6.25 TB + case strings.Contains(tapeType, "LTO-5"): + return 3000 * 1024 * 1024 * 1024 // 3 TB + case strings.Contains(tapeType, "LTO-4"): + return 1600 * 1024 * 1024 * 1024 // 1.6 TB + case strings.Contains(tapeType, "LTO-3"): + return 800 * 1024 * 1024 * 1024 // 800 GB + case strings.Contains(tapeType, "LTO-2"): + return 400 * 1024 * 1024 * 1024 // 400 GB + case strings.Contains(tapeType, "LTO-1"): + return 200 * 1024 * 1024 * 1024 // 200 GB + default: + return 3000 * 1024 * 1024 * 1024 // Default: 3 TB (LTO-5) + } +} + +// findTapeInLibrary finds which library and slot a tape is in +func (s *VTLService) findTapeInLibrary(barcode string) (libraryID int, slotID int) { + // Check all known libraries + libraryIDs := []int{10, 30} + + // Also try to get from device.conf + deviceConfig, err := s.parseDeviceConfig() + if err == nil && len(deviceConfig.Libraries) > 0 { + libraryIDs = []int{} + for _, lib := range deviceConfig.Libraries { + libraryIDs = append(libraryIDs, lib.ID) + } + } + + for _, libID := range libraryIDs { + slotMap, err := s.parseLibraryContents(libID) + if err == nil { + for sid, bc := range slotMap { + if bc == barcode { + return libID, sid + } + } + } + } + + return 0, 0 +} + +// removeTapeFromLibraryContents removes a tape from library_contents file +func (s *VTLService) removeTapeFromLibraryContents(libraryID int, slotID int) error { + contentsPath := fmt.Sprintf("/etc/mhvtl/library_contents.%d", libraryID) + + // Read the file + file, err := os.OpenFile(contentsPath, os.O_RDWR, 0644) + if err != nil { + return fmt.Errorf("failed to open library_contents file: %v", err) + } + defer file.Close() + + // Read all lines + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("failed to read library_contents file: %v", err) + } + + // Find and clear the slot line (set to empty, don't remove the line) + slotPattern := fmt.Sprintf("Slot %d:", slotID) + updated := false + var newLines []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, slotPattern) { + // Clear the slot by setting it to empty (format: "Slot X:" without "-") + // Preserve original indentation + indent := "" + for i, char := range line { + if char != ' ' && char != '\t' { + indent = line[:i] + break + } + } + newLines = append(newLines, fmt.Sprintf("%sSlot %d:", indent, slotID)) + updated = true + } else { + newLines = append(newLines, line) + } + } + + if !updated { + log.Printf("Slot %d not found in library_contents.%d", slotID, libraryID) + return nil // Not an error if slot not found + } + + // Write back to file + if err := file.Truncate(0); err != nil { + return fmt.Errorf("failed to truncate file: %v", err) + } + if _, err := file.Seek(0, 0); err != nil { + return fmt.Errorf("failed to seek file: %v", err) + } + + writer := bufio.NewWriter(file) + for _, line := range newLines { + if _, err := writer.WriteString(line + "\n"); err != nil { + return fmt.Errorf("failed to write line: %v", err) + } + } + if err := writer.Flush(); err != nil { + return fmt.Errorf("failed to flush file: %v", err) + } + + log.Printf("Removed slot %d from library_contents.%d", slotID, libraryID) + return nil +} + +// addTapeToLibraryContents adds a tape to library_contents file +func (s *VTLService) addTapeToLibraryContents(libraryID int, slotID int, barcode string) error { + contentsPath := fmt.Sprintf("/etc/mhvtl/library_contents.%d", libraryID) + + // Read the file + file, err := os.OpenFile(contentsPath, os.O_RDWR, 0644) + if err != nil { + return fmt.Errorf("failed to open library_contents file: %v", err) + } + defer file.Close() + + // Read all lines + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("failed to read library_contents file: %v", err) + } + + // Find and update the slot line + slotPattern := fmt.Sprintf("Slot %d:", slotID) + updated := false + var newLines []string + slotRegex := regexp.MustCompile(`^Slot\s+(\d+):\s*(.+)$`) + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, slotPattern) { + // Update the slot with barcode + // Preserve original indentation + indent := "" + for i, char := range line { + if char != ' ' && char != '\t' { + indent = line[:i] + break + } + } + newLines = append(newLines, fmt.Sprintf("%sSlot %d: %s", indent, slotID, barcode)) + updated = true + } else { + newLines = append(newLines, line) + } + } + + // If slot not found, add it at the end + if !updated { + // Find the highest slot number to determine where to add + maxSlot := 0 + for _, line := range lines { + trimmed := strings.TrimSpace(line) + matches := slotRegex.FindStringSubmatch(trimmed) + if len(matches) == 3 { + if sid, err := strconv.Atoi(matches[1]); err == nil && sid > maxSlot { + maxSlot = sid + } + } + } + + // Add new slot line + newLines = append(newLines, fmt.Sprintf("Slot %d: %s", slotID, barcode)) + updated = true + } + + if !updated { + return fmt.Errorf("failed to update slot %d in library_contents.%d", slotID, libraryID) + } + + // Write back to file + if err := file.Truncate(0); err != nil { + return fmt.Errorf("failed to truncate file: %v", err) + } + if _, err := file.Seek(0, 0); err != nil { + return fmt.Errorf("failed to seek file: %v", err) + } + + writer := bufio.NewWriter(file) + for _, line := range newLines { + if _, err := writer.WriteString(line + "\n"); err != nil { + return fmt.Errorf("failed to write line: %v", err) + } + } + if err := writer.Flush(); err != nil { + return fmt.Errorf("failed to flush file: %v", err) + } + + log.Printf("Added tape %s to slot %d in library_contents.%d", barcode, slotID, libraryID) + return nil +} + +// ensureWritableFilesystem checks if filesystem is writable and remounts if needed +func (s *VTLService) ensureWritableFilesystem(path string) error { + // Try to create a test file to check if writable + testFile := filepath.Join(path, ".write-test-check") + if file, err := os.Create(testFile); err == nil { + file.Close() + os.Remove(testFile) + return nil // Already writable + } + + // Filesystem might be read-only, try to remount + log.Printf("Filesystem appears read-only, attempting remount for %s", path) + return s.remountReadWrite(path) +} + +// remountReadWrite attempts to remount the filesystem containing the path as read-write +func (s *VTLService) remountReadWrite(path string) error { + // Find the mount point for the path + // Try /opt first, then /, then use findmnt + var mountPoint string + + // Check if path is under /opt + if strings.HasPrefix(path, "/opt") { + mountPoint = "/opt" + } else { + mountPoint = "/" + } + + // Try to remount with sudo + log.Printf("Attempting to remount %s as read-write", mountPoint) + cmd := exec.Command("sudo", "mount", "-o", "remount,rw", mountPoint) + if output, err := cmd.CombinedOutput(); err != nil { + outputStr := strings.TrimSpace(string(output)) + log.Printf("Remount failed for %s: %v, output: %s", mountPoint, err, outputStr) + + // Try alternative: remount root + if mountPoint != "/" { + log.Printf("Trying to remount root filesystem") + cmd = exec.Command("sudo", "mount", "-o", "remount,rw", "/") + if output, err := cmd.CombinedOutput(); err != nil { + outputStr = strings.TrimSpace(string(output)) + return fmt.Errorf("failed to remount filesystem: %v, output: %s", err, outputStr) + } + log.Printf("Root filesystem remounted as read-write") + return nil + } + return fmt.Errorf("failed to remount %s: %v, output: %s", mountPoint, err, outputStr) + } + + log.Printf("Filesystem %s remounted as read-write", mountPoint) + return nil +} diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 679fe6f..d744590 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -80,6 +80,10 @@ iSCSI Target - +
+ VTL (mhvtl) + - +
API Status Online @@ -153,6 +157,7 @@ updateStatus('smb-status', data.services.smb_status); updateStatus('nfs-status', data.services.nfs_status); updateStatus('iscsi-status', data.services.iscsi_status); + updateStatus('vtl-status', data.services.vtl_status); // Update jobs document.getElementById('jobs-running').textContent = data.jobs.running || 0; diff --git a/web/templates/vtl.html b/web/templates/vtl.html new file mode 100644 index 0000000..ac9cb76 --- /dev/null +++ b/web/templates/vtl.html @@ -0,0 +1,890 @@ +{{define "vtl-content"}} +
+
+
+

Virtual Tape Library

+

Manage mhvtl drives, tapes, and media changer

+
+
+ + +
+
+ + +
+
+
+

Service

+
+ + + +
+
+

-

+

mhvtl Service

+
+ +
+
+

Drives

+
+

-

+

- Total

+
+ +
+
+

Tapes

+
+

-

+

- Total

+
+ +
+
+

Media Changer

+
+

-

+

Libraries

+
+
+ + +
+
+ + + +
+ + +
+
+
+

Virtual Tape Drives

+

Manage tape drives and loaded media

+
+ +
+
+

Loading...

+
+
+ + + + + + +
+
+ + + + + + + + + + + +{{end}} + +{{define "vtl.html"}} +{{template "base" .}} +{{end}} +