diff --git a/pkg/blueprint/disk_customizations.go b/pkg/blueprint/disk_customizations.go index f5a06dc68e..fe150483ed 100644 --- a/pkg/blueprint/disk_customizations.go +++ b/pkg/blueprint/disk_customizations.go @@ -9,6 +9,7 @@ import ( "regexp" "slices" "strings" + "unicode/utf16" "github.com/google/uuid" "github.com/osbuild/images/pkg/datasizes" @@ -72,6 +73,14 @@ type PartitionCustomization struct { // or the payload type. PartType string `json:"part_type,omitempty" toml:"part_type,omitempty"` + // The partition label for GPT partitions, not supported for dos partitions. + // Note: This is not the same as the label, which can be set in "Label" + PartLabel string `json:"part_label,omitempty" toml:"part_label,omitempty"` + + // The partition GUID for GPT partitions, not supported for dos partitions. + // Note: This is the unique uuid, not the type guid, that is PartType + PartUUID string `json:"part_uuid,omitempty" toml:"part_uuid,omitempty"` + BtrfsVolumeCustomization VGCustomization @@ -162,9 +171,11 @@ type BtrfsSubvolumeCustomization struct { func (v *PartitionCustomization) UnmarshalJSON(data []byte) error { errPrefix := "JSON unmarshal:" var typeSniffer struct { - Type string `json:"type"` - MinSize any `json:"minsize"` - PartType string `json:"part_type"` + Type string `json:"type"` + MinSize any `json:"minsize"` + PartType string `json:"part_type"` + PartLabel string `json:"part_label"` + PartUUID string `json:"part_uuid"` } if err := json.Unmarshal(data, &typeSniffer); err != nil { return fmt.Errorf("%s %w", errPrefix, err) @@ -194,6 +205,8 @@ func (v *PartitionCustomization) UnmarshalJSON(data []byte) error { v.Type = partType v.PartType = typeSniffer.PartType + v.PartLabel = typeSniffer.PartLabel + v.PartUUID = typeSniffer.PartUUID if typeSniffer.MinSize == nil { return fmt.Errorf("minsize is required") @@ -213,11 +226,13 @@ 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, minsize, and part_type are handled by the caller. These are added here to + // Type, minsize, and part_* are handled by the caller. These are added here to // satisfy "DisallowUnknownFields" when decoding. - Type string `json:"type"` - MinSize any `json:"minsize"` - PartType string `json:"part_type"` + Type string `json:"type"` + MinSize any `json:"minsize"` + PartType string `json:"part_type"` + PartLabel string `json:"part_label"` + PartUUID string `json:"part_uuid"` FilesystemTypedCustomization } @@ -237,11 +252,13 @@ 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, minsize, and part_type are handled by the caller. These are added here to + // Type, minsize, and part_* are handled by the caller. These are added here to // satisfy "DisallowUnknownFields" when decoding. - Type string `json:"type"` - MinSize any `json:"minsize"` - PartType string `json:"part_type"` + Type string `json:"type"` + MinSize any `json:"minsize"` + PartType string `json:"part_type"` + PartLabel string `json:"part_label"` + PartUUID string `json:"part_uuid"` BtrfsVolumeCustomization } @@ -261,11 +278,13 @@ 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, minsize, and part_type are handled by the caller. These are added here to + // Type, minsize, and part_* are handled by the caller. These are added here to // satisfy "DisallowUnknownFields" when decoding. - Type string `json:"type"` - MinSize any `json:"minsize"` - PartType string `json:"part_type"` + Type string `json:"type"` + MinSize any `json:"minsize"` + PartType string `json:"part_type"` + PartLabel string `json:"part_label"` + PartUUID string `json:"part_uuid"` VGCustomization } @@ -383,6 +402,12 @@ func (p *DiskCustomization) Validate() error { if err := part.ValidatePartitionTypeID(p.Type); err != nil { errs = append(errs, err) } + if err := part.ValidatePartitionID(p.Type); err != nil { + errs = append(errs, err) + } + if err := part.ValidatePartitionLabel(p.Type); err != nil { + errs = append(errs, err) + } switch part.Type { case "plain", "": errs = append(errs, part.validatePlain(mountpoints)) @@ -525,6 +550,48 @@ func (p *PartitionCustomization) ValidatePartitionTypeID(ptType string) error { return nil } +// ValidatePartitionID returns an error if the partition ID is not +// valid given the partition table type. If the partition table type is an +// empty string, the function returns an error only if the partition type ID is +// invalid for both gpt and dos partition tables. +func (p *PartitionCustomization) ValidatePartitionID(ptType string) error { + // Empty PartUUID is fine, it will be selected automatically if needed + if p.PartUUID == "" { + return nil + } + + if ptType == "dos" { + return fmt.Errorf("part_type is not supported for dos partition tables") + } + + _, uuidErr := uuid.Parse(p.PartUUID) + if uuidErr != nil { + return fmt.Errorf("invalid partition part_uuid %q (must be a valid UUID): %w", p.PartUUID, uuidErr) + } + + return nil +} + +// ValidatePartitionID returns an error if the partition ID is not +// valid given the partition table type. +func (p *PartitionCustomization) ValidatePartitionLabel(ptType string) error { + // Empty PartLabel is fine + if p.PartLabel == "" { + return nil + } + + if ptType == "dos" { + return fmt.Errorf("part_label is not supported for dos partition tables") + } + + // GPT Labels are up to 36 utf-16 chars + if len(utf16.Encode([]rune(p.PartLabel))) > 36 { + return fmt.Errorf("part_label is not a valid GPT label, it is too long") + } + + return nil +} + 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 74060af861..2ef3b82e28 100644 --- a/pkg/blueprint/disk_customizations_test.go +++ b/pkg/blueprint/disk_customizations_test.go @@ -1055,6 +1055,103 @@ func TestPartitioningValidation(t *testing.T) { }, expectedMsg: "invalid partitioning customizations:\ninvalid partition part_type \"93a9549d-cae1-4024-b95c-e09d77b34c60\" for partition table type \"dos\" (must be a 2-digit hex number)", }, + "happy-partition-part_uuid": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + PartUUID: "12345678-1234-1234-1234-1234567890ab", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + }, + }, + }, + "unhappy-partition-part_uuid-dos": { + partitioning: &blueprint.DiskCustomization{ + Type: "dos", + Partitions: []blueprint.PartitionCustomization{ + { + PartUUID: "12345678-1234-1234-1234-1234567890ab", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\npart_type is not supported for dos partition tables", + }, + "unhappy-partition-part_": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + PartUUID: "12345678-", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\ninvalid partition part_uuid \"12345678-\" (must be a valid UUID): invalid UUID length: 9", + }, + "happy-partition-part_label": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + PartLabel: "TheLabel", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + }, + }, + }, + "unhappy-partition-part_label-dos": { + partitioning: &blueprint.DiskCustomization{ + Type: "dos", + Partitions: []blueprint.PartitionCustomization{ + { + PartLabel: "TheLabel", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\npart_label is not supported for dos partition tables", + }, + "happy-partition-part_label-long": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + PartLabel: "123456789012345678901234567890123456", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + }, + }, + }, + "unhappy-partition-part_label-long": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + PartLabel: "1234567890123456789012345678901234567", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\npart_label is not a valid GPT label, it is too long", + }, } for name := range testCases { diff --git a/pkg/disk/partition.go b/pkg/disk/partition.go index 8d40b054ec..2c1b83ecc6 100644 --- a/pkg/disk/partition.go +++ b/pkg/disk/partition.go @@ -21,6 +21,9 @@ type Partition struct { // is just a string. UUID string `json:"uuid,omitempty" yaml:"uuid,omitempty"` + // Partition name (not filesystem label), only supported for GPT + Label string `json:"label,omitempty" yaml:"label,omitempty"` + // If nil, the partition is raw; It doesn't contain a payload. Payload PayloadEntity `json:"payload,omitempty" yaml:"payload,omitempty"` } @@ -36,6 +39,7 @@ func (p *Partition) Clone() Entity { Type: p.Type, Bootable: p.Bootable, UUID: p.UUID, + Label: p.Label, } if p.Payload != nil { diff --git a/pkg/disk/partition_table.go b/pkg/disk/partition_table.go index 7a3e73e0bf..7de9402af4 100644 --- a/pkg/disk/partition_table.go +++ b/pkg/disk/partition_table.go @@ -1302,10 +1302,16 @@ func NewCustomPartitionTable(customizations *blueprint.DiskCustomization, option // add user customized partitions for _, part := range customizations.Partitions { if part.PartType != "" { - // check the partition type now that we also know the partition table type + // check the partition details now that we also know the partition table type if err := part.ValidatePartitionTypeID(pt.Type.String()); err != nil { return nil, fmt.Errorf("%s error validating partition type ID for %q: %w", errPrefix, part.Mountpoint, err) } + if err := part.ValidatePartitionID(pt.Type.String()); err != nil { + return nil, fmt.Errorf("%s error validating partition ID for %q: %w", errPrefix, part.Mountpoint, err) + } + if err := part.ValidatePartitionLabel(pt.Type.String()); err != nil { + return nil, fmt.Errorf("%s error validating partition label for %q: %w", errPrefix, part.Mountpoint, err) + } } switch part.Type { @@ -1400,6 +1406,8 @@ func addPlainPartition(pt *PartitionTable, partition blueprint.PartitionCustomiz newpart := Partition{ Type: partType, + UUID: partition.PartUUID, + Label: partition.PartLabel, Size: partition.MinSize, Payload: payload, } @@ -1472,6 +1480,8 @@ func addLVMPartition(pt *PartitionTable, partition blueprint.PartitionCustomizat newpart := Partition{ Type: partType, + UUID: partition.PartUUID, + Label: partition.PartLabel, Size: partition.MinSize, Bootable: false, Payload: newvg, @@ -1505,6 +1515,8 @@ func addBtrfsPartition(pt *PartitionTable, partition blueprint.PartitionCustomiz } newpart := Partition{ Type: partType, + UUID: partition.PartUUID, + Label: partition.PartLabel, Bootable: false, Payload: newvol, Size: partition.MinSize, diff --git a/pkg/disk/partition_table_test.go b/pkg/disk/partition_table_test.go index 7623030652..397c0bbb6d 100644 --- a/pkg/disk/partition_table_test.go +++ b/pkg/disk/partition_table_test.go @@ -1275,8 +1275,10 @@ func TestNewCustomPartitionTable(t *testing.T) { customizations: &blueprint.DiskCustomization{ Partitions: []blueprint.PartitionCustomization{ { - MinSize: 20 * datasizes.MiB, - PartType: "01234567-89ab-cdef-0123-456789abcdef", // overrides the inferred type + MinSize: 20 * datasizes.MiB, + PartType: "01234567-89ab-cdef-0123-456789abcdef", // overrides the inferred type + PartLabel: "TheLabel", + PartUUID: "76543210-89ab-cdef-0123-456789abcdef", // don't generate uuid FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ Mountpoint: "/data", Label: "data", @@ -1323,7 +1325,8 @@ func TestNewCustomPartitionTable(t *testing.T) { Size: 20 * datasizes.MiB, Type: "01234567-89ab-cdef-0123-456789abcdef", Bootable: false, - UUID: "a178892e-e285-4ce1-9114-55780875d64e", + UUID: "76543210-89ab-cdef-0123-456789abcdef", + Label: "TheLabel", Payload: &disk.Filesystem{ Type: "ext4", Label: "data", @@ -1338,7 +1341,7 @@ func TestNewCustomPartitionTable(t *testing.T) { Start: 222 * datasizes.MiB, Size: 1*datasizes.MiB - (disk.DefaultSectorSize + (128 * 128)), Type: disk.RootPartitionX86_64GUID, - UUID: "e2d3d0d0-de6b-48f9-b44c-e85ff044c6b1", + UUID: "a178892e-e285-4ce1-9114-55780875d64e", Bootable: false, Payload: &disk.Filesystem{ Type: "xfs", diff --git a/pkg/osbuild/disk.go b/pkg/osbuild/disk.go index 94e0bed6d1..da66fc7175 100644 --- a/pkg/osbuild/disk.go +++ b/pkg/osbuild/disk.go @@ -19,6 +19,7 @@ func sfdiskStageOptions(pt *disk.PartitionTable) *SfdiskStageOptions { Size: pt.BytesToSectors(p.Size), Type: p.Type, UUID: p.UUID, + Name: p.Label, } } stageOptions := &SfdiskStageOptions{ @@ -40,6 +41,7 @@ func sgdiskStageOptions(pt *disk.PartitionTable) *SgdiskStageOptions { Start: pt.BytesToSectors(p.Start), Size: pt.BytesToSectors(p.Size), Type: p.Type, + Name: p.Label, } if p.UUID != "" {