feat: support multipath in Kairos SDK

Signed-off-by: Aidan Leuck <aidan_leuck@selinc.com>
This commit is contained in:
Aidan Leuck
2025-08-07 18:58:38 -06:00
committed by Dimitris Karakasilis
parent 2fcb006782
commit 1cd4261f67
6 changed files with 851 additions and 232 deletions

View File

@@ -0,0 +1,58 @@
package ghw
import (
"os"
"path/filepath"
"strings"
"github.com/kairos-io/kairos-sdk/types"
)
type DiskPartitionHandler struct {
DiskName string
}
// Validate that DiskPartitionHandler implements PartitionHandler interface
var _ PartitionHandler = &DiskPartitionHandler{}
func NewDiskPartitionHandler(diskName string) *DiskPartitionHandler {
return &DiskPartitionHandler{DiskName: diskName}
}
func (d *DiskPartitionHandler) GetPartitions(paths *Paths, logger *types.KairosLogger) types.PartitionList {
out := make(types.PartitionList, 0)
path := filepath.Join(paths.SysBlock, d.DiskName)
logger.Logger.Debug().Str("file", path).Msg("Reading disk file")
files, err := os.ReadDir(path)
if err != nil {
logger.Logger.Error().Err(err).Msg("failed to read disk partitions")
return out
}
for _, file := range files {
fname := file.Name()
if !strings.HasPrefix(fname, d.DiskName) {
continue
}
logger.Logger.Debug().Str("file", fname).Msg("Reading partition file")
partitionPath := filepath.Join(d.DiskName, fname)
size := partitionSizeBytes(paths, partitionPath, logger)
mp, pt := partitionInfo(paths, fname, logger)
du := diskPartUUID(paths, partitionPath, logger)
if pt == "" {
pt = diskPartTypeUdev(paths, partitionPath, logger)
}
fsLabel := diskFSLabel(paths, partitionPath, logger)
p := &types.Partition{
Name: fname,
Size: uint(size / (1024 * 1024)),
MountPoint: mp,
UUID: du,
FilesystemLabel: fsLabel,
FS: pt,
Path: filepath.Join("/dev", fname),
Disk: filepath.Join("/dev", d.DiskName),
}
out = append(out, p)
}
return out
}

View File

@@ -1,9 +1,7 @@
package ghw package ghw
import ( import (
"bufio"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -49,6 +47,10 @@ func NewPaths(withOptionalPrefix string) *Paths {
return p return p
} }
func isMultipathDevice(entry os.DirEntry) bool {
return strings.HasPrefix(entry.Name(), "dm-")
}
func GetDisks(paths *Paths, logger *types.KairosLogger) []*types.Disk { func GetDisks(paths *Paths, logger *types.KairosLogger) []*types.Disk {
if logger == nil { if logger == nil {
newLogger := types.NewKairosLogger("ghw", "info", false) newLogger := types.NewKairosLogger("ghw", "info", false)
@@ -61,10 +63,18 @@ func GetDisks(paths *Paths, logger *types.KairosLogger) []*types.Disk {
return nil return nil
} }
for _, file := range files { for _, file := range files {
var partitionHandler PartitionHandler;
logger.Logger.Debug().Str("file", file.Name()).Msg("Reading file") logger.Logger.Debug().Str("file", file.Name()).Msg("Reading file")
dname := file.Name() dname := file.Name()
size := diskSizeBytes(paths, dname, logger) size := diskSizeBytes(paths, dname, logger)
// Skip entries that are multipath partitions
// we will handle them when we parse this disks partitions
if isMultipathPartition(file, paths, logger) {
logger.Logger.Debug().Str("file", dname).Msg("Skipping multipath partition")
continue
}
if strings.HasPrefix(dname, "loop") && size == 0 { if strings.HasPrefix(dname, "loop") && size == 0 {
// We don't care about unused loop devices... // We don't care about unused loop devices...
continue continue
@@ -72,10 +82,17 @@ func GetDisks(paths *Paths, logger *types.KairosLogger) []*types.Disk {
d := &types.Disk{ d := &types.Disk{
Name: dname, Name: dname,
SizeBytes: size, SizeBytes: size,
UUID: diskUUID(paths, dname, "", logger), UUID: diskUUID(paths, dname, logger),
} }
parts := diskPartitions(paths, dname, logger) if(isMultipathDevice(file)) {
partitionHandler = NewMultipathPartitionHandler(dname)
} else {
partitionHandler = NewDiskPartitionHandler(dname)
}
parts := partitionHandler.GetPartitions(paths, logger)
d.Partitions = parts d.Partitions = parts
disks = append(disks, d) disks = append(disks, d)
@@ -84,6 +101,25 @@ func GetDisks(paths *Paths, logger *types.KairosLogger) []*types.Disk {
return disks return disks
} }
func isMultipathPartition(entry os.DirEntry, paths *Paths, logger *types.KairosLogger) bool {
// Must be a dm device to be a multipath partition
if !isMultipathDevice(entry) {
return false
}
deviceName := entry.Name()
udevInfo, err := udevInfoPartition(paths, deviceName, logger)
if err != nil {
logger.Logger.Error().Err(err).Str("devNo", deviceName).Msg("Failed to get udev info")
return false
}
// Check if the udev info contains DM_PART indicating it's a partition
// this is the primary check for multipath partitions and should be safe.
_, ok := udevInfo["DM_PART"]
return ok
}
func diskSizeBytes(paths *Paths, disk string, logger *types.KairosLogger) uint64 { func diskSizeBytes(paths *Paths, disk string, logger *types.KairosLogger) uint64 {
// We can find the number of 512-byte sectors by examining the contents of // We can find the number of 512-byte sectors by examining the contents of
// /sys/block/$DEVICE/size and calculate the physical bytes accordingly. // /sys/block/$DEVICE/size and calculate the physical bytes accordingly.
@@ -102,229 +138,3 @@ func diskSizeBytes(paths *Paths, disk string, logger *types.KairosLogger) uint64
logger.Logger.Trace().Uint64("size", size*sectorSize).Msg("Got disk size") logger.Logger.Trace().Uint64("size", size*sectorSize).Msg("Got disk size")
return size * sectorSize return size * sectorSize
} }
// diskPartitions takes the name of a disk (note: *not* the path of the disk,
// but just the name. In other words, "sda", not "/dev/sda" and "nvme0n1" not
// "/dev/nvme0n1") and returns a slice of pointers to Partition structs
// representing the partitions in that disk
func diskPartitions(paths *Paths, disk string, logger *types.KairosLogger) types.PartitionList {
out := make(types.PartitionList, 0)
path := filepath.Join(paths.SysBlock, disk)
logger.Logger.Debug().Str("file", path).Msg("Reading disk file")
files, err := os.ReadDir(path)
if err != nil {
logger.Logger.Error().Err(err).Msg("failed to read disk partitions")
return out
}
for _, file := range files {
fname := file.Name()
if !strings.HasPrefix(fname, disk) {
continue
}
logger.Logger.Debug().Str("file", fname).Msg("Reading partition file")
size := partitionSizeBytes(paths, disk, fname, logger)
mp, pt := partitionInfo(paths, fname, logger)
du := diskPartUUID(paths, disk, fname, logger)
if pt == "" {
pt = diskPartTypeUdev(paths, disk, fname, logger)
}
fsLabel := diskFSLabel(paths, disk, fname, logger)
p := &types.Partition{
Name: fname,
Size: uint(size / (1024 * 1024)),
MountPoint: mp,
UUID: du,
FilesystemLabel: fsLabel,
FS: pt,
Path: filepath.Join("/dev", fname),
Disk: filepath.Join("/dev", disk),
}
out = append(out, p)
}
return out
}
func partitionSizeBytes(paths *Paths, disk string, part string, logger *types.KairosLogger) uint64 {
path := filepath.Join(paths.SysBlock, disk, part, "size")
logger.Logger.Debug().Str("file", path).Msg("Reading size file")
contents, err := os.ReadFile(path)
if err != nil {
logger.Logger.Error().Str("file", path).Err(err).Msg("failed to read disk partition size")
return 0
}
size, err := strconv.ParseUint(strings.TrimSpace(string(contents)), 10, 64)
if err != nil {
logger.Logger.Error().Str("contents", string(contents)).Err(err).Msg("failed to parse disk partition size")
return 0
}
logger.Logger.Trace().Str("disk", disk).Str("partition", part).Uint64("size", size*sectorSize).Msg("Got partition size")
return size * sectorSize
}
func partitionInfo(paths *Paths, part string, logger *types.KairosLogger) (string, string) {
// Allow calling PartitionInfo with either the full partition name
// "/dev/sda1" or just "sda1"
if !strings.HasPrefix(part, "/dev") {
part = "/dev/" + part
}
// mount entries for mounted partitions look like this:
// /dev/sda6 / ext4 rw,relatime,errors=remount-ro,data=ordered 0 0
var r io.ReadCloser
logger.Logger.Debug().Str("file", paths.ProcMounts).Msg("Reading mounts file")
r, err := os.Open(paths.ProcMounts)
if err != nil {
logger.Logger.Error().Str("file", paths.ProcMounts).Err(err).Msg("failed to open mounts")
return "", ""
}
defer r.Close()
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
logger.Logger.Trace().Str("line", line).Msg("Parsing mount info")
entry := parseMountEntry(line, logger)
if entry == nil || entry.Partition != part {
continue
}
return entry.Mountpoint, entry.FilesystemType
}
return "", ""
}
type mountEntry struct {
Partition string
Mountpoint string
FilesystemType string
}
func parseMountEntry(line string, logger *types.KairosLogger) *mountEntry {
// mount entries for mounted partitions look like this:
// /dev/sda6 / ext4 rw,relatime,errors=remount-ro,data=ordered 0 0
if line[0] != '/' {
return nil
}
fields := strings.Fields(line)
if len(fields) < 4 {
logger.Logger.Debug().Interface("fields", fields).Msg("Mount line has less than 4 fields")
return nil
}
// We do some special parsing of the mountpoint, which may contain space,
// tab and newline characters, encoded into the mount entry line using their
// octal-to-string representations. From the GNU mtab man pages:
//
// "Therefore these characters are encoded in the files and the getmntent
// function takes care of the decoding while reading the entries back in.
// '\040' is used to encode a space character, '\011' to encode a tab
// character, '\012' to encode a newline character, and '\\' to encode a
// backslash."
mp := fields[1]
r := strings.NewReplacer(
"\\011", "\t", "\\012", "\n", "\\040", " ", "\\\\", "\\",
)
mp = r.Replace(mp)
res := &mountEntry{
Partition: fields[0],
Mountpoint: mp,
FilesystemType: fields[2],
}
return res
}
func diskUUID(paths *Paths, disk string, partition string, logger *types.KairosLogger) string {
info, err := udevInfoPartition(paths, disk, partition, logger)
logger.Logger.Trace().Interface("info", info).Msg("Disk UUID")
if err != nil {
logger.Logger.Error().Str("disk", disk).Str("partition", partition).Interface("info", info).Err(err).Msg("failed to read disk UUID")
return UNKNOWN
}
if pType, ok := info["ID_PART_TABLE_UUID"]; ok {
logger.Logger.Trace().Str("disk", disk).Str("partition", partition).Str("uuid", pType).Msg("Got disk uuid")
return pType
}
return UNKNOWN
}
func diskPartUUID(paths *Paths, disk string, partition string, logger *types.KairosLogger) string {
info, err := udevInfoPartition(paths, disk, partition, logger)
logger.Logger.Trace().Interface("info", info).Msg("Disk Part UUID")
if err != nil {
logger.Logger.Error().Str("disk", disk).Str("partition", partition).Interface("info", info).Err(err).Msg("Disk Part UUID")
return UNKNOWN
}
if pType, ok := info["ID_PART_ENTRY_UUID"]; ok {
logger.Logger.Trace().Str("disk", disk).Str("partition", partition).Str("uuid", pType).Msg("Got partition uuid")
return pType
}
return UNKNOWN
}
// diskPartTypeUdev gets the partition type from the udev database directly and its only used as fallback when
// the partition is not mounted, so we cannot get the type from paths.ProcMounts from the partitionInfo function
func diskPartTypeUdev(paths *Paths, disk string, partition string, logger *types.KairosLogger) string {
info, err := udevInfoPartition(paths, disk, partition, logger)
logger.Logger.Trace().Interface("info", info).Msg("Disk Part Type")
if err != nil {
logger.Logger.Error().Str("disk", disk).Str("partition", partition).Interface("info", info).Err(err).Msg("Disk Part Type")
return UNKNOWN
}
if pType, ok := info["ID_FS_TYPE"]; ok {
logger.Logger.Trace().Str("disk", disk).Str("partition", partition).Str("FS", pType).Msg("Got partition fs type")
return pType
}
return UNKNOWN
}
func diskFSLabel(paths *Paths, disk string, partition string, logger *types.KairosLogger) string {
info, err := udevInfoPartition(paths, disk, partition, logger)
logger.Logger.Trace().Interface("info", info).Msg("Disk FS label")
if err != nil {
logger.Logger.Error().Str("disk", disk).Str("partition", partition).Interface("info", info).Err(err).Msg("Disk FS label")
return UNKNOWN
}
if label, ok := info["ID_FS_LABEL"]; ok {
logger.Logger.Trace().Str("disk", disk).Str("partition", partition).Str("uuid", label).Msg("Got partition label")
return label
}
return UNKNOWN
}
func udevInfoPartition(paths *Paths, disk string, partition string, logger *types.KairosLogger) (map[string]string, error) {
// Get device major:minor numbers
devNo, err := os.ReadFile(filepath.Join(paths.SysBlock, disk, partition, "dev"))
if err != nil {
logger.Logger.Error().Err(err).Str("path", filepath.Join(paths.SysBlock, disk, partition, "dev")).Msg("failed to read udev info")
return nil, err
}
return UdevInfo(paths, string(devNo), logger)
}
// UdevInfo will return information on udev database about a device number
func UdevInfo(paths *Paths, devNo string, logger *types.KairosLogger) (map[string]string, error) {
// Look up block device in udev runtime database
udevID := "b" + strings.TrimSpace(devNo)
udevBytes, err := os.ReadFile(filepath.Join(paths.RunUdevData, udevID))
if err != nil {
logger.Logger.Error().Err(err).Str("path", filepath.Join(paths.RunUdevData, udevID)).Msg("failed to read udev info for device")
return nil, err
}
udevInfo := make(map[string]string)
for _, udevLine := range strings.Split(string(udevBytes), "\n") {
if strings.HasPrefix(udevLine, "E:") {
if s := strings.SplitN(udevLine[2:], "=", 2); len(s) == 2 {
udevInfo[s[0]] = s[1]
}
}
}
return udevInfo, nil
}

View File

@@ -69,4 +69,250 @@ var _ = Describe("GHW functions tests", func() {
}) })
}) })
Describe("With multipath devices", func() {
BeforeEach(func() {
// Create a multipath device dm-0 with two partitions dm-1 and dm-2
multipathDisk := types.Disk{
Name: "dm-0",
UUID: "mpath-uuid-123",
SizeBytes: 10 * 1024 * 1024, // 10MB
Partitions: []*types.Partition{
{
Name: "dm-1",
FilesystemLabel: "MPATH_BOOT",
FS: "ext4",
MountPoint: "/boot",
Size: 512,
UUID: "part1-uuid-456",
},
{
Name: "dm-2",
FilesystemLabel: "MPATH_DATA",
FS: "xfs",
MountPoint: "/data",
Size: 1024,
UUID: "part2-uuid-789",
},
},
}
ghwMock.AddDisk(multipathDisk)
ghwMock.CreateMultipathDevices()
})
It("Identifies multipath device correctly", func() {
disks := ghw.GetDisks(ghw.NewPaths(ghwMock.Chroot), nil)
// Should find the multipath device but not the partitions as separate disks
Expect(len(disks)).To(Equal(1), "Should find only the multipath device")
disk := disks[0]
Expect(disk.Name).To(Equal("dm-0"))
Expect(disk.UUID).To(Equal("mpath-uuid-123"))
Expect(disk.SizeBytes).To(Equal(uint64(10 * 1024 * 1024 * 512))) // size * sectorSize
})
It("Finds multipath partitions using MultipathPartitionHandler", func() {
disks := ghw.GetDisks(ghw.NewPaths(ghwMock.Chroot), nil)
Expect(len(disks)).To(Equal(1))
disk := disks[0]
// Should find both partitions
Expect(len(disk.Partitions)).To(Equal(2))
// Verify first partition
part1 := disk.Partitions[0]
Expect(part1.Name).To(Equal("dm-1"))
Expect(part1.FilesystemLabel).To(Equal("MPATH_BOOT"))
Expect(part1.FS).To(Equal("ext4"))
Expect(part1.MountPoint).To(Equal("/boot"))
Expect(part1.UUID).To(Equal("part1-uuid-456"))
Expect(part1.Path).To(Equal("/dev/dm-1"))
Expect(part1.Disk).To(Equal("/dev/dm-0"))
// Verify second partition
part2 := disk.Partitions[1]
Expect(part2.Name).To(Equal("dm-2"))
Expect(part2.FilesystemLabel).To(Equal("MPATH_DATA"))
Expect(part2.FS).To(Equal("xfs"))
Expect(part2.MountPoint).To(Equal("/data"))
Expect(part2.UUID).To(Equal("part2-uuid-789"))
Expect(part2.Path).To(Equal("/dev/dm-2"))
Expect(part2.Disk).To(Equal("/dev/dm-0"))
})
It("Skips multipath partitions in main disk enumeration", func() {
// This test verifies that multipath partitions (dm-1, dm-2) are not
// returned as separate disks in the main GetDisks call
disks := ghw.GetDisks(ghw.NewPaths(ghwMock.Chroot), nil)
// Should only find the parent multipath device, not the partition devices
Expect(len(disks)).To(Equal(1))
Expect(disks[0].Name).To(Equal("dm-0"))
// Verify no disk named dm-1 or dm-2 is returned
for _, disk := range disks {
Expect(disk.Name).ToNot(Equal("dm-1"))
Expect(disk.Name).ToNot(Equal("dm-2"))
}
})
})
Describe("With multipath devices using /dev/dm-<n> mount format", func() {
BeforeEach(func() {
// Create a multipath device dm-3 with partitions mounted as /dev/dm-<n> instead of /dev/mapper/<name>
multipathDisk := types.Disk{
Name: "dm-3",
UUID: "mpath-dm-mount-uuid",
SizeBytes: 8 * 1024 * 1024,
Partitions: []*types.Partition{
{
Name: "dm-4",
FilesystemLabel: "DM_BOOT",
FS: "ext4",
MountPoint: "/boot",
Size: 256,
UUID: "dm-part1-uuid",
},
{
Name: "dm-5",
FilesystemLabel: "DM_DATA",
FS: "xfs",
MountPoint: "/data",
Size: 512,
UUID: "dm-part2-uuid",
},
},
}
ghwMock.AddDisk(multipathDisk)
ghwMock.CreateMultipathDevicesWithDmMounts()
})
It("Finds multipath partitions mounted as /dev/dm-<n>", func() {
disks := ghw.GetDisks(ghw.NewPaths(ghwMock.Chroot), nil)
Expect(len(disks)).To(Equal(1))
disk := disks[0]
// Should find both partitions
Expect(len(disk.Partitions)).To(Equal(2))
// Verify partitions can be found regardless of mount format
var bootPartition, dataPartition *types.Partition
for _, part := range disk.Partitions {
if part.MountPoint == "/boot" {
bootPartition = part
} else if part.MountPoint == "/data" {
dataPartition = part
}
}
Expect(bootPartition).ToNot(BeNil())
Expect(bootPartition.Name).To(Equal("dm-4"))
Expect(bootPartition.FilesystemLabel).To(Equal("DM_BOOT"))
Expect(bootPartition.MountPoint).To(Equal("/boot"))
Expect(bootPartition.FS).To(Equal("ext4"))
Expect(bootPartition.Path).To(Equal("/dev/dm-4"))
Expect(dataPartition).ToNot(BeNil())
Expect(dataPartition.Name).To(Equal("dm-5"))
Expect(dataPartition.FilesystemLabel).To(Equal("DM_DATA"))
Expect(dataPartition.MountPoint).To(Equal("/data"))
Expect(dataPartition.FS).To(Equal("xfs"))
Expect(dataPartition.Path).To(Equal("/dev/dm-5"))
})
})
Describe("With standalone multipath device (no partitions)", func() {
It("Handles multipath device with no partitions", func() {
multipathDisk := types.Disk{
Name: "dm-5",
UUID: "mpath-empty-uuid",
SizeBytes: 5 * 1024 * 1024,
}
ghwMock.AddDisk(multipathDisk)
ghwMock.CreateMultipathDevices()
disks := ghw.GetDisks(ghw.NewPaths(ghwMock.Chroot), nil)
Expect(len(disks)).To(Equal(1))
disk := disks[0]
Expect(disk.Name).To(Equal("dm-5"))
Expect(len(disk.Partitions)).To(Equal(0))
})
})
Describe("With mixed regular and multipath disks", func() {
It("Handles mixed regular and multipath disks", func() {
// Create multipath device
multipathDeviceDef := types.Disk{
Name: "dm-0",
UUID: "mpath-uuid-123",
SizeBytes: 10 * 1024 * 1024,
Partitions: []*types.Partition{
{
Name: "dm-1",
FilesystemLabel: "MPATH_BOOT",
FS: "ext4",
MountPoint: "/boot",
Size: 512,
UUID: "part1-uuid-456",
},
},
}
// Create regular disk
regularDisk := types.Disk{
Name: "sda",
UUID: "regular-uuid-999",
SizeBytes: 8 * 1024 * 1024,
Partitions: []*types.Partition{
{
Name: "sda1",
FilesystemLabel: "REGULAR_ROOT",
FS: "ext4",
MountPoint: "/",
Size: 2048,
UUID: "regular-part-uuid",
},
},
}
// Add both disks
ghwMock.AddDisk(multipathDeviceDef)
ghwMock.AddDisk(regularDisk)
// Create multipath structure (this will handle both disks appropriately)
ghwMock.CreateMultipathDevices()
disks := ghw.GetDisks(ghw.NewPaths(ghwMock.Chroot), nil)
// Should find both the regular disk and multipath device
Expect(len(disks)).To(Equal(2))
var foundMultipathDisk, foundRegularDisk *types.Disk
for _, disk := range disks {
if disk.Name == "dm-0" {
foundMultipathDisk = disk
} else if disk.Name == "sda" {
foundRegularDisk = disk
}
}
Expect(foundMultipathDisk).ToNot(BeNil())
Expect(foundRegularDisk).ToNot(BeNil())
// Verify multipath device has its partition
Expect(len(foundMultipathDisk.Partitions)).To(Equal(1))
Expect(foundMultipathDisk.Partitions[0].Name).To(Equal("dm-1"))
// Verify regular device has its partition
Expect(len(foundRegularDisk.Partitions)).To(Equal(1))
Expect(foundRegularDisk.Partitions[0].Name).To(Equal("sda1"))
})
})
}) })

View File

@@ -2,12 +2,13 @@ package mocks
import ( import (
"fmt" "fmt"
"github.com/kairos-io/kairos-sdk/ghw"
"github.com/kairos-io/kairos-sdk/types"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"github.com/kairos-io/kairos-sdk/ghw"
"github.com/kairos-io/kairos-sdk/types"
) )
// GhwMock is used to construct a fake disk to present to ghw when scanning block devices // GhwMock is used to construct a fake disk to present to ghw when scanning block devices
@@ -163,3 +164,198 @@ func (g *GhwMock) Clean() {
_ = os.Unsetenv("GHW_CHROOT") _ = os.Unsetenv("GHW_CHROOT")
_ = os.RemoveAll(g.Chroot) _ = os.RemoveAll(g.Chroot)
} }
// CreateMultipathDevicesWithDmMounts creates multipath device structure using /dev/dm-<n> mount format
// This is the same as CreateMultipathDevices but mounts partitions as /dev/dm-<n> instead of /dev/mapper/<name>
func (g *GhwMock) CreateMultipathDevicesWithDmMounts() {
g.createMultipathDevicesWithMountFormat(true)
}
// CreateMultipathDevices creates multipath device structure in the mock filesystem
// This sets up the basic dm device structure needed for multipath devices
func (g *GhwMock) CreateMultipathDevices() {
g.createMultipathDevicesWithMountFormat(false)
}
// createMultipathDevicesWithMountFormat is the common implementation
// useDmMount determines whether to use /dev/dm-<n> (true) or /dev/mapper/<name> (false) for mounts
func (g *GhwMock) createMultipathDevicesWithMountFormat(useDmMount bool) {
// Store multipath partitions before clearing them
multipathPartitions := make(map[string][]*types.Partition)
// Clear partitions from multipath devices before creating basic structure
// We'll recreate them as multipath partitions after
for i := range g.disks {
if strings.HasPrefix(g.disks[i].Name, "dm-") {
multipathPartitions[g.disks[i].Name] = g.disks[i].Partitions
g.disks[i].Partitions = nil // Clear existing partitions
}
}
// First create the basic devices (now without partitions for dm devices)
g.CreateDevices()
// Now add multipath-specific structure for dm- devices
for indexDisk, disk := range g.disks {
if strings.HasPrefix(disk.Name, "dm-") {
diskPath := filepath.Join(g.paths.SysBlock, disk.Name)
// Create dm/name file
dmDir := filepath.Join(diskPath, "dm")
_ = os.MkdirAll(dmDir, 0755)
_ = os.WriteFile(filepath.Join(dmDir, "name"), []byte(fmt.Sprintf("mpath%d", indexDisk)), 0644)
_ = os.WriteFile(filepath.Join(dmDir, "uuid"), []byte(fmt.Sprintf("mpath-%s", disk.UUID)), 0644)
// Create holders directory for partitions
holdersDir := filepath.Join(diskPath, "holders")
_ = os.MkdirAll(holdersDir, 0755)
// Create slaves directory to indicate this is a multipath device
slavesDir := filepath.Join(diskPath, "slaves")
_ = os.MkdirAll(slavesDir, 0755)
// Add some fake slave devices
_ = os.WriteFile(filepath.Join(slavesDir, "sda"), []byte(""), 0644)
_ = os.WriteFile(filepath.Join(slavesDir, "sdb"), []byte(""), 0644)
// Convert stored partitions to multipath partitions
if partitions, exists := multipathPartitions[disk.Name]; exists {
for partIndex, partition := range partitions {
g.createMultipathPartitionWithMountFormat(disk.Name, partition, partIndex+1, useDmMount)
}
}
}
}
}
// createMultipathPartitionWithMountFormat creates a multipath partition structure
// useDmMount determines the mount format: true for /dev/dm-<n>, false for /dev/mapper/<name>
func (g *GhwMock) createMultipathPartitionWithMountFormat(parentDiskName string, partition *types.Partition, partNum int, useDmMount bool) {
parentDiskPath := filepath.Join(g.paths.SysBlock, parentDiskName)
holdersDir := filepath.Join(parentDiskPath, "holders")
partitionSuffix := fmt.Sprintf("p%d", partNum)
// Create the partition as a top-level device in /sys/block/
partitionPath := filepath.Join(g.paths.SysBlock, partition.Name)
_ = os.MkdirAll(partitionPath, 0755)
// Create partition dev file (use unique device numbers)
partIndex := 100 + partNum // Ensure unique device numbers
_ = os.WriteFile(filepath.Join(partitionPath, "dev"), []byte(fmt.Sprintf("253:%d\n", partIndex)), 0644)
_ = os.WriteFile(filepath.Join(partitionPath, "size"), []byte(fmt.Sprintf("%d\n", partition.Size)), 0644)
// Create dm structure for partition
partDmDir := filepath.Join(partitionPath, "dm")
_ = os.MkdirAll(partDmDir, 0755)
_ = os.WriteFile(filepath.Join(partDmDir, "name"), []byte(fmt.Sprintf("%s%s", parentDiskName, partitionSuffix)), 0644)
_ = os.WriteFile(filepath.Join(partDmDir, "uuid"), []byte(fmt.Sprintf("part-mpath-%s", partition.UUID)), 0644)
// Create holder symlink from parent to partition
_ = os.WriteFile(filepath.Join(holdersDir, partition.Name), []byte(""), 0644)
// Create udev data for the partition with multipath-specific entries
udevData := []string{
fmt.Sprintf("E:ID_FS_LABEL=%s\n", partition.FilesystemLabel),
fmt.Sprintf("E:DM_NAME=%s%s\n", parentDiskName, partitionSuffix),
fmt.Sprintf("E:DM_PART=%d\n", partNum), // This indicates it's a multipath partition
}
if partition.FS != "" {
udevData = append(udevData, fmt.Sprintf("E:ID_FS_TYPE=%s\n", partition.FS))
}
if partition.UUID != "" {
udevData = append(udevData, fmt.Sprintf("E:ID_PART_ENTRY_UUID=%s\n", partition.UUID))
}
_ = os.WriteFile(filepath.Join(g.paths.RunUdevData, fmt.Sprintf("b253:%d", partIndex)), []byte(strings.Join(udevData, "")), 0644)
// Add mount if specified
if partition.MountPoint != "" {
if partition.FS == "" {
partition.FS = "ext4"
}
var mountDevice string
if useDmMount {
// Use /dev/dm-<n> format for mounting
mountDevice = fmt.Sprintf("/dev/%s", partition.Name)
} else {
// Use /dev/mapper/<name> format for mounting
mountDevice = fmt.Sprintf("/dev/mapper/%s%s", parentDiskName, partitionSuffix)
}
g.mounts = append(
g.mounts,
fmt.Sprintf("%s %s %s ro,relatime 0 0\n", mountDevice, partition.MountPoint, partition.FS))
// Rewrite mounts file
_ = os.WriteFile(g.paths.ProcMounts, []byte(strings.Join(g.mounts, "")), 0644)
}
}
// createMultipathPartition creates a multipath partition structure using /dev/mapper mount format
func (g *GhwMock) createMultipathPartition(parentDiskName string, partition *types.Partition, partNum int) {
g.createMultipathPartitionWithMountFormat(parentDiskName, partition, partNum, false)
}
// AddMultipathPartition adds a multipath partition to a multipath device
// This creates the partition as a holder of the parent device and sets up
// the necessary dm structure for the partition
func (g *GhwMock) AddMultipathPartition(parentDiskName string, partition *types.Partition) {
if g.paths == nil {
return // Must call CreateMultipathDevices first
}
parentDiskPath := filepath.Join(g.paths.SysBlock, parentDiskName)
holdersDir := filepath.Join(parentDiskPath, "holders")
// Count existing holders to determine partition number
existingHolders, _ := os.ReadDir(holdersDir)
partNum := len(existingHolders) + 1
partitionSuffix := fmt.Sprintf("p%d", partNum)
// Create the partition as a top-level device in /sys/block/
partitionPath := filepath.Join(g.paths.SysBlock, partition.Name)
_ = os.MkdirAll(partitionPath, 0755)
// Create partition dev file (use unique device numbers)
partIndex := len(g.mounts) + 100 + partNum // Ensure unique device numbers
_ = os.WriteFile(filepath.Join(partitionPath, "dev"), []byte(fmt.Sprintf("253:%d\n", partIndex)), 0644)
_ = os.WriteFile(filepath.Join(partitionPath, "size"), []byte(fmt.Sprintf("%d\n", partition.Size)), 0644)
// Create dm structure for partition
partDmDir := filepath.Join(partitionPath, "dm")
_ = os.MkdirAll(partDmDir, 0755)
_ = os.WriteFile(filepath.Join(partDmDir, "name"), []byte(fmt.Sprintf("%s%s", parentDiskName, partitionSuffix)), 0644)
_ = os.WriteFile(filepath.Join(partDmDir, "uuid"), []byte(fmt.Sprintf("part-mpath-%s", partition.UUID)), 0644)
// Create holder symlink from parent to partition
_ = os.WriteFile(filepath.Join(holdersDir, partition.Name), []byte(""), 0644)
// Create udev data for the partition with multipath-specific entries
udevData := []string{
fmt.Sprintf("E:ID_FS_LABEL=%s\n", partition.FilesystemLabel),
fmt.Sprintf("E:DM_NAME=%s%s\n", parentDiskName, partitionSuffix),
fmt.Sprintf("E:DM_PART=%d\n", partNum), // This indicates it's a multipath partition
}
if partition.FS != "" {
udevData = append(udevData, fmt.Sprintf("E:ID_FS_TYPE=%s\n", partition.FS))
}
if partition.UUID != "" {
udevData = append(udevData, fmt.Sprintf("E:ID_PART_ENTRY_UUID=%s\n", partition.UUID))
}
_ = os.WriteFile(filepath.Join(g.paths.RunUdevData, fmt.Sprintf("b253:%d", partIndex)), []byte(strings.Join(udevData, "")), 0644)
// Add mount if specified
if partition.MountPoint != "" {
if partition.FS == "" {
partition.FS = "ext4"
}
// For multipath partitions, they can be mounted by /dev/mapper/ name or /dev/dm- name
g.mounts = append(
g.mounts,
fmt.Sprintf("/dev/mapper/%s%s %s %s ro,relatime 0 0\n", parentDiskName, partitionSuffix, partition.MountPoint, partition.FS))
}
// Rewrite mounts file
_ = os.WriteFile(g.paths.ProcMounts, []byte(strings.Join(g.mounts, "")), 0644)
}

View File

@@ -0,0 +1,107 @@
package ghw
import (
"os"
"path/filepath"
"github.com/kairos-io/kairos-sdk/types"
)
type MultipathPartitionHandler struct {
DiskName string
}
func NewMultipathPartitionHandler(diskName string) *MultipathPartitionHandler {
return &MultipathPartitionHandler{DiskName: diskName}
}
var _ PartitionHandler = &MultipathPartitionHandler{}
func (m *MultipathPartitionHandler) GetPartitions(paths *Paths, logger *types.KairosLogger) types.PartitionList {
out := make(types.PartitionList, 0)
// For multipath devices, partitions appear as holders of the parent device
// in /sys/block/<disk>/holders/<holder>
holdersPath := filepath.Join(paths.SysBlock, m.DiskName, "holders")
logger.Logger.Debug().Str("path", holdersPath).Msg("Reading multipath holders")
holders, err := os.ReadDir(holdersPath)
if err != nil {
logger.Logger.Error().Err(err).Msg("failed to read holders directory")
return out
}
// Find all multipath partitions by checking each holder
for _, holder := range holders {
partName := holder.Name()
// Only consider dm- devices as potential multipath partitions
if !isMultipathDevice(holder) {
logger.Logger.Debug().Str("path", holder.Name()).Msg("Is not a multipath device")
continue
}
// Verify this holder is actually a multipath partition
// We can use the holder DirEntry directly - no need to search for it!
if !isMultipathPartition(holder, paths, logger) {
logger.Logger.Debug().Str("partition", partName).Msg("Holder is not a multipath partition")
continue
}
logger.Logger.Debug().Str("partition", partName).Msg("Found multipath partition")
udevInfo, err := udevInfoPartition(paths, partName, logger)
if err != nil {
logger.Logger.Error().Err(err).Str("devNo", partName).Msg("Failed to get udev info")
return out
}
mapperName, ok := udevInfo["DM_NAME"]
if !ok {
logger.Logger.Error().Str("devNo", partName).Msg("DM_NAME not found in udev info")
continue
}
// For multipath partitions, we need to get size directly from the partition device
// since it's a top-level entry in /sys/block, not nested under the parent
size := partitionSizeBytes(paths, partName, logger)
du := diskPartUUID(paths, partName, logger)
// The mount point is usually the same as the mapper name
// however you can also mount it as /dev/dm-<n> or /dev/mapper/<mapperName>
// so we need to check both
potentialMountNames := []string{
filepath.Join("/dev/mapper", mapperName),
filepath.Join("/dev", partName),
}
// Search for the mount point in the system
var mp, pt string
for _, mountName := range potentialMountNames {
mp, pt = partitionInfo(paths, mountName, logger)
if mp != "" {
logger.Logger.Debug().Str("mountPoint", mp).Msg("Found mount point for partition")
break
}
}
if pt == "" {
pt = diskPartTypeUdev(paths, partName, logger)
}
fsLabel := diskFSLabel(paths, partName, logger)
p := &types.Partition{
Name: partName,
Size: uint(size / (1024 * 1024)),
MountPoint: mp,
UUID: du,
FilesystemLabel: fsLabel,
FS: pt,
Path: filepath.Join("/dev", partName),
Disk: filepath.Join("/dev", m.DiskName),
}
out = append(out, p)
}
return out
}

202
ghw/partitions.go Normal file
View File

@@ -0,0 +1,202 @@
package ghw
import (
"bufio"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/kairos-io/kairos-sdk/types"
)
type PartitionHandler interface {
// GetPartitions returns a list of partitions on the system.
GetPartitions(paths *Paths, logger *types.KairosLogger) types.PartitionList
}
func partitionSizeBytes(paths *Paths, partitionPath string, logger *types.KairosLogger) uint64 {
path := filepath.Join(paths.SysBlock, partitionPath, "size")
logger.Logger.Debug().Str("file", path).Msg("Reading size file")
contents, err := os.ReadFile(path)
if err != nil {
logger.Logger.Error().Str("file", path).Err(err).Msg("failed to read disk partition size")
return 0
}
size, err := strconv.ParseUint(strings.TrimSpace(string(contents)), 10, 64)
if err != nil {
logger.Logger.Error().Str("contents", string(contents)).Err(err).Msg("failed to parse disk partition size")
return 0
}
logger.Logger.Trace().Str("partition", partitionPath).Uint64("size", size*sectorSize).Msg("Got partition size")
return size * sectorSize
}
func partitionInfo(paths *Paths, part string, logger *types.KairosLogger) (string, string) {
// Allow calling PartitionInfo with either the full partition name
// "/dev/sda1" or just "sda1"
if !strings.HasPrefix(part, "/dev") {
part = "/dev/" + part
}
// mount entries for mounted partitions look like this:
// /dev/sda6 / ext4 rw,relatime,errors=remount-ro,data=ordered 0 0
var r io.ReadCloser
logger.Logger.Debug().Str("file", paths.ProcMounts).Msg("Reading mounts file")
r, err := os.Open(paths.ProcMounts)
if err != nil {
logger.Logger.Error().Str("file", paths.ProcMounts).Err(err).Msg("failed to open mounts")
return "", ""
}
defer r.Close()
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
logger.Logger.Trace().Str("line", line).Msg("Parsing mount info")
entry := parseMountEntry(line, logger)
if entry == nil || entry.Partition != part {
continue
}
return entry.Mountpoint, entry.FilesystemType
}
return "", ""
}
type mountEntry struct {
Partition string
Mountpoint string
FilesystemType string
}
func parseMountEntry(line string, logger *types.KairosLogger) *mountEntry {
// mount entries for mounted partitions look like this:
// /dev/sda6 / ext4 rw,relatime,errors=remount-ro,data=ordered 0 0
if line[0] != '/' {
return nil
}
fields := strings.Fields(line)
if len(fields) < 4 {
logger.Logger.Debug().Interface("fields", fields).Msg("Mount line has less than 4 fields")
return nil
}
// We do some special parsing of the mountpoint, which may contain space,
// tab and newline characters, encoded into the mount entry line using their
// octal-to-string representations. From the GNU mtab man pages:
//
// "Therefore these characters are encoded in the files and the getmntent
// function takes care of the decoding while reading the entries back in.
// '\040' is used to encode a space character, '\011' to encode a tab
// character, '\012' to encode a newline character, and '\\' to encode a
// backslash."
mp := fields[1]
r := strings.NewReplacer(
"\\011", "\t", "\\012", "\n", "\\040", " ", "\\\\", "\\",
)
mp = r.Replace(mp)
res := &mountEntry{
Partition: fields[0],
Mountpoint: mp,
FilesystemType: fields[2],
}
return res
}
func diskUUID(paths *Paths, partitionPath string, logger *types.KairosLogger) string {
info, err := udevInfoPartition(paths, partitionPath, logger)
logger.Logger.Trace().Interface("info", info).Msg("Disk UUID")
if err != nil {
logger.Logger.Error().Str("partition", partitionPath).Interface("info", info).Err(err).Msg("failed to read disk UUID")
return UNKNOWN
}
if pType, ok := info["ID_PART_TABLE_UUID"]; ok {
logger.Logger.Trace().Str("disk", partitionPath).Str("partition", partitionPath).Str("uuid", pType).Msg("Got disk uuid")
return pType
}
return UNKNOWN
}
func diskPartUUID(paths *Paths, partitionPath string, logger *types.KairosLogger) string {
info, err := udevInfoPartition(paths, partitionPath, logger)
logger.Logger.Trace().Interface("info", info).Msg("Disk Part UUID")
if err != nil {
logger.Logger.Error().Str("partition", partitionPath).Interface("info", info).Err(err).Msg("Disk Part UUID")
return UNKNOWN
}
if pType, ok := info["ID_PART_ENTRY_UUID"]; ok {
logger.Logger.Trace().Str("partition", partitionPath).Str("uuid", pType).Msg("Got partition uuid")
return pType
}
return UNKNOWN
}
// diskPartTypeUdev gets the partition type from the udev database directly and its only used as fallback when
// the partition is not mounted, so we cannot get the type from paths.ProcMounts from the partitionInfo function
func diskPartTypeUdev(paths *Paths, partitionPath string, logger *types.KairosLogger) string {
info, err := udevInfoPartition(paths, partitionPath, logger)
logger.Logger.Trace().Interface("info", info).Msg("Disk Part Type")
if err != nil {
logger.Logger.Error().Str("partition", partitionPath).Interface("info", info).Err(err).Msg("Disk Part Type")
return UNKNOWN
}
if pType, ok := info["ID_FS_TYPE"]; ok {
logger.Logger.Trace().Str("partition", partitionPath).Str("FS", pType).Msg("Got partition fs type")
return pType
}
return UNKNOWN
}
func diskFSLabel(paths *Paths, partitionPath string, logger *types.KairosLogger) string {
info, err := udevInfoPartition(paths, partitionPath, logger)
logger.Logger.Trace().Interface("info", info).Msg("Disk FS label")
if err != nil {
logger.Logger.Error().Str("partition", partitionPath).Interface("info", info).Err(err).Msg("Disk FS label")
return UNKNOWN
}
if label, ok := info["ID_FS_LABEL"]; ok {
logger.Logger.Trace().Str("partition", partitionPath).Str("uuid", label).Msg("Got partition label")
return label
}
return UNKNOWN
}
func udevInfoPartition(paths *Paths, partitionPath string, logger *types.KairosLogger) (map[string]string, error) {
// Get device major:minor numbers
devNo, err := os.ReadFile(filepath.Join(paths.SysBlock, partitionPath, "dev"))
if err != nil {
logger.Logger.Error().Err(err).Str("path", filepath.Join(paths.SysBlock, partitionPath, "dev")).Msg("failed to read udev info")
return nil, err
}
return UdevInfo(paths, string(devNo), logger)
}
// UdevInfo will return information on udev database about a device number
func UdevInfo(paths *Paths, devNo string, logger *types.KairosLogger) (map[string]string, error) {
// Look up block device in udev runtime database
udevID := "b" + strings.TrimSpace(devNo)
udevBytes, err := os.ReadFile(filepath.Join(paths.RunUdevData, udevID))
if err != nil {
logger.Logger.Error().Err(err).Str("path", filepath.Join(paths.RunUdevData, udevID)).Msg("failed to read udev info for device")
return nil, err
}
udevInfo := make(map[string]string)
for _, udevLine := range strings.Split(string(udevBytes), "\n") {
if strings.HasPrefix(udevLine, "E:") {
if s := strings.SplitN(udevLine[2:], "=", 2); len(s) == 2 {
udevInfo[s[0]] = s[1]
}
}
}
return udevInfo, nil
}