package tape_vtl import ( "bufio" "context" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "time" "database/sql" "github.com/atlasos/calypso/internal/common/database" "github.com/atlasos/calypso/internal/common/logger" ) // MHVTLMonitor monitors mhvtl configuration files and syncs to database type MHVTLMonitor struct { service *Service logger *logger.Logger configPath string interval time.Duration stopCh chan struct{} } // NewMHVTLMonitor creates a new MHVTL monitor service func NewMHVTLMonitor(db *database.DB, log *logger.Logger, configPath string, interval time.Duration) *MHVTLMonitor { return &MHVTLMonitor{ service: NewService(db, log), logger: log, configPath: configPath, interval: interval, stopCh: make(chan struct{}), } } // Start starts the MHVTL monitor background service func (m *MHVTLMonitor) Start(ctx context.Context) { m.logger.Info("Starting MHVTL monitor service", "config_path", m.configPath, "interval", m.interval) ticker := time.NewTicker(m.interval) defer ticker.Stop() // Run initial sync immediately m.syncMHVTL(ctx) for { select { case <-ctx.Done(): m.logger.Info("MHVTL monitor service stopped") return case <-m.stopCh: m.logger.Info("MHVTL monitor service stopped") return case <-ticker.C: m.syncMHVTL(ctx) } } } // Stop stops the MHVTL monitor service func (m *MHVTLMonitor) Stop() { close(m.stopCh) } // syncMHVTL parses mhvtl configuration and syncs to database func (m *MHVTLMonitor) syncMHVTL(ctx context.Context) { m.logger.Debug("Running MHVTL configuration sync") deviceConfPath := filepath.Join(m.configPath, "device.conf") if _, err := os.Stat(deviceConfPath); os.IsNotExist(err) { m.logger.Warn("MHVTL device.conf not found", "path", deviceConfPath) return } // Parse device.conf to get libraries and drives libraries, drives, err := m.parseDeviceConf(ctx, deviceConfPath) if err != nil { m.logger.Error("Failed to parse device.conf", "error", err) return } m.logger.Info("Parsed MHVTL configuration", "libraries", len(libraries), "drives", len(drives)) // Sync libraries to database for _, lib := range libraries { if err := m.syncLibrary(ctx, lib); err != nil { m.logger.Error("Failed to sync library", "library_id", lib.LibraryID, "error", err) } } // Sync drives to database for _, drive := range drives { if err := m.syncDrive(ctx, drive); err != nil { m.logger.Error("Failed to sync drive", "drive_id", drive.DriveID, "error", err) } } // Parse library_contents files to get tapes for _, lib := range libraries { contentsPath := filepath.Join(m.configPath, fmt.Sprintf("library_contents.%d", lib.LibraryID)) if err := m.syncLibraryContents(ctx, lib.LibraryID, contentsPath); err != nil { m.logger.Warn("Failed to sync library contents", "library_id", lib.LibraryID, "error", err) } } m.logger.Debug("MHVTL configuration sync completed") } // LibraryInfo represents a library from device.conf type LibraryInfo struct { LibraryID int Vendor string Product string SerialNumber string HomeDirectory string Channel string Target string LUN string } // DriveInfo represents a drive from device.conf type DriveInfo struct { DriveID int LibraryID int Slot int Vendor string Product string SerialNumber string Channel string Target string LUN string } // parseDeviceConf parses mhvtl device.conf file func (m *MHVTLMonitor) parseDeviceConf(ctx context.Context, path string) ([]LibraryInfo, []DriveInfo, error) { file, err := os.Open(path) if err != nil { return nil, nil, fmt.Errorf("failed to open device.conf: %w", err) } defer file.Close() var libraries []LibraryInfo var drives []DriveInfo scanner := bufio.NewScanner(file) var currentLibrary *LibraryInfo var currentDrive *DriveInfo libraryRegex := regexp.MustCompile(`^Library:\s+(\d+)\s+CHANNEL:\s+(\S+)\s+TARGET:\s+(\S+)\s+LUN:\s+(\S+)`) driveRegex := regexp.MustCompile(`^Drive:\s+(\d+)\s+CHANNEL:\s+(\S+)\s+TARGET:\s+(\S+)\s+LUN:\s+(\S+)`) libraryIDRegex := regexp.MustCompile(`Library ID:\s+(\d+)\s+Slot:\s+(\d+)`) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Skip comments and empty lines if strings.HasPrefix(line, "#") || line == "" { continue } // Check for Library entry if matches := libraryRegex.FindStringSubmatch(line); matches != nil { if currentLibrary != nil { libraries = append(libraries, *currentLibrary) } libID, _ := strconv.Atoi(matches[1]) currentLibrary = &LibraryInfo{ LibraryID: libID, Channel: matches[2], Target: matches[3], LUN: matches[4], } currentDrive = nil continue } // Check for Drive entry if matches := driveRegex.FindStringSubmatch(line); matches != nil { if currentDrive != nil { drives = append(drives, *currentDrive) } driveID, _ := strconv.Atoi(matches[1]) currentDrive = &DriveInfo{ DriveID: driveID, Channel: matches[2], Target: matches[3], LUN: matches[4], } if matches := libraryIDRegex.FindStringSubmatch(line); matches != nil { libID, _ := strconv.Atoi(matches[1]) slot, _ := strconv.Atoi(matches[2]) currentDrive.LibraryID = libID currentDrive.Slot = slot } continue } // Parse library fields if currentLibrary != nil { if strings.HasPrefix(line, "Vendor identification:") { currentLibrary.Vendor = strings.TrimSpace(strings.TrimPrefix(line, "Vendor identification:")) } else if strings.HasPrefix(line, "Product identification:") { currentLibrary.Product = strings.TrimSpace(strings.TrimPrefix(line, "Product identification:")) } else if strings.HasPrefix(line, "Unit serial number:") { currentLibrary.SerialNumber = strings.TrimSpace(strings.TrimPrefix(line, "Unit serial number:")) } else if strings.HasPrefix(line, "Home directory:") { currentLibrary.HomeDirectory = strings.TrimSpace(strings.TrimPrefix(line, "Home directory:")) } } // Parse drive fields if currentDrive != nil { if strings.HasPrefix(line, "Vendor identification:") { currentDrive.Vendor = strings.TrimSpace(strings.TrimPrefix(line, "Vendor identification:")) } else if strings.HasPrefix(line, "Product identification:") { currentDrive.Product = strings.TrimSpace(strings.TrimPrefix(line, "Product identification:")) } else if strings.HasPrefix(line, "Unit serial number:") { currentDrive.SerialNumber = strings.TrimSpace(strings.TrimPrefix(line, "Unit serial number:")) } else if strings.HasPrefix(line, "Library ID:") && strings.Contains(line, "Slot:") { matches := libraryIDRegex.FindStringSubmatch(line) if matches != nil { libID, _ := strconv.Atoi(matches[1]) slot, _ := strconv.Atoi(matches[2]) currentDrive.LibraryID = libID currentDrive.Slot = slot } } } } // Add last library and drive if currentLibrary != nil { libraries = append(libraries, *currentLibrary) } if currentDrive != nil { drives = append(drives, *currentDrive) } if err := scanner.Err(); err != nil { return nil, nil, fmt.Errorf("error reading device.conf: %w", err) } return libraries, drives, nil } // syncLibrary syncs a library to database func (m *MHVTLMonitor) syncLibrary(ctx context.Context, libInfo LibraryInfo) error { // Check if library exists by mhvtl_library_id var existingID string err := m.service.db.QueryRowContext(ctx, "SELECT id FROM virtual_tape_libraries WHERE mhvtl_library_id = $1", libInfo.LibraryID, ).Scan(&existingID) libraryName := fmt.Sprintf("VTL-%d", libInfo.LibraryID) if libInfo.Product != "" { libraryName = fmt.Sprintf("%s-%d", libInfo.Product, libInfo.LibraryID) } if err == sql.ErrNoRows { // Create new library // Get backing store path from mhvtl.conf backingStorePath := "/opt/mhvtl" if libInfo.HomeDirectory != "" { backingStorePath = libInfo.HomeDirectory } // Count slots and drives from library_contents file contentsPath := filepath.Join(m.configPath, fmt.Sprintf("library_contents.%d", libInfo.LibraryID)) slotCount, driveCount := m.countSlotsAndDrives(contentsPath) _, err = m.service.db.ExecContext(ctx, ` INSERT INTO virtual_tape_libraries ( name, description, mhvtl_library_id, backing_store_path, vendor, slot_count, drive_count, is_active ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `, libraryName, fmt.Sprintf("MHVTL Library %d (%s)", libInfo.LibraryID, libInfo.Product), libInfo.LibraryID, backingStorePath, libInfo.Vendor, slotCount, driveCount, true) if err != nil { return fmt.Errorf("failed to insert library: %w", err) } m.logger.Info("Created virtual library from MHVTL", "library_id", libInfo.LibraryID, "name", libraryName) } else if err == nil { // Update existing library _, err = m.service.db.ExecContext(ctx, ` UPDATE virtual_tape_libraries SET name = $1, description = $2, backing_store_path = $3, vendor = $4, is_active = $5, updated_at = NOW() WHERE id = $6 `, libraryName, fmt.Sprintf("MHVTL Library %d (%s)", libInfo.LibraryID, libInfo.Product), libInfo.HomeDirectory, libInfo.Vendor, true, existingID) if err != nil { return fmt.Errorf("failed to update library: %w", err) } m.logger.Debug("Updated virtual library from MHVTL", "library_id", libInfo.LibraryID) } else { return fmt.Errorf("failed to check library existence: %w", err) } return nil } // syncDrive syncs a drive to database func (m *MHVTLMonitor) syncDrive(ctx context.Context, driveInfo DriveInfo) error { // Get library ID from mhvtl_library_id var libraryID string err := m.service.db.QueryRowContext(ctx, "SELECT id FROM virtual_tape_libraries WHERE mhvtl_library_id = $1", driveInfo.LibraryID, ).Scan(&libraryID) if err != nil { return fmt.Errorf("library not found for drive: %w", err) } // Calculate drive number from slot (drives are typically in slots 1, 2, 3, etc.) driveNumber := driveInfo.Slot // Check if drive exists var existingID string err = m.service.db.QueryRowContext(ctx, "SELECT id FROM virtual_tape_drives WHERE library_id = $1 AND drive_number = $2", libraryID, driveNumber, ).Scan(&existingID) // Get device path (typically /dev/stX or /dev/nstX) devicePath := fmt.Sprintf("/dev/st%d", driveInfo.DriveID-10) // Drive 11 -> st1, Drive 12 -> st2, etc. stablePath := fmt.Sprintf("/dev/tape/by-id/scsi-%s", driveInfo.SerialNumber) if err == sql.ErrNoRows { // Create new drive _, err = m.service.db.ExecContext(ctx, ` INSERT INTO virtual_tape_drives ( library_id, drive_number, device_path, stable_path, status, is_active ) VALUES ($1, $2, $3, $4, $5, $6) `, libraryID, driveNumber, devicePath, stablePath, "idle", true) if err != nil { return fmt.Errorf("failed to insert drive: %w", err) } m.logger.Info("Created virtual drive from MHVTL", "drive_id", driveInfo.DriveID, "library_id", driveInfo.LibraryID) } else if err == nil { // Update existing drive _, err = m.service.db.ExecContext(ctx, ` UPDATE virtual_tape_drives SET device_path = $1, stable_path = $2, is_active = $3, updated_at = NOW() WHERE id = $4 `, devicePath, stablePath, true, existingID) if err != nil { return fmt.Errorf("failed to update drive: %w", err) } m.logger.Debug("Updated virtual drive from MHVTL", "drive_id", driveInfo.DriveID) } else { return fmt.Errorf("failed to check drive existence: %w", err) } return nil } // syncLibraryContents syncs tapes from library_contents file func (m *MHVTLMonitor) syncLibraryContents(ctx context.Context, libraryID int, contentsPath string) error { // Get library ID from database var dbLibraryID string err := m.service.db.QueryRowContext(ctx, "SELECT id FROM virtual_tape_libraries WHERE mhvtl_library_id = $1", libraryID, ).Scan(&dbLibraryID) if err != nil { return fmt.Errorf("library not found: %w", err) } // Get backing store path var backingStorePath string err = m.service.db.QueryRowContext(ctx, "SELECT backing_store_path FROM virtual_tape_libraries WHERE id = $1", dbLibraryID, ).Scan(&backingStorePath) if err != nil { return fmt.Errorf("failed to get backing store path: %w", err) } file, err := os.Open(contentsPath) if err != nil { return fmt.Errorf("failed to open library_contents file: %w", err) } defer file.Close() scanner := bufio.NewScanner(file) slotRegex := regexp.MustCompile(`^Slot\s+(\d+):\s+(.+)`) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Skip comments and empty lines if strings.HasPrefix(line, "#") || line == "" { continue } matches := slotRegex.FindStringSubmatch(line) if matches != nil { slotNumber, _ := strconv.Atoi(matches[1]) barcode := strings.TrimSpace(matches[2]) if barcode == "" || barcode == "?" { continue // Empty slot } // Determine tape type from barcode suffix tapeType := "LTO-8" // Default if len(barcode) >= 2 { suffix := barcode[len(barcode)-2:] switch suffix { case "L1": tapeType = "LTO-1" case "L2": tapeType = "LTO-2" case "L3": tapeType = "LTO-3" case "L4": tapeType = "LTO-4" case "L5": tapeType = "LTO-5" case "L6": tapeType = "LTO-6" case "L7": tapeType = "LTO-7" case "L8": tapeType = "LTO-8" case "L9": tapeType = "LTO-9" } } // Check if tape exists var existingID string err := m.service.db.QueryRowContext(ctx, "SELECT id FROM virtual_tapes WHERE library_id = $1 AND barcode = $2", dbLibraryID, barcode, ).Scan(&existingID) imagePath := filepath.Join(backingStorePath, "tapes", fmt.Sprintf("%s.img", barcode)) defaultSize := int64(15 * 1024 * 1024 * 1024 * 1024) // 15 TB default for LTO-8 if err == sql.ErrNoRows { // Create new tape _, err = m.service.db.ExecContext(ctx, ` 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) `, dbLibraryID, barcode, slotNumber, imagePath, defaultSize, 0, tapeType, "idle") if err != nil { m.logger.Warn("Failed to insert tape", "barcode", barcode, "error", err) } else { m.logger.Debug("Created virtual tape from MHVTL", "barcode", barcode, "slot", slotNumber) } } else if err == nil { // Update existing tape slot _, err = m.service.db.ExecContext(ctx, ` UPDATE virtual_tapes SET slot_number = $1, tape_type = $2, updated_at = NOW() WHERE id = $3 `, slotNumber, tapeType, existingID) if err != nil { m.logger.Warn("Failed to update tape", "barcode", barcode, "error", err) } } } } return scanner.Err() } // countSlotsAndDrives counts slots and drives from library_contents file func (m *MHVTLMonitor) countSlotsAndDrives(contentsPath string) (slotCount, driveCount int) { file, err := os.Open(contentsPath) if err != nil { return 10, 2 // Default values } defer file.Close() scanner := bufio.NewScanner(file) slotRegex := regexp.MustCompile(`^Slot\s+(\d+):`) driveRegex := regexp.MustCompile(`^Drive\s+(\d+):`) maxSlot := 0 driveCount = 0 for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "#") || line == "" { continue } if matches := slotRegex.FindStringSubmatch(line); matches != nil { slot, _ := strconv.Atoi(matches[1]) if slot > maxSlot { maxSlot = slot } } if matches := driveRegex.FindStringSubmatch(line); matches != nil { driveCount++ } } slotCount = maxSlot if slotCount == 0 { slotCount = 10 // Default } if driveCount == 0 { driveCount = 2 // Default } return slotCount, driveCount }