From a1cf15cc9d6af4a796432dfc6e2e69e8a857cde9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Budai?= Date: Fri, 20 Dec 2024 14:14:50 +0100 Subject: [PATCH 1/2] blueprint: support specifying partition GUIDs People need to customize partitition types/GUIDs, so this commit adds this option to the blueprint. It's technically a partition type for DOS partitions, but the key type is already taken, so let's go with guid both for DOS and GPT. --- pkg/blueprint/disk_customizations.go | 51 +++++++++++++++++++-- pkg/blueprint/disk_customizations_test.go | 56 +++++++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/pkg/blueprint/disk_customizations.go b/pkg/blueprint/disk_customizations.go index 16120a7307..df04a8e884 100644 --- a/pkg/blueprint/disk_customizations.go +++ b/pkg/blueprint/disk_customizations.go @@ -6,9 +6,11 @@ import ( "errors" "fmt" "path/filepath" + "regexp" "slices" "strings" + "github.com/google/uuid" "github.com/osbuild/images/pkg/datasizes" "github.com/osbuild/images/pkg/pathpolicy" ) @@ -64,6 +66,15 @@ type PartitionCustomization struct { // (optional, defaults depend on payload and mountpoints). MinSize uint64 `json:"minsize" toml:"minsize"` + // The partition type GUID for GPT partitions. For DOS partitions, this + // field can be used to set the partition type (e.g. "swap"). + // If not set, the code will make the best guess based on the mountpoint or + // the payload type. + // Examples: + // 3B8F8425-20E0-4F3B-907F-1A25A76F98E8 (/srv on GPT) + // 06 (FAT16 on DOS) + GUID string `json:"guid,omitempty" toml:"guid,omitempty"` + BtrfsVolumeCustomization VGCustomization @@ -156,6 +167,7 @@ func (v *PartitionCustomization) UnmarshalJSON(data []byte) error { var typeSniffer struct { Type string `json:"type"` MinSize any `json:"minsize"` + GUID string `json:"guid"` } if err := json.Unmarshal(data, &typeSniffer); err != nil { return fmt.Errorf("%s %w", errPrefix, err) @@ -184,6 +196,7 @@ func (v *PartitionCustomization) UnmarshalJSON(data []byte) error { } v.Type = partType + v.GUID = typeSniffer.GUID if typeSniffer.MinSize == nil { return fmt.Errorf("minsize is required") @@ -203,10 +216,11 @@ func (v *PartitionCustomization) UnmarshalJSON(data []byte) error { // the type is "plain", none of the fields for btrfs or lvm are used. func decodePlain(v *PartitionCustomization, data []byte) error { var plain struct { - // Type and minsize are handled by the caller. These are added here to + // Type, minsize and guid are handled by the caller. These are added here to // satisfy "DisallowUnknownFields" when decoding. Type string `json:"type"` MinSize any `json:"minsize"` + GUID string `json:"guid"` FilesystemTypedCustomization } @@ -226,10 +240,11 @@ func decodePlain(v *PartitionCustomization, data []byte) error { // the type is btrfs, none of the fields for plain or lvm are used. func decodeBtrfs(v *PartitionCustomization, data []byte) error { var btrfs struct { - // Type and minsize are handled by the caller. These are added here to + // Type, minsize and guid are handled by the caller. These are added here to // satisfy "DisallowUnknownFields" when decoding. Type string `json:"type"` MinSize any `json:"minsize"` + GUID string `json:"guid"` BtrfsVolumeCustomization } @@ -249,10 +264,11 @@ func decodeBtrfs(v *PartitionCustomization, data []byte) error { // is lvm, none of the fields for plain or btrfs are used. func decodeLVM(v *PartitionCustomization, data []byte) error { var vg struct { - // Type and minsize are handled by the caller. These are added here to + // Type, minsize and guid are handled by the caller. These are added here to // satisfy "DisallowUnknownFields" when decoding. Type string `json:"type"` MinSize any `json:"minsize"` + GUID string `json:"guid"` VGCustomization } @@ -367,6 +383,9 @@ func (p *DiskCustomization) Validate() error { vgnames := make(map[string]bool) var errs []error for _, part := range p.Partitions { + if err := part.validateGeneric(); err != nil { + errs = append(errs, err) + } switch part.Type { case "plain", "": errs = append(errs, part.validatePlain(mountpoints)) @@ -471,6 +490,32 @@ var validPlainFSTypes = []string{ "xfs", } +// exactly 2 hex digits +var validDosPartitionType = regexp.MustCompile(`^[0-9a-fA-F]{2}$`) + +// validateGeneric checks the partition validity regardless of its type. +// Currently, it only checks the GUID field. +func (p *PartitionCustomization) validateGeneric() error { + // Empty GUID is fine, the code will auto-generate it later. + if p.GUID == "" { + return nil + } + + // We don't know the partition table type yet, so check: + + // 1) the partition GUID is either a valid UUID (for GPT) + if _, err := uuid.Parse(p.GUID); err == nil { + return nil + } + + // 2) or a valid DOS partition type (for MBR) + if validDosPartitionType.MatchString(p.GUID) { + return nil + } + + return fmt.Errorf("invalid partition GUID: %q (use UUIDs for GPT partition tables, or 2-digit hex numbers for DOS partition tables)", p.GUID) +} + func (p *PartitionCustomization) validatePlain(mountpoints map[string]bool) error { if p.FSType == "swap" { // make sure the mountpoint is empty and return diff --git a/pkg/blueprint/disk_customizations_test.go b/pkg/blueprint/disk_customizations_test.go index 70a1a0dcd3..ce7e01bc75 100644 --- a/pkg/blueprint/disk_customizations_test.go +++ b/pkg/blueprint/disk_customizations_test.go @@ -970,6 +970,48 @@ func TestPartitioningValidation(t *testing.T) { }, expectedMsg: `invalid partitioning customizations: "dos" partition table type only supports up to 4 partitions: got 6`, }, + "happy-partition-guids": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + GUID: "12345678-1234-1234-1234-1234567890ab", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/gpt", + }, + }, + { + GUID: "ef", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/dos", + }, + }, + }, + }, + }, + "unhappy-partition-guids": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + + { + GUID: "12345678-uuid-1234-1234-1234567890ab", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/gpt", + }, + }, + { + GUID: "0x52", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/dos", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\ninvalid partition GUID: \"12345678-uuid-1234-1234-1234567890ab\" (use UUIDs for GPT partition tables, or 2-digit hex numbers for DOS partition tables)\ninvalid partition GUID: \"0x52\" (use UUIDs for GPT partition tables, or 2-digit hex numbers for DOS partition tables)", + }, } for name := range testCases { @@ -1187,6 +1229,7 @@ func TestPartitionCustomizationUnmarshalJSON(t *testing.T) { input: `{ "type": "plain", "minsize": "1 GiB", + "guid": "12345678-1234-1234-1234-1234567890ab", "mountpoint": "/", "label": "root", "fs_type": "xfs" @@ -1194,6 +1237,7 @@ func TestPartitionCustomizationUnmarshalJSON(t *testing.T) { expected: &blueprint.PartitionCustomization{ Type: "plain", MinSize: 1 * datasizes.GiB, + GUID: "12345678-1234-1234-1234-1234567890ab", FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ Mountpoint: "/", Label: "root", @@ -1223,6 +1267,7 @@ func TestPartitionCustomizationUnmarshalJSON(t *testing.T) { input: `{ "type": "btrfs", "minsize": "10 GiB", + "guid": "12345678-1234-1234-1234-1234567890ab", "subvolumes": [ { "name": "subvols/root", @@ -1237,6 +1282,7 @@ func TestPartitionCustomizationUnmarshalJSON(t *testing.T) { expected: &blueprint.PartitionCustomization{ Type: "btrfs", MinSize: 10 * datasizes.GiB, + GUID: "12345678-1234-1234-1234-1234567890ab", BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ { @@ -1288,6 +1334,7 @@ func TestPartitionCustomizationUnmarshalJSON(t *testing.T) { "type": "lvm", "name": "myvg", "minsize": "99 GiB", + "guid": "12345678-1234-1234-1234-1234567890ab", "logical_volumes": [ { "name": "homelv", @@ -1308,6 +1355,7 @@ func TestPartitionCustomizationUnmarshalJSON(t *testing.T) { expected: &blueprint.PartitionCustomization{ Type: "lvm", MinSize: 99 * datasizes.GiB, + GUID: "12345678-1234-1234-1234-1234567890ab", VGCustomization: blueprint.VGCustomization{ Name: "myvg", LogicalVolumes: []blueprint.LVCustomization{ @@ -1399,6 +1447,14 @@ func TestPartitionCustomizationUnmarshalJSON(t *testing.T) { }`, errorMsg: "JSON unmarshal: error decoding minsize for partition: cannot be negative", }, + "guid-not-string": { + input: `{ + "minsize": "10 GiB", + "mountpoint": "/", + "guid": 12345678 + }`, + errorMsg: "JSON unmarshal: json: cannot unmarshal number into Go struct field .guid of type string", + }, "wrong-type/btrfs-with-lvm": { input: `{ "type": "btrfs", From fb31aafd08162a6a1366ce498aa874ad654bd92d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Budai?= Date: Fri, 20 Dec 2024 14:40:35 +0100 Subject: [PATCH 2/2] disk: add support for custom partition GUIDs This commit takes the blueprint customization added in the last commit, and makes it actually useful. --- pkg/disk/disk.go | 21 ++++++++++ pkg/disk/partition_table.go | 68 ++++++++++++++++++++++---------- pkg/disk/partition_table_test.go | 48 +++++++++++++++++++++- 3 files changed, 115 insertions(+), 22 deletions(-) diff --git a/pkg/disk/disk.go b/pkg/disk/disk.go index 9844e701dd..345aaf0bf1 100644 --- a/pkg/disk/disk.go +++ b/pkg/disk/disk.go @@ -24,6 +24,7 @@ import ( "io" "math/rand" "reflect" + "regexp" "strings" "slices" @@ -120,6 +121,26 @@ func getPartitionTypeIDfor(ptType PartitionTableType, partTypeName string) (stri return id, nil } +// exactly 2 hex digits +var validDosPartitionType = regexp.MustCompile(`^[0-9a-fA-F]{2}$`) + +func validatePartitionTypeID(ptType PartitionTableType, partTypeName string) error { + switch ptType { + case PT_DOS: + if !validDosPartitionType.MatchString(partTypeName) { + return fmt.Errorf("invalid dos partition type ID: %s", partTypeName) + } + case PT_GPT: + if _, err := uuid.Parse(partTypeName); err != nil { + return fmt.Errorf("invalid gpt partition type GUID: %s", partTypeName) + } + default: + return fmt.Errorf("unknown or unsupported partition table enum: %d", ptType) + } + + return nil +} + // FSType is the filesystem type enum. // // There should always be one value for each filesystem type supported by diff --git a/pkg/disk/partition_table.go b/pkg/disk/partition_table.go index 44bed061eb..7549ee2deb 100644 --- a/pkg/disk/partition_table.go +++ b/pkg/disk/partition_table.go @@ -1313,24 +1313,35 @@ func addPlainPartition(pt *PartitionTable, partition blueprint.PartitionCustomiz return fmt.Errorf("error creating partition with mountpoint %q: %w", partition.Mountpoint, err) } - // all user-defined partitions are data partitions except boot and swap - var typeName string - switch { - case partition.Mountpoint == "/boot": - typeName = "boot" - case fstype == "swap": - typeName = "swap" - default: - typeName = "data" - } + partType := partition.GUID - partType, err := getPartitionTypeIDfor(pt.Type, typeName) - if err != nil { - return fmt.Errorf("error getting partition type ID for %q: %w", partition.Mountpoint, err) + if partType != "" { + if err := validatePartitionTypeID(pt.Type, partType); err != nil { + return fmt.Errorf("error validating partition type ID for %q: %w", partition.Mountpoint, err) + } + } else { + // if the partition type is not specified, determine it based on the + // mountpoint and the partition type + + // all user-defined partitions are data partitions except boot and swap + var typeName string + switch { + case partition.Mountpoint == "/boot": + typeName = "boot" + case fstype == "swap": + typeName = "swap" + default: + typeName = "data" + } + + partType, err = getPartitionTypeIDfor(pt.Type, typeName) + if err != nil { + return fmt.Errorf("error getting partition type ID for %q: %w", partition.Mountpoint, err) + } } var payload PayloadEntity - switch typeName { + switch fstype { case "swap": payload = &Swap{ Label: partition.Label, @@ -1408,10 +1419,19 @@ func addLVMPartition(pt *PartitionTable, partition blueprint.PartitionCustomizat } // create partition for volume group - partType, err := getPartitionTypeIDfor(pt.Type, "lvm") - if err != nil { - return fmt.Errorf("error creating lvm partition %q: %w", vgname, err) + partType := partition.GUID + if partType != "" { + if err := validatePartitionTypeID(pt.Type, partType); err != nil { + return fmt.Errorf("error validating partition type ID for %q: %w", vgname, err) + } + } else { + var err error + partType, err = getPartitionTypeIDfor(pt.Type, "lvm") + if err != nil { + return fmt.Errorf("error creating lvm partition %q: %w", vgname, err) + } } + newpart := Partition{ Type: partType, Size: partition.MinSize, @@ -1437,9 +1457,17 @@ func addBtrfsPartition(pt *PartitionTable, partition blueprint.PartitionCustomiz } // create partition for btrfs volume - partType, err := getPartitionTypeIDfor(pt.Type, "data") - if err != nil { - return fmt.Errorf("error creating btrfs partition: %w", err) + partType := partition.GUID + if partType != "" { + if err := validatePartitionTypeID(pt.Type, partType); err != nil { + return fmt.Errorf("error validating partition type ID for btrfs: %w", err) + } + } else { + var err error + partType, err = getPartitionTypeIDfor(pt.Type, "data") + if err != nil { + return fmt.Errorf("error creating btrfs partition: %w", err) + } } newpart := Partition{ Type: partType, diff --git a/pkg/disk/partition_table_test.go b/pkg/disk/partition_table_test.go index c198984764..66f547aa9f 100644 --- a/pkg/disk/partition_table_test.go +++ b/pkg/disk/partition_table_test.go @@ -1187,6 +1187,7 @@ func TestNewCustomPartitionTable(t *testing.T) { Partitions: []blueprint.PartitionCustomization{ { MinSize: 20 * datasizes.MiB, + GUID: "42", // overrides the inferred type FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ Mountpoint: "/data", Label: "data", @@ -1230,7 +1231,7 @@ func TestNewCustomPartitionTable(t *testing.T) { { Start: 202 * datasizes.MiB, Size: 20 * datasizes.MiB, - Type: disk.FilesystemLinuxDOSID, + Type: "42", Bootable: false, UUID: "", // partitions on dos PTs don't have UUIDs Payload: &disk.Filesystem{ @@ -1267,6 +1268,7 @@ func TestNewCustomPartitionTable(t *testing.T) { Partitions: []blueprint.PartitionCustomization{ { MinSize: 20 * datasizes.MiB, + GUID: "01234567-89ab-cdef-0123-456789abcdef", // overrides the inferred type FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ Mountpoint: "/data", Label: "data", @@ -1310,7 +1312,7 @@ func TestNewCustomPartitionTable(t *testing.T) { { Start: 202 * datasizes.MiB, Size: 20 * datasizes.MiB, - Type: disk.FilesystemDataGUID, + Type: "01234567-89ab-cdef-0123-456789abcdef", Bootable: false, UUID: "a178892e-e285-4ce1-9114-55780875d64e", Payload: &disk.Filesystem{ @@ -2640,6 +2642,48 @@ func TestNewCustomPartitionTableErrors(t *testing.T) { }, errmsg: `error generating partition table: invalid partition table: "dos" partition table type only supports up to 4 partitions: got 5 after creating the partition table with all necessary partitions`, }, + "bad-guid-dos": { + customizations: &blueprint.DiskCustomization{ + Type: "dos", + Partitions: []blueprint.PartitionCustomization{ + { + MinSize: 20 * datasizes.MiB, + GUID: "01234567-89ab-cdef-0123-456789abcdef", // dos cannot use UUIDs + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/data", + Label: "data", + FSType: "ext4", + }, + }, + }, + }, + options: &disk.CustomPartitionTableOptions{ + DefaultFSType: disk.FS_XFS, + BootMode: platform.BOOT_HYBRID, + }, + errmsg: `error generating partition table: error validating partition type ID for "/data": invalid dos partition type ID: 01234567-89ab-cdef-0123-456789abcdef`, + }, + "bad-guid-gpt": { + customizations: &blueprint.DiskCustomization{ + Type: "gpt", + Partitions: []blueprint.PartitionCustomization{ + { + MinSize: 20 * datasizes.MiB, + GUID: "EF", // gpt requires a 36-character GUID + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/data", + Label: "data", + FSType: "ext4", + }, + }, + }, + }, + options: &disk.CustomPartitionTableOptions{ + DefaultFSType: disk.FS_XFS, + BootMode: platform.BOOT_HYBRID, + }, + errmsg: `error generating partition table: error validating partition type ID for "/data": invalid gpt partition type GUID: EF`, + }, } // we don't care about the rng for error tests