package storage import ( "context" "database/sql" "encoding/json" "fmt" "os/exec" "strconv" "strings" "time" "github.com/atlasos/calypso/internal/common/database" "github.com/atlasos/calypso/internal/common/logger" ) // DiskService handles disk discovery and management type DiskService struct { db *database.DB logger *logger.Logger } // NewDiskService creates a new disk service func NewDiskService(db *database.DB, log *logger.Logger) *DiskService { return &DiskService{ db: db, logger: log, } } // PhysicalDisk represents a physical disk type PhysicalDisk struct { ID string `json:"id"` DevicePath string `json:"device_path"` Vendor string `json:"vendor"` Model string `json:"model"` SerialNumber string `json:"serial_number"` SizeBytes int64 `json:"size_bytes"` SectorSize int `json:"sector_size"` IsSSD bool `json:"is_ssd"` HealthStatus string `json:"health_status"` HealthDetails map[string]interface{} `json:"health_details"` IsUsed bool `json:"is_used"` AttachedToPool string `json:"attached_to_pool"` // Pool name if disk is used in a ZFS pool CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // DiscoverDisks discovers physical disks on the system func (s *DiskService) DiscoverDisks(ctx context.Context) ([]PhysicalDisk, error) { // Use lsblk to discover block devices cmd := exec.CommandContext(ctx, "lsblk", "-b", "-o", "NAME,SIZE,TYPE", "-J") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to run lsblk: %w", err) } var lsblkOutput struct { BlockDevices []struct { Name string `json:"name"` Size interface{} `json:"size"` // Can be string or number Type string `json:"type"` } `json:"blockdevices"` } if err := json.Unmarshal(output, &lsblkOutput); err != nil { return nil, fmt.Errorf("failed to parse lsblk output: %w", err) } var disks []PhysicalDisk for _, device := range lsblkOutput.BlockDevices { // Only process disk devices (not partitions) if device.Type != "disk" { continue } devicePath := "/dev/" + device.Name // Skip ZFS volume block devices (zd* devices are ZFS volumes exported as block devices) // These are not physical disks and should not appear in physical disk list if strings.HasPrefix(device.Name, "zd") { s.logger.Debug("Skipping ZFS volume block device", "device", devicePath) continue } // Skip devices under /dev/zvol (ZFS volume devices in zvol directory) // These are virtual block devices created from ZFS volumes, not physical hardware if strings.HasPrefix(devicePath, "/dev/zvol/") { s.logger.Debug("Skipping ZFS volume device", "device", devicePath) continue } // Skip OS disk (disk that has root or boot partition) if s.isOSDisk(ctx, devicePath) { s.logger.Debug("Skipping OS disk", "device", devicePath) continue } disk, err := s.getDiskInfo(ctx, devicePath) if err != nil { s.logger.Warn("Failed to get disk info", "device", devicePath, "error", err) continue } // Parse size (can be string or number) var sizeBytes int64 switch v := device.Size.(type) { case string: if size, err := strconv.ParseInt(v, 10, 64); err == nil { sizeBytes = size } case float64: sizeBytes = int64(v) case int64: sizeBytes = v case int: sizeBytes = int64(v) } disk.SizeBytes = sizeBytes disks = append(disks, *disk) } return disks, nil } // getDiskInfo retrieves detailed information about a disk func (s *DiskService) getDiskInfo(ctx context.Context, devicePath string) (*PhysicalDisk, error) { disk := &PhysicalDisk{ DevicePath: devicePath, HealthStatus: "unknown", HealthDetails: make(map[string]interface{}), } // Get disk information using udevadm cmd := exec.CommandContext(ctx, "udevadm", "info", "--query=property", "--name="+devicePath) output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to get udev info: %w", err) } props := parseUdevProperties(string(output)) disk.Vendor = props["ID_VENDOR"] disk.Model = props["ID_MODEL"] disk.SerialNumber = props["ID_SERIAL_SHORT"] if props["ID_ATA_ROTATION_RATE"] == "0" { disk.IsSSD = true } // Get sector size if sectorSize, err := strconv.Atoi(props["ID_SECTOR_SIZE"]); err == nil { disk.SectorSize = sectorSize } // Check if disk is in use (part of a volume group or ZFS pool) disk.IsUsed = s.isDiskInUse(ctx, devicePath) // Check if disk is used in a ZFS pool poolName := s.getZFSPoolForDisk(ctx, devicePath) if poolName != "" { disk.IsUsed = true disk.AttachedToPool = poolName } // Get health status (simplified - would use smartctl in production) disk.HealthStatus = "healthy" // Placeholder return disk, nil } // parseUdevProperties parses udevadm output func parseUdevProperties(output string) map[string]string { props := make(map[string]string) lines := strings.Split(output, "\n") for _, line := range lines { parts := strings.SplitN(line, "=", 2) if len(parts) == 2 { props[parts[0]] = parts[1] } } return props } // isDiskInUse checks if a disk is part of a volume group func (s *DiskService) isDiskInUse(ctx context.Context, devicePath string) bool { cmd := exec.CommandContext(ctx, "pvdisplay", devicePath) err := cmd.Run() return err == nil } // getZFSPoolForDisk checks if a disk is used in a ZFS pool and returns the pool name func (s *DiskService) getZFSPoolForDisk(ctx context.Context, devicePath string) string { // Extract device name (e.g., /dev/sde -> sde) deviceName := strings.TrimPrefix(devicePath, "/dev/") // Get all ZFS pools cmd := exec.CommandContext(ctx, "sudo", "zpool", "list", "-H", "-o", "name") output, err := cmd.Output() if err != nil { return "" } pools := strings.Split(strings.TrimSpace(string(output)), "\n") for _, poolName := range pools { if poolName == "" { continue } // Check pool status for this device statusCmd := exec.CommandContext(ctx, "sudo", "zpool", "status", poolName) statusOutput, err := statusCmd.Output() if err != nil { continue } statusStr := string(statusOutput) // Check if device is in the pool (as data disk or spare) if strings.Contains(statusStr, deviceName) { return poolName } } return "" } // isOSDisk checks if a disk is used as OS disk (has root or boot partition) func (s *DiskService) isOSDisk(ctx context.Context, devicePath string) bool { // Extract device name (e.g., /dev/sda -> sda) deviceName := strings.TrimPrefix(devicePath, "/dev/") // Check if any partition of this disk is mounted as root or boot // Use lsblk to get mount points for this device and its children cmd := exec.CommandContext(ctx, "lsblk", "-n", "-o", "NAME,MOUNTPOINT", devicePath) output, err := cmd.Output() if err != nil { return false } lines := strings.Split(string(output), "\n") for _, line := range lines { fields := strings.Fields(line) if len(fields) >= 2 { mountPoint := fields[1] // Check if mounted as root or boot if mountPoint == "/" || mountPoint == "/boot" || mountPoint == "/boot/efi" { return true } } } // Also check all partitions of this disk using lsblk with recursive listing partCmd := exec.CommandContext(ctx, "lsblk", "-n", "-o", "NAME,MOUNTPOINT", "-l") partOutput, err := partCmd.Output() if err == nil { partLines := strings.Split(string(partOutput), "\n") for _, line := range partLines { if strings.HasPrefix(line, deviceName) { fields := strings.Fields(line) if len(fields) >= 2 { mountPoint := fields[1] if mountPoint == "/" || mountPoint == "/boot" || mountPoint == "/boot/efi" { return true } } } } } return false } // SyncDisksToDatabase syncs discovered disks to the database func (s *DiskService) SyncDisksToDatabase(ctx context.Context) error { s.logger.Info("Starting disk discovery and sync") disks, err := s.DiscoverDisks(ctx) if err != nil { s.logger.Error("Failed to discover disks", "error", err) return fmt.Errorf("failed to discover disks: %w", err) } s.logger.Info("Discovered disks", "count", len(disks)) for _, disk := range disks { // Check if disk exists var existingID string err := s.db.QueryRowContext(ctx, "SELECT id FROM physical_disks WHERE device_path = $1", disk.DevicePath, ).Scan(&existingID) healthDetailsJSON, _ := json.Marshal(disk.HealthDetails) if err == sql.ErrNoRows { // Insert new disk _, err = s.db.ExecContext(ctx, ` INSERT INTO physical_disks ( device_path, vendor, model, serial_number, size_bytes, sector_size, is_ssd, health_status, health_details, is_used ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) `, disk.DevicePath, disk.Vendor, disk.Model, disk.SerialNumber, disk.SizeBytes, disk.SectorSize, disk.IsSSD, disk.HealthStatus, healthDetailsJSON, disk.IsUsed) if err != nil { s.logger.Error("Failed to insert disk", "device", disk.DevicePath, "error", err) } } else if err == nil { // Update existing disk _, err = s.db.ExecContext(ctx, ` UPDATE physical_disks SET vendor = $1, model = $2, serial_number = $3, size_bytes = $4, sector_size = $5, is_ssd = $6, health_status = $7, health_details = $8, is_used = $9, updated_at = NOW() WHERE id = $10 `, disk.Vendor, disk.Model, disk.SerialNumber, disk.SizeBytes, disk.SectorSize, disk.IsSSD, disk.HealthStatus, healthDetailsJSON, disk.IsUsed, existingID) if err != nil { s.logger.Error("Failed to update disk", "device", disk.DevicePath, "error", err) } else { s.logger.Debug("Updated disk", "device", disk.DevicePath) } } } s.logger.Info("Disk sync completed", "total_disks", len(disks)) return nil } // ListDisksFromDatabase retrieves all physical disks from the database func (s *DiskService) ListDisksFromDatabase(ctx context.Context) ([]PhysicalDisk, error) { query := ` SELECT id, device_path, vendor, model, serial_number, size_bytes, sector_size, is_ssd, health_status, health_details, is_used, created_at, updated_at FROM physical_disks ORDER BY device_path ` rows, err := s.db.QueryContext(ctx, query) if err != nil { return nil, fmt.Errorf("failed to query disks: %w", err) } defer rows.Close() var disks []PhysicalDisk for rows.Next() { var disk PhysicalDisk var healthDetailsJSON []byte var attachedToPool sql.NullString err := rows.Scan( &disk.ID, &disk.DevicePath, &disk.Vendor, &disk.Model, &disk.SerialNumber, &disk.SizeBytes, &disk.SectorSize, &disk.IsSSD, &disk.HealthStatus, &healthDetailsJSON, &disk.IsUsed, &disk.CreatedAt, &disk.UpdatedAt, ) if err != nil { s.logger.Warn("Failed to scan disk row", "error", err) continue } // Parse health details JSON if len(healthDetailsJSON) > 0 { if err := json.Unmarshal(healthDetailsJSON, &disk.HealthDetails); err != nil { s.logger.Warn("Failed to parse health details", "error", err) disk.HealthDetails = make(map[string]interface{}) } } else { disk.HealthDetails = make(map[string]interface{}) } // Get ZFS pool attachment if disk is used if disk.IsUsed { err := s.db.QueryRowContext(ctx, `SELECT zp.name FROM zfs_pools zp INNER JOIN zfs_pool_disks zpd ON zp.id = zpd.pool_id WHERE zpd.disk_id = $1 LIMIT 1`, disk.ID, ).Scan(&attachedToPool) if err == nil && attachedToPool.Valid { disk.AttachedToPool = attachedToPool.String } } disks = append(disks, disk) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("error iterating disk rows: %w", err) } return disks, nil }