310 lines
8.9 KiB
Go
310 lines
8.9 KiB
Go
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 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, "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, "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 {
|
|
disks, err := s.DiscoverDisks(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to discover disks: %w", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|