diff --git a/toolkit/tools/imagecustomizer/docs/configuration.md b/toolkit/tools/imagecustomizer/docs/configuration.md index 428baf46f3a..59a4d6fa771 100644 --- a/toolkit/tools/imagecustomizer/docs/configuration.md +++ b/toolkit/tools/imagecustomizer/docs/configuration.md @@ -1,7 +1,6 @@ # Mariner Image Customizer configuration The Mariner Image Customizer is configured using a YAML (or JSON) file. -The top level type for this YAML file is the [Config](#config-type) type. ### Operation ordering @@ -63,19 +62,86 @@ SystemConfig: - kernel-hci ``` +## Top-level + +The top level type for the YAML file is the [Config](#config-type) type. + ## Config type The top-level type of the configuration. +### Disks [[Disk](#disk-type)[]] + +Contains the options for provisioning disks and their partitions. + +If the Disks field isn't specified, then the partitions of the base image aren't +changed. + +If Disks is specified, then [SystemConfig.BootType](#boottype-boottype) must also be +specified. + +While Disks is a list, only 1 disk is supported at the moment. +Support for multiple disks may (or may not) be added in the future. + +```yaml +Disks: +- PartitionTableType: gpt + MaxSize: 4096 + Partitions: + - ID: esp + Flags: + - esp + - boot + Start: 1 + End: 9 + FsType: fat32 + + - ID: rootfs + Start: 9 + FsType: ext4 + +SystemConfig: + BootType: efi + PartitionSettings: + - ID: esp + MountPoint: /boot/efi + MountOptions: umask=0077 + + - ID: rootfs + MountPoint: / +``` + ### SystemConfig [[SystemConfig](#systemconfig-type)] Contains the configuration options for the OS. +Example: + ```yaml SystemConfig: Hostname: example-image ``` +## Disk type + +Specifies the properties of a disk, including its partitions. + +### PartitionTableType [string] + +Specifies how the partition tables are laid out. + +Supported options: + +- `gpt`: Use the GUID Partition Table (GPT) format. + +### MaxSize [uint64] + +The size of the disk, specified in mebibytes (MiB). + +### Partitions [[Partition](#partition-type)] + +The partitions to provision on the disk. + ## FileConfig type Specifies options for placing a file in the OS. @@ -180,6 +246,124 @@ Packages: - openssh-server ``` +## Partition type + +### ID [string] + +Required. + +The ID of the partition. +This is used correlate Partition objects with [PartitionSetting](#partitionsetting-type) +objects. + +### FsType [string] + +Required. + +The filesystem type of the partition. + +Supported options: + +- `ext4` +- `fat32` +- `xfs` + +### Name [string] + +The label to assign to the partition. + +### Start [uint64] + +Required. + +The start location (inclusive) of the partition, specified in MiBs. + +### End [uint64] + +The end location (exclusive) of the partition, specified in MiBs. + +The End and Size fields cannot be specified at the same time. + +Either the Size or End field is required for all partitions except for the last +partition. +When both the Size and End fields are omitted, the last partition will fill the +remainder of the disk (based on the disk's [MaxSize](#maxsize-uint64) field). + +### Size [uint64] + +The size of the partition, specified in MiBs. + +### Flags [string[]] + +Specifies options for the partition. + +Supported options: + +- `esp`: The UEFI System Partition (ESP). + The partition must have a `FsType` of `fat32`. + + When specified on a GPT formatted disk, the `boot` flag must also be added. + +- `bios_grub`: Specifies this partition is the BIOS boot partition. + This is required for GPT disks that wish to be bootable using legacy BIOS mode. + + This partition must start at block 1. + + This flag is only supported on GPT formatted disks. + + For further details, see: https://en.wikipedia.org/wiki/BIOS_boot_partition + +- `boot`: Specifies that this partition contains the boot loader. + + When specified on a GPT formatted disk, the `esp` flag must also be added. + +These options mirror those in +[parted](https://www.gnu.org/software/parted/manual/html_node/set.html). + +## PartitionSetting type + +Specifies the mount options for a partition. + +### ID [string] + +Required. + +The ID of the partition. +This is used correlate [Partition](#partition-type) objects with PartitionSetting +objects. + +### MountIdentifier [string] + +Default: `partuuid` + +The partition ID type that should be used to recognize the partition on the disk. + +Supported options: + +- `uuid`: The filesystem's partition UUID. + +- `partuuid`: The partition UUID specified in the partition table. + +- `partlabel`: The partition label specified in the partition table. + +### MountOptions [string] + +The additional options used when mounting the file system. + +These options are in the same format as [mount](https://linux.die.net/man/8/mount)'s +`-o` option (or the `fs_mntops` field of the +[fstab](https://man7.org/linux/man-pages/man5/fstab.5.html) file). + +### MountPoint [string] + +Required. + +The absolute path of where the partition should be mounted. + +The mounts will be sorted to ensure that parent directories are mounted before child +directories. +For example, `/boot` will be mounted before `/boot/efi`. + ## Script type Points to a script file (typically a Bash script) to be run during customization. @@ -248,6 +432,22 @@ SystemConfig: Contains the configuration options for the OS. +### BootType [string] + +Specifies the boot system that the image supports. + +Supported options: + +- `legacy`: Support booting from BIOS firmware. + + When this option is specified, the partition layout must contain a partition with the + `bios_grub` flag. + +- `efi`: Support booting from UEFI firmware. + + When this option is specified, the partition layout must contain a partition with the + `esp` flag. + ### Hostname [string] Specifies the hostname for the OS. @@ -393,6 +593,10 @@ SystemConfig: Permissions: "664" ``` +### PartitionSettings [[PartitionSetting](#partitionsetting-type)[]] + +Specifies the mount options of the partitions. + ### PostInstallScripts [[Script](#script-type)[]] Scripts to run against the image after the packages have been added and removed. diff --git a/toolkit/tools/imagecustomizerapi/boottype.go b/toolkit/tools/imagecustomizerapi/boottype.go new file mode 100644 index 00000000000..e8931ace1b3 --- /dev/null +++ b/toolkit/tools/imagecustomizerapi/boottype.go @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerapi + +import ( + "fmt" +) + +type BootType string + +const ( + BootTypeEfi BootType = "efi" + BootTypeLegacy BootType = "legacy" + BootTypeUnset BootType = "" +) + +func (t BootType) IsValid() error { + switch t { + case BootTypeEfi, BootTypeLegacy, BootTypeUnset: + // All good. + return nil + + default: + return fmt.Errorf("invalid BootType value (%v)", t) + } +} diff --git a/toolkit/tools/imagecustomizerapi/boottype_test.go b/toolkit/tools/imagecustomizerapi/boottype_test.go new file mode 100644 index 00000000000..7514bb9624f --- /dev/null +++ b/toolkit/tools/imagecustomizerapi/boottype_test.go @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerapi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBootTypeIsValid(t *testing.T) { + err := BootTypeEfi.IsValid() + assert.NoError(t, err) +} + +func TestBootTypeIsValidBadValue(t *testing.T) { + err := BootType("bad").IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "invalid BootType value") +} diff --git a/toolkit/tools/imagecustomizerapi/config.go b/toolkit/tools/imagecustomizerapi/config.go index a070877c1a8..f387de301b8 100644 --- a/toolkit/tools/imagecustomizerapi/config.go +++ b/toolkit/tools/imagecustomizerapi/config.go @@ -3,15 +3,82 @@ package imagecustomizerapi +import ( + "fmt" + + "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/sliceutils" +) + type Config struct { + Disks *[]Disk `yaml:"Disks"` SystemConfig SystemConfig `yaml:"SystemConfig"` } func (c *Config) IsValid() error { + if c.Disks != nil { + disks := *c.Disks + if len(disks) < 1 { + return fmt.Errorf("at least 1 disk must be specified (or the Disks field should be ommited)") + } + if len(disks) > 1 { + return fmt.Errorf("multiple disks is not currently supported") + } + + for i, disk := range disks { + err := disk.IsValid() + if err != nil { + return fmt.Errorf("invalid disk at index %d:\n%w", i, err) + } + } + } + err := c.SystemConfig.IsValid() if err != nil { return err } + hasDisks := c.Disks != nil + hasBootType := c.SystemConfig.BootType != BootTypeUnset + + if hasDisks != hasBootType { + return fmt.Errorf("SystemConfig.BootType and Disks must be specified together") + } + + // Ensure the correct partitions exist to support the specified the boot type. + switch c.SystemConfig.BootType { + case BootTypeEfi: + hasEsp := sliceutils.ContainsFunc(*c.Disks, func(disk Disk) bool { + return sliceutils.ContainsFunc(disk.Partitions, func(partition Partition) bool { + return sliceutils.ContainsValue(partition.Flags, PartitionFlagESP) + }) + }) + if !hasEsp { + return fmt.Errorf("'esp' partition must be provided for 'efi' boot type") + } + + case BootTypeLegacy: + hasBiosBoot := sliceutils.ContainsFunc(*c.Disks, func(disk Disk) bool { + return sliceutils.ContainsFunc(disk.Partitions, func(partition Partition) bool { + return sliceutils.ContainsValue(partition.Flags, PartitionFlagBiosGrub) + }) + }) + if !hasBiosBoot { + return fmt.Errorf("'bios_grub' partition must be provided for 'legacy' boot type") + } + } + + // Ensure all the partition settings object have an equivalent partition object. + for i, partitionSetting := range c.SystemConfig.PartitionSettings { + diskExists := sliceutils.ContainsFunc(*c.Disks, func(disk Disk) bool { + return sliceutils.ContainsFunc(disk.Partitions, func(partition Partition) bool { + return partition.ID == partitionSetting.ID + }) + }) + if !diskExists { + return fmt.Errorf("invalid PartitionSetting at index %d:\nno partition with matching ID (%s)", i, + partitionSetting.ID) + } + } + return nil } diff --git a/toolkit/tools/imagecustomizerapi/config_test.go b/toolkit/tools/imagecustomizerapi/config_test.go new file mode 100644 index 00000000000..2b0a2017973 --- /dev/null +++ b/toolkit/tools/imagecustomizerapi/config_test.go @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerapi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConfigIsValid(t *testing.T) { + config := &Config{ + Disks: &[]Disk{{ + PartitionTableType: "gpt", + MaxSize: 2, + Partitions: []Partition{ + { + ID: "esp", + FsType: "fat32", + Start: 1, + Flags: []PartitionFlag{ + "esp", + "boot", + }, + }, + }, + }}, + SystemConfig: SystemConfig{ + BootType: "efi", + Hostname: "test", + PartitionSettings: []PartitionSetting{ + { + ID: "esp", + MountPoint: "/boot/efi", + }, + }, + }, + } + + err := config.IsValid() + assert.NoError(t, err) +} + +func TestConfigIsValidLegacy(t *testing.T) { + config := &Config{ + Disks: &[]Disk{{ + PartitionTableType: "gpt", + MaxSize: 2, + Partitions: []Partition{ + { + ID: "boot", + FsType: "fat32", + Start: 1, + Flags: []PartitionFlag{ + "bios_grub", + }, + }, + }, + }}, + SystemConfig: SystemConfig{ + BootType: "legacy", + Hostname: "test", + }, + } + + err := config.IsValid() + assert.NoError(t, err) +} + +func TestConfigIsValidNoBootType(t *testing.T) { + config := &Config{ + Disks: &[]Disk{{ + PartitionTableType: "gpt", + MaxSize: 2, + Partitions: []Partition{ + { + ID: "a", + FsType: "ext4", + Start: 1, + }, + }, + }}, + SystemConfig: SystemConfig{ + Hostname: "test", + }, + } + + err := config.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "BootType") + assert.ErrorContains(t, err, "Disks") +} + +func TestConfigIsValidMultipleDisks(t *testing.T) { + config := &Config{ + Disks: &[]Disk{ + { + PartitionTableType: "gpt", + MaxSize: 1, + }, + { + PartitionTableType: "gpt", + MaxSize: 1, + }, + }, + SystemConfig: SystemConfig{ + Hostname: "test", + }, + } + + err := config.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "multiple disks") +} + +func TestConfigIsValidZeroDisks(t *testing.T) { + config := &Config{ + Disks: &[]Disk{}, + SystemConfig: SystemConfig{ + Hostname: "test", + }, + } + + err := config.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "1 disk") +} + +func TestConfigIsValidBadHostname(t *testing.T) { + config := &Config{ + SystemConfig: SystemConfig{ + Hostname: "test_", + }, + } + + err := config.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "invalid hostname") +} + +func TestConfigIsValidBadDisk(t *testing.T) { + config := &Config{ + Disks: &[]Disk{{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 0, + }}, + SystemConfig: SystemConfig{ + Hostname: "test", + }, + } + + err := config.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "MaxSize") +} + +func TestConfigIsValidMissingEsp(t *testing.T) { + config := &Config{ + Disks: &[]Disk{{ + PartitionTableType: "gpt", + MaxSize: 2, + Partitions: []Partition{}, + }}, + SystemConfig: SystemConfig{ + BootType: "efi", + Hostname: "test", + }, + } + + err := config.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "esp") + assert.ErrorContains(t, err, "efi") +} + +func TestConfigIsValidMissingBiosBoot(t *testing.T) { + config := &Config{ + Disks: &[]Disk{{ + PartitionTableType: "gpt", + MaxSize: 2, + Partitions: []Partition{}, + }}, + SystemConfig: SystemConfig{ + BootType: "legacy", + Hostname: "test", + }, + } + + err := config.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "bios_grub") + assert.ErrorContains(t, err, "legacy") +} + +func TestConfigIsValidInvalidMountPoint(t *testing.T) { + config := &Config{ + Disks: &[]Disk{{ + PartitionTableType: "gpt", + MaxSize: 2, + Partitions: []Partition{ + { + ID: "esp", + FsType: "fat32", + Start: 1, + Flags: []PartitionFlag{ + "esp", + "boot", + }, + }, + }, + }}, + SystemConfig: SystemConfig{ + BootType: "efi", + Hostname: "test", + PartitionSettings: []PartitionSetting{ + { + ID: "esp", + MountPoint: "boot/efi", + }, + }, + }, + } + + err := config.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "MountPoint") + assert.ErrorContains(t, err, "absolute path") +} + +func TestConfigIsValidInvalidPartitionId(t *testing.T) { + config := &Config{ + Disks: &[]Disk{{ + PartitionTableType: "gpt", + MaxSize: 2, + Partitions: []Partition{ + { + ID: "esp", + FsType: "fat32", + Start: 1, + Flags: []PartitionFlag{ + "esp", + "boot", + }, + }, + }, + }}, + SystemConfig: SystemConfig{ + BootType: "efi", + Hostname: "test", + PartitionSettings: []PartitionSetting{ + { + ID: "boot", + MountPoint: "/boot/efi", + }, + }, + }, + } + + err := config.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "partition") + assert.ErrorContains(t, err, "ID") +} diff --git a/toolkit/tools/imagecustomizerapi/disk.go b/toolkit/tools/imagecustomizerapi/disk.go new file mode 100644 index 00000000000..17980fd86d0 --- /dev/null +++ b/toolkit/tools/imagecustomizerapi/disk.go @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerapi + +import ( + "fmt" + "sort" + "strconv" + + "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/sliceutils" +) + +type Disk struct { + // The type of partition table to use (e.g. mbr, gpt) + PartitionTableType PartitionTableType `yaml:"PartitionTableType"` + + // The virtual size of the disk. + MaxSize uint64 `yaml:"MaxSize"` + + // The partitions to allocate on the disk. + Partitions []Partition `yaml:"Partitions"` +} + +func (d *Disk) IsValid() error { + err := d.PartitionTableType.IsValid() + if err != nil { + return err + } + + if d.MaxSize <= 0 { + return fmt.Errorf("a disk's MaxSize value (%d) must be a positive non-zero number", d.MaxSize) + } + + partitionIDSet := make(map[string]bool) + for i, partition := range d.Partitions { + err := partition.IsValid() + if err != nil { + return fmt.Errorf("invalid partition at index %d:\n%w", i, err) + } + + if _, existingName := partitionIDSet[partition.ID]; existingName { + return fmt.Errorf("duplicate partition ID used (%s) at index %d", partition.ID, i) + } + + partitionIDSet[partition.ID] = false // dummy value + + if d.PartitionTableType == PartitionTableTypeGpt { + isESP := sliceutils.ContainsValue(partition.Flags, PartitionFlagESP) + isBoot := sliceutils.ContainsValue(partition.Flags, PartitionFlagBoot) + + if isESP != isBoot { + return fmt.Errorf( + "invalid partition at index %d:\n'esp' and 'boot' flags must be specified together on GPT disks", i) + } + } + } + + // Check for overlapping partitions. + // First, sort partitions by start index. + sortedPartitions := append([]Partition(nil), d.Partitions...) + sort.Slice(sortedPartitions, func(i, j int) bool { + return sortedPartitions[i].Start < sortedPartitions[j].Start + }) + + // Then, confirm each partition ends before the next starts. + for i := 0; i < len(sortedPartitions)-1; i++ { + a := &sortedPartitions[i] + b := &sortedPartitions[i+1] + + aEnd, aHasEnd := a.GetEnd() + if !aHasEnd { + return fmt.Errorf("partition (%s) is not last partition but ommitted End value", a.ID) + } + if aEnd > b.Start { + bEnd, bHasEnd := b.GetEnd() + bEndStr := "" + if bHasEnd { + bEndStr = strconv.FormatUint(bEnd, 10) + } + return fmt.Errorf("partition's (%s) range [%d, %d) overlaps partition's (%s) range [%d, %s)", + a.ID, a.Start, aEnd, b.ID, b.Start, bEndStr) + } + } + + if len(sortedPartitions) > 0 { + // Make sure the first block isn't used. + firstPartition := sortedPartitions[0] + if firstPartition.Start == 0 { + return fmt.Errorf("block 0 must be reserved for the MBR header (%s)", firstPartition.ID) + } + + // Check that the disk is big enough for the partition layout. + lastPartition := sortedPartitions[len(sortedPartitions)-1] + + lastPartitionEnd, lastPartitionHasEnd := lastPartition.GetEnd() + + var requiredSize uint64 + if !lastPartitionHasEnd { + requiredSize = lastPartition.Start + 1 + } else { + requiredSize = lastPartitionEnd + } + + if requiredSize > d.MaxSize { + return fmt.Errorf("disk's partitions need %d MiB but MaxSize is only %d MiB", requiredSize, d.MaxSize) + } + } + + return nil +} diff --git a/toolkit/tools/imagecustomizerapi/disk_test.go b/toolkit/tools/imagecustomizerapi/disk_test.go new file mode 100644 index 00000000000..dd8aa91c58e --- /dev/null +++ b/toolkit/tools/imagecustomizerapi/disk_test.go @@ -0,0 +1,323 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerapi + +import ( + "testing" + + "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/ptrutils" + "github.com/stretchr/testify/assert" +) + +func TestDiskIsValid(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 2, + Partitions: []Partition{ + { + ID: "a", + FsType: "ext4", + Start: 1, + }, + }, + } + + err := disk.IsValid() + assert.NoError(t, err) +} + +func TestDiskIsValidWithEnd(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 2, + Partitions: []Partition{ + { + ID: "a", + FsType: "ext4", + Start: 1, + End: ptrutils.PtrTo(uint64(2)), + }, + }, + } + + err := disk.IsValid() + assert.NoError(t, err) +} + +func TestDiskIsValidWithSize(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 2, + Partitions: []Partition{ + { + ID: "a", + FsType: "ext4", + Start: 1, + Size: ptrutils.PtrTo(uint64(1)), + }, + }, + } + + err := disk.IsValid() + assert.NoError(t, err) +} + +func TestDiskIsValidStartAt0(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 2, + Partitions: []Partition{ + { + ID: "a", + FsType: "ext4", + Start: 0, + }, + }, + } + + err := disk.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "block 0") + assert.ErrorContains(t, err, "MBR header") +} + +func TestDiskIsValidInvalidTableType(t *testing.T) { + disk := &Disk{ + PartitionTableType: "a", + MaxSize: 2, + Partitions: []Partition{ + { + ID: "a", + FsType: "ext4", + Start: 1, + }, + }, + } + + err := disk.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "PartitionTableType") +} + +func TestDiskIsValidInvalidPartition(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 2, + Partitions: []Partition{ + { + ID: "a", + FsType: "ext4", + Start: 2, + End: ptrutils.PtrTo(uint64(0)), + }, + }, + } + + err := disk.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "invalid partition") +} + +func TestDiskIsValidTwoExpanding(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 4, + Partitions: []Partition{ + { + ID: "a", + FsType: "ext4", + Start: 1, + }, + { + ID: "b", + FsType: "ext4", + Start: 2, + }, + }, + } + + err := disk.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "is not last partition") +} + +func TestDiskIsValidOverlaps(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 4, + Partitions: []Partition{ + { + ID: "a", + FsType: "ext4", + Start: 1, + End: ptrutils.PtrTo(uint64(3)), + }, + { + ID: "b", + FsType: "ext4", + Start: 2, + End: ptrutils.PtrTo(uint64(4)), + }, + }, + } + + err := disk.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "overlaps") +} + +func TestDiskIsValidOverlapsExpanding(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 4, + Partitions: []Partition{ + { + ID: "a", + FsType: "ext4", + Start: 1, + End: ptrutils.PtrTo(uint64(3)), + }, + { + ID: "b", + FsType: "ext4", + Start: 2, + }, + }, + } + + err := disk.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "overlaps") +} + +func TestDiskIsValidTooSmall(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 3, + Partitions: []Partition{ + { + ID: "a", + FsType: "ext4", + Start: 1, + End: ptrutils.PtrTo(uint64(2)), + }, + { + ID: "b", + FsType: "ext4", + Start: 3, + End: ptrutils.PtrTo(uint64(4)), + }, + }, + } + + err := disk.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "MaxSize") +} + +func TestDiskIsValidTooSmallExpanding(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 3, + Partitions: []Partition{ + { + ID: "a", + FsType: "ext4", + Start: 1, + End: ptrutils.PtrTo(uint64(3)), + }, + { + ID: "b", + FsType: "ext4", + Start: 3, + }, + }, + } + + err := disk.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "MaxSize") +} + +func TestDiskIsValidZeroSize(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 0, + Partitions: []Partition{}, + } + + err := disk.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "MaxSize") +} + +func TestDiskIsValidMissingEspFlag(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 3, + Partitions: []Partition{ + { + ID: "a", + FsType: "fat32", + Start: 1, + Flags: []PartitionFlag{ + "boot", + }, + }, + }, + } + + err := disk.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "esp") + assert.ErrorContains(t, err, "boot") + assert.ErrorContains(t, err, "flag") +} + +func TestDiskIsValidMissingBootFlag(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 3, + Partitions: []Partition{ + { + ID: "a", + FsType: "fat32", + Start: 1, + Flags: []PartitionFlag{ + "esp", + }, + }, + }, + } + + err := disk.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "esp") + assert.ErrorContains(t, err, "boot") + assert.ErrorContains(t, err, "flag") +} + +func TestDiskIsValidDuplicatePartitionId(t *testing.T) { + disk := &Disk{ + PartitionTableType: PartitionTableTypeGpt, + MaxSize: 2, + Partitions: []Partition{ + { + ID: "a", + FsType: "ext4", + Start: 1, + End: ptrutils.PtrTo(uint64(2)), + }, + { + ID: "a", + FsType: "ext4", + Start: 2, + }, + }, + } + + err := disk.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "duplicate partition ID") +} diff --git a/toolkit/tools/imagecustomizerapi/filesystemtype.go b/toolkit/tools/imagecustomizerapi/filesystemtype.go new file mode 100644 index 00000000000..0a8e29ecc17 --- /dev/null +++ b/toolkit/tools/imagecustomizerapi/filesystemtype.go @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerapi + +import "fmt" + +// FileSystemType is a type of file system (e.g. ext4, xfs, etc.) +type FileSystemType string + +const ( + FileSystemTypeExt4 FileSystemType = "ext4" + FileSystemTypeXfs FileSystemType = "xfs" + FileSystemTypeFat32 FileSystemType = "fat32" +) + +func (t FileSystemType) IsValid() error { + switch t { + case FileSystemTypeExt4, FileSystemTypeXfs, FileSystemTypeFat32: + // All good. + return nil + + default: + return fmt.Errorf("invalid FileSystemType value (%s)", t) + } +} diff --git a/toolkit/tools/imagecustomizerapi/mountidentifier.go b/toolkit/tools/imagecustomizerapi/mountidentifier.go new file mode 100644 index 00000000000..30d92c4d042 --- /dev/null +++ b/toolkit/tools/imagecustomizerapi/mountidentifier.go @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerapi + +import "fmt" + +// MountIdentifierType indicates how a partition should be identified in the fstab file +type MountIdentifierType string + +const ( + // MountIdentifierTypeUuid mounts this partition via the filesystem UUID + MountIdentifierTypeUuid MountIdentifierType = "uuid" + + // MountIdentifierTypePartUuid mounts this partition via the GPT/MBR PARTUUID + MountIdentifierTypePartUuid MountIdentifierType = "partuuid" + + // MountIdentifierTypePartLabel mounts this partition via the GPT PARTLABEL + MountIdentifierTypePartLabel MountIdentifierType = "partlabel" + + // MountIdentifierTypeDefault uses the default type, which is PARTUUID. + MountIdentifierTypeDefault MountIdentifierType = "" +) + +func (m MountIdentifierType) IsValid() error { + switch m { + case MountIdentifierTypeUuid, MountIdentifierTypePartUuid, MountIdentifierTypePartLabel, MountIdentifierTypeDefault: + // All good. + return nil + + default: + return fmt.Errorf("invalid MountIdentifierType value (%v)", m) + } +} diff --git a/toolkit/tools/imagecustomizerapi/partition.go b/toolkit/tools/imagecustomizerapi/partition.go new file mode 100644 index 00000000000..3941d642534 --- /dev/null +++ b/toolkit/tools/imagecustomizerapi/partition.go @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerapi + +import ( + "fmt" + "unicode" + + "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/sliceutils" +) + +type Partition struct { + // ID is used to correlate `Partition` objects with `PartitionSetting` objects. + ID string `yaml:"ID"` + // FsType is the type of file system to use on the partition. + FsType FileSystemType `yaml:"FsType"` + // Name is the label to assign to the partition. + Name string `yaml:"Name"` + // Start is the offset where the partition begins (inclusive), in MiBs. + Start uint64 `yaml:"Start"` + // End is the offset where the partition ends (exclusive), in MiBs. + End *uint64 `yaml:"End"` + // Size is the size of the partition in MiBs. + Size *uint64 `yaml:"Size"` + // Flags assigns features to the partition. + Flags []PartitionFlag `yaml:"Flags"` +} + +func (p *Partition) IsValid() error { + err := p.FsType.IsValid() + if err != nil { + return fmt.Errorf("invalid partition (%s) FsType value:\n%w", p.ID, err) + } + + err = isGPTNameValid(p.Name) + if err != nil { + return err + } + + if p.End != nil && p.Size != nil { + return fmt.Errorf("cannot specify both End and Size on partition (%s)", p.ID) + } + + if (p.End != nil && p.Start >= *p.End) || (p.Size != nil && *p.Size <= 0) { + return fmt.Errorf("partition's (%s) size can't be 0 or negative", p.ID) + } + + for _, f := range p.Flags { + err := f.IsValid() + if err != nil { + return err + } + } + + isESP := sliceutils.ContainsValue(p.Flags, PartitionFlagESP) + if isESP { + if p.FsType != FileSystemTypeFat32 { + return fmt.Errorf("ESP partition must have 'fat32' filesystem type") + } + } + + isBiosBoot := sliceutils.ContainsValue(p.Flags, PartitionFlagBiosGrub) + if isBiosBoot { + if p.Start != 1 { + return fmt.Errorf("BIOS boot partition must start at block 1") + } + + if p.FsType != FileSystemTypeFat32 { + return fmt.Errorf("BIOS boot partition must have 'fat32' filesystem type") + } + } + + return nil +} + +func (p *Partition) GetEnd() (uint64, bool) { + if p.End != nil { + return *p.End, true + } + + if p.Size != nil { + return p.Start + *p.Size, true + } + + return 0, false +} + +// isGPTNameValid checks if a GPT partition name is valid. +func isGPTNameValid(name string) error { + // The max partition name length is 36 UTF-16 code units, including a null terminator. + // Since we are also restricting the name to ASCII, this means 35 ASCII characters. + const maxLength = 35 + + // Restrict the name to only ASCII characters as some tools (e.g. parted) work better + // with only ASCII characters. + for _, char := range name { + if char > unicode.MaxASCII { + return fmt.Errorf("partition name (%s) contains a non-ASCII character (%c)", name, char) + } + } + + if len(name) > maxLength { + return fmt.Errorf("partition name (%s) is too long", name) + } + + return nil +} diff --git a/toolkit/tools/imagecustomizerapi/partition_test.go b/toolkit/tools/imagecustomizerapi/partition_test.go new file mode 100644 index 00000000000..63d5a935b80 --- /dev/null +++ b/toolkit/tools/imagecustomizerapi/partition_test.go @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerapi + +import ( + "testing" + + "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/ptrutils" + "github.com/stretchr/testify/assert" +) + +func TestPartitionIsValidExpanding(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ext4", + Start: 0, + } + + err := partition.IsValid() + assert.NoError(t, err) +} + +func TestPartitionIsValidFixedSize(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ext4", + Start: 0, + End: ptrutils.PtrTo(uint64(1)), + } + + err := partition.IsValid() + assert.NoError(t, err) +} + +func TestPartitionIsValidZeroSize(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ext4", + Start: 0, + End: ptrutils.PtrTo(uint64(0)), + } + + err := partition.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "partition") + assert.ErrorContains(t, err, "size") +} + +func TestPartitionIsValidZeroSizeV2(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ext4", + Start: 0, + Size: ptrutils.PtrTo(uint64(0)), + } + + err := partition.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "partition") + assert.ErrorContains(t, err, "size") +} + +func TestPartitionIsValidNegativeSize(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ext4", + Start: 2, + End: ptrutils.PtrTo(uint64(1)), + } + + err := partition.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "partition") + assert.ErrorContains(t, err, "size") +} + +func TestPartitionIsValidBothEndAndSize(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ext4", + Start: 2, + End: ptrutils.PtrTo(uint64(3)), + Size: ptrutils.PtrTo(uint64(1)), + } + + err := partition.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "End") + assert.ErrorContains(t, err, "Size") +} + +func TestPartitionIsValidGoodName(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ext4", + Start: 0, + End: nil, + Name: "a", + } + + err := partition.IsValid() + assert.NoError(t, err) +} + +func TestPartitionIsValidNameTooLong(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ext4", + Start: 0, + End: nil, + Name: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } + + err := partition.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "name") + assert.ErrorContains(t, err, "too long") +} + +func TestPartitionIsValidNameNonASCII(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ext4", + Start: 0, + End: nil, + Name: "❤️", + } + + err := partition.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "name") + assert.ErrorContains(t, err, "ASCII") +} + +func TestPartitionIsValidGoodFlag(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "fat32", + Start: 0, + End: nil, + Flags: []PartitionFlag{"esp"}, + } + + err := partition.IsValid() + assert.NoError(t, err) +} + +func TestPartitionIsValidBadFlag(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ext4", + Start: 0, + End: nil, + Flags: []PartitionFlag{"a"}, + } + + err := partition.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "PartitionFlag") +} + +func TestPartitionIsValidUnsupportedFileSystem(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ntfs", + Start: 0, + End: nil, + Flags: []PartitionFlag{"a"}, + } + + err := partition.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "FileSystemType") +} + +func TestPartitionIsValidBadEspFsType(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ext4", + Start: 0, + End: nil, + Flags: []PartitionFlag{"esp"}, + } + + err := partition.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "ESP") + assert.ErrorContains(t, err, "fat32") +} + +func TestPartitionIsValidBadBiosBootFsType(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ext4", + Start: 1, + End: nil, + Flags: []PartitionFlag{"bios_grub"}, + } + + err := partition.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "BIOS boot") + assert.ErrorContains(t, err, "fat32") +} + +func TestPartitionIsValidBadBiosBootStart(t *testing.T) { + partition := Partition{ + ID: "a", + FsType: "ext4", + Start: 2, + End: nil, + Flags: []PartitionFlag{"bios_grub"}, + } + + err := partition.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "BIOS boot") + assert.ErrorContains(t, err, "start") +} diff --git a/toolkit/tools/imagecustomizerapi/partitionflag.go b/toolkit/tools/imagecustomizerapi/partitionflag.go new file mode 100644 index 00000000000..032287ac142 --- /dev/null +++ b/toolkit/tools/imagecustomizerapi/partitionflag.go @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerapi + +import ( + "fmt" +) + +// PartitionFlag describes the features of a partition. +type PartitionFlag string + +const ( + // PartitionFlagEsp indicates this is a UEFI System Partition (ESP). + // + // On GPT disks, "boot" and "esp" must always be specified together. + PartitionFlagESP PartitionFlag = "esp" + + // PartitionFlagBiosGrub indicates this is the BIOS boot partition. + // This is required for GPT disks that wish to be bootable using legacy BIOS mode. + // This partition must start at block 1. + // + // See, https://en.wikipedia.org/wiki/BIOS_boot_partition + PartitionFlagBiosGrub PartitionFlag = "bios_grub" + + // PartitionFlagBoot indicates this is a boot partition. + // + // On GPT disks, "boot" and "esp" must always be specified together. + PartitionFlagBoot PartitionFlag = "boot" +) + +func (p PartitionFlag) IsValid() (err error) { + switch p { + case PartitionFlagBoot, PartitionFlagBiosGrub, PartitionFlagESP: + // All good. + return nil + + default: + return fmt.Errorf("unknown PartitionFlag value (%s)", p) + } +} diff --git a/toolkit/tools/imagecustomizerapi/partitionsetting.go b/toolkit/tools/imagecustomizerapi/partitionsetting.go new file mode 100644 index 00000000000..5bccc6bebf6 --- /dev/null +++ b/toolkit/tools/imagecustomizerapi/partitionsetting.go @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerapi + +import ( + "fmt" + "path" +) + +// PartitionSetting holds the mounting information for each partition. +type PartitionSetting struct { + ID string `yaml:"ID"` + MountIdentifier MountIdentifierType `yaml:"MountIdentifier"` + MountOptions string `yaml:"MountOptions"` + MountPoint string `yaml:"MountPoint"` +} + +// IsValid returns an error if the PartitionSetting is not valid +func (p *PartitionSetting) IsValid() error { + err := p.MountIdentifier.IsValid() + if err != nil { + return err + } + + if p.MountPoint != "" && !path.IsAbs(p.MountPoint) { + return fmt.Errorf("MountPoint (%s) must be an absolute path", p.MountPoint) + } + + return nil +} diff --git a/toolkit/tools/imagecustomizerapi/partitionsetting_test.go b/toolkit/tools/imagecustomizerapi/partitionsetting_test.go new file mode 100644 index 00000000000..851c091c144 --- /dev/null +++ b/toolkit/tools/imagecustomizerapi/partitionsetting_test.go @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerapi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPartitionIsValidInvalidMountIdentifier(t *testing.T) { + partition := PartitionSetting{ + ID: "a", + MountIdentifier: "bad", + } + + err := partition.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "invalid") + assert.ErrorContains(t, err, "MountIdentifierType") +} diff --git a/toolkit/tools/imagecustomizerapi/partitiontabletype.go b/toolkit/tools/imagecustomizerapi/partitiontabletype.go new file mode 100644 index 00000000000..4f17d48a0d8 --- /dev/null +++ b/toolkit/tools/imagecustomizerapi/partitiontabletype.go @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerapi + +import ( + "fmt" +) + +// PartitionTableType is either gpt, mbr, or none +type PartitionTableType string + +const ( + PartitionTableTypeGpt PartitionTableType = "gpt" +) + +func (t PartitionTableType) IsValid() error { + switch t { + case PartitionTableTypeGpt: + // All good. + return nil + + default: + return fmt.Errorf("invalid PartitionTableType value (%s)", t) + } +} diff --git a/toolkit/tools/imagecustomizerapi/systemconfig.go b/toolkit/tools/imagecustomizerapi/systemconfig.go index 415161a0d85..e2ed6bedefb 100644 --- a/toolkit/tools/imagecustomizerapi/systemconfig.go +++ b/toolkit/tools/imagecustomizerapi/systemconfig.go @@ -12,6 +12,7 @@ import ( // SystemConfig defines how each system present on the image is supposed to be configured. type SystemConfig struct { + BootType BootType `yaml:"BootType"` Hostname string `yaml:"Hostname"` UpdateBaseImagePackages bool `yaml:"UpdateBaseImagePackages"` PackageListsInstall []string `yaml:"PackageListsInstall"` @@ -21,6 +22,7 @@ type SystemConfig struct { PackageListsUpdate []string `yaml:"PackageListsUpdate"` PackagesUpdate []string `yaml:"PackagesUpdate"` AdditionalFiles map[string]FileConfigList `yaml:"AdditionalFiles"` + PartitionSettings []PartitionSetting `yaml:"PartitionSettings"` PostInstallScripts []Script `yaml:"PostInstallScripts"` FinalizeImageScripts []Script `yaml:"FinalizeImageScripts"` Users []User `yaml:"Users"` @@ -31,6 +33,11 @@ type SystemConfig struct { func (s *SystemConfig) IsValid() error { var err error + err = s.BootType.IsValid() + if err != nil { + return err + } + if s.Hostname != "" { if !govalidator.IsDNSName(s.Hostname) || strings.Contains(s.Hostname, "_") { return fmt.Errorf("invalid hostname: %s", s.Hostname) @@ -44,6 +51,20 @@ func (s *SystemConfig) IsValid() error { } } + partitionIDSet := make(map[string]bool) + for i, partition := range s.PartitionSettings { + err = partition.IsValid() + if err != nil { + return fmt.Errorf("invalid PartitionSettings item at index %d: %w", i, err) + } + + if _, existingName := partitionIDSet[partition.ID]; existingName { + return fmt.Errorf("duplicate PartitionSettings ID used (%s) at index %d", partition.ID, i) + } + + partitionIDSet[partition.ID] = false // dummy value + } + for i, script := range s.PostInstallScripts { err = script.IsValid() if err != nil { diff --git a/toolkit/tools/imagecustomizerapi/systemconfig_test.go b/toolkit/tools/imagecustomizerapi/systemconfig_test.go index f08ac90eae1..cada72e43e7 100644 --- a/toolkit/tools/imagecustomizerapi/systemconfig_test.go +++ b/toolkit/tools/imagecustomizerapi/systemconfig_test.go @@ -5,6 +5,8 @@ package imagecustomizerapi import ( "testing" + + "github.com/stretchr/testify/assert" ) func TestSystemConfigValidEmpty(t *testing.T) { @@ -22,3 +24,20 @@ func TestSystemConfigInvalidHostname(t *testing.T) { func TestSystemConfigInvalidAdditionalFiles(t *testing.T) { testInvalidYamlValue[*SystemConfig](t, "{ \"AdditionalFiles\": { \"a.txt\": [] } }") } + +func TestSystemConfigIsValidDuplicatePartitionID(t *testing.T) { + value := SystemConfig{ + PartitionSettings: []PartitionSetting{ + { + ID: "a", + }, + { + ID: "a", + }, + }, + } + + err := value.IsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "duplicate PartitionSettings ID") +} diff --git a/toolkit/tools/internal/sliceutils/sliceutils.go b/toolkit/tools/internal/sliceutils/sliceutils.go index 0d5fdc83287..c9612153adb 100644 --- a/toolkit/tools/internal/sliceutils/sliceutils.go +++ b/toolkit/tools/internal/sliceutils/sliceutils.go @@ -89,3 +89,23 @@ func RemoveDuplicatesFromSlice[K comparable](inputSlice []K) (outputSlice []K) { func nilCheck(expected interface{}, given interface{}) (checkValid, checkResult bool) { return (expected == nil || given == nil), (expected == nil && given == nil) } + +// Can be replaced by slices.Contains in Go 1.21. +func ContainsValue[K comparable](inputSlice []K, value K) bool { + for _, item := range inputSlice { + if item == value { + return true + } + } + return false +} + +// Can be replaced by slices.ContainsFunc in Go 1.21. +func ContainsFunc[K any](inputSlice []K, fn func(K) bool) bool { + for _, item := range inputSlice { + if fn(item) { + return true + } + } + return false +} diff --git a/toolkit/tools/pkg/imagecustomizerlib/customizepartitions.go b/toolkit/tools/pkg/imagecustomizerlib/customizepartitions.go new file mode 100644 index 00000000000..f909e5eb812 --- /dev/null +++ b/toolkit/tools/pkg/imagecustomizerlib/customizepartitions.go @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerlib + +import ( + "path/filepath" + + "github.com/microsoft/CBL-Mariner/toolkit/tools/imagecustomizerapi" +) + +func customizePartitions(buildDir string, baseConfigPath string, config *imagecustomizerapi.Config, + buildImageFile string, +) (string, error) { + if config.Disks == nil && config.SystemConfig.BootType == imagecustomizerapi.BootTypeUnset { + // No changes to make to the partitions. + // So, just use the original disk. + return buildImageFile, nil + } + + newBuildImageFile := filepath.Join(buildDir, PartitionCustomizedImageName) + + // If there is no known way to create the new partition layout from the old one, + // then fallback to creating the new partitions from scratch and doing a file copy. + err := customizePartitionsUsingFileCopy(buildDir, baseConfigPath, config, buildImageFile, newBuildImageFile) + if err != nil { + return "", err + } + + return newBuildImageFile, nil +} diff --git a/toolkit/tools/pkg/imagecustomizerlib/customizepartitionsfilecopy.go b/toolkit/tools/pkg/imagecustomizerlib/customizepartitionsfilecopy.go new file mode 100644 index 00000000000..e7bb64e0919 --- /dev/null +++ b/toolkit/tools/pkg/imagecustomizerlib/customizepartitionsfilecopy.go @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerlib + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/microsoft/CBL-Mariner/toolkit/tools/imagecustomizerapi" + "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/logger" + "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/safechroot" + "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/shell" +) + +func customizePartitionsUsingFileCopy(buildDir string, baseConfigPath string, config *imagecustomizerapi.Config, + buildImageFile string, newBuildImageFile string, +) error { + existingImageConnection, err := connectToExistingImage(buildImageFile, buildDir, "imageroot") + if err != nil { + return err + } + defer existingImageConnection.Close() + + diskConfig := (*config.Disks)[0] + + installOSFunc := func(imageChroot *safechroot.Chroot) error { + return copyFilesIntoNewDisk(existingImageConnection.Chroot(), imageChroot) + } + + newImageConnection, err := createNewImage(newBuildImageFile, diskConfig, config.SystemConfig.PartitionSettings, + config.SystemConfig.BootType, buildDir, "newimageroot", installOSFunc) + if err != nil { + return err + } + defer newImageConnection.Close() + + err = newImageConnection.CleanClose() + if err != nil { + return err + } + + err = existingImageConnection.CleanClose() + if err != nil { + return err + } + + return nil +} + +func copyFilesIntoNewDisk(existingImageChroot *safechroot.Chroot, newImageChroot *safechroot.Chroot) error { + err := copyFilesIntoNewDiskHelper(existingImageChroot, newImageChroot) + if err != nil { + return fmt.Errorf("failed to copy files into new partition layout:\n%w", err) + } + return nil +} + +func copyFilesIntoNewDiskHelper(existingImageChroot *safechroot.Chroot, newImageChroot *safechroot.Chroot) error { + // Notes: + // `-a` ensures unix permissions, extended attributes (including SELinux), and sub-directories (-r) are copied. + // `--no-dereference` ensures that symlinks are copied as symlinks. + copyArgs := []string{"--verbose", "--no-clobber", "-a", "--no-dereference", "--sparse", "always", "-t", newImageChroot.RootDir()} + + files, err := os.ReadDir(existingImageChroot.RootDir()) + if err != nil { + return fmt.Errorf("failed to read base image root directory:\n%w", err) + } + + for _, file := range files { + switch file.Name() { + case "dev", "proc", "sys", "run", "tmp": + // Exclude special directories. + // + // Note: Under /var, there are symlinks to a couple of these special directories. + // However, the `cp` command is called with `--no-dereference`. So, the symlinks will be copied as symlinks. + continue + } + + fullFileName := filepath.Join(existingImageChroot.RootDir(), file.Name()) + copyArgs = append(copyArgs, fullFileName) + } + + err = shell.ExecuteLiveWithCallback(func(...interface{}) {}, logger.Log.Warn, false, + "cp", copyArgs...) + if err != nil { + return fmt.Errorf("failed to copy files:\n%w", err) + } + + return nil +} diff --git a/toolkit/tools/pkg/imagecustomizerlib/imageConnection.go b/toolkit/tools/pkg/imagecustomizerlib/imageConnection.go new file mode 100644 index 00000000000..d6e31e12ba2 --- /dev/null +++ b/toolkit/tools/pkg/imagecustomizerlib/imageConnection.go @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerlib + +import ( + "fmt" + + "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/safechroot" + "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/safeloopback" +) + +type ImageConnection struct { + loopback *safeloopback.Loopback + chroot *safechroot.Chroot + chrootIsExistingDir bool +} + +func NewImageConnection() *ImageConnection { + return &ImageConnection{} +} + +func (c *ImageConnection) ConnectLoopback(diskFilePath string) error { + if c.loopback != nil { + return fmt.Errorf("loopback already connected") + } + + loopback, err := safeloopback.NewLoopback(diskFilePath) + if err != nil { + return fmt.Errorf("failed to mount raw disk (%s) as a loopback device:\n%w", diskFilePath, err) + } + c.loopback = loopback + return nil +} + +func (c *ImageConnection) ConnectChroot(rootDir string, isExistingDir bool, extraDirectories []string, + extraMountPoints []*safechroot.MountPoint, +) error { + if c.chroot != nil { + return fmt.Errorf("chroot already connected") + } + + chroot := safechroot.NewChroot(rootDir, isExistingDir) + err := chroot.Initialize("", extraDirectories, extraMountPoints) + if err != nil { + return err + } + c.chroot = chroot + c.chrootIsExistingDir = isExistingDir + + return nil +} + +func (c *ImageConnection) Chroot() *safechroot.Chroot { + return c.chroot +} + +func (c *ImageConnection) Loopback() *safeloopback.Loopback { + return c.loopback +} + +func (c *ImageConnection) Close() { + if c.chroot != nil { + c.chroot.Close(c.chrootIsExistingDir) + } + + if c.loopback != nil { + c.loopback.Close() + } +} + +func (c *ImageConnection) CleanClose() error { + err := c.chroot.Close(c.chrootIsExistingDir) + if err != nil { + return err + } + + err = c.loopback.CleanClose() + if err != nil { + return err + } + + return nil +} diff --git a/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer.go b/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer.go index 834507f6b31..474772759be 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer.go +++ b/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer.go @@ -11,13 +11,14 @@ import ( "github.com/microsoft/CBL-Mariner/toolkit/tools/imagecustomizerapi" "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/file" "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/logger" - "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/safechroot" - "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/safeloopback" "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/shell" ) const ( tmpParitionDirName = "tmppartition" + + BaseImageName = "image.raw" + PartitionCustomizedImageName = "image2.raw" ) var ( @@ -84,7 +85,7 @@ func CustomizeImage(buildDir string, baseConfigPath string, config *imagecustomi } // Convert image file to raw format, so that a kernel loop device can be used to make changes to the image. - buildImageFile := filepath.Join(buildDirAbs, "image.raw") + buildImageFile := filepath.Join(buildDirAbs, BaseImageName) logger.Log.Infof("Mounting base image: %s", buildImageFile) err = shell.ExecuteLiveWithErr(1, "qemu-img", "convert", "-O", "raw", imageFile, buildImageFile) @@ -92,6 +93,12 @@ func CustomizeImage(buildDir string, baseConfigPath string, config *imagecustomi return fmt.Errorf("failed to convert image file to raw format:\n%w", err) } + // Customize the partitions. + buildImageFile, err = customizePartitions(buildDirAbs, baseConfigPath, config, buildImageFile) + if err != nil { + return err + } + // Customize the raw image file. err = customizeImageHelper(buildDirAbs, baseConfigPath, config, buildImageFile, rpmsSources, useBaseImageRpmRepos) if err != nil { @@ -196,43 +203,19 @@ func validateScript(baseConfigPath string, script *imagecustomizerapi.Script) er func customizeImageHelper(buildDir string, baseConfigPath string, config *imagecustomizerapi.Config, buildImageFile string, rpmsSources []string, useBaseImageRpmRepos bool, ) error { - // Mount the raw disk image file. - loopback, err := safeloopback.NewLoopback(buildImageFile) - if err != nil { - return fmt.Errorf("failed to mount raw disk (%s) as a loopback device:\n%w", buildImageFile, err) - } - defer loopback.Close() - - // Look for all the partitions on the image. - newMountDirectories, mountPoints, err := findPartitions(buildDir, loopback.DevicePath()) - if err != nil { - return fmt.Errorf("failed to find disk partitions:\n%w", err) - } - - // Create chroot environment. - imageChrootDir := filepath.Join(buildDir, "imageroot") - - chrootLeaveOnDisk := false - imageChroot := safechroot.NewChroot(imageChrootDir, chrootLeaveOnDisk) - err = imageChroot.Initialize("", newMountDirectories, mountPoints) + imageConnection, err := connectToExistingImage(buildImageFile, buildDir, "imageroot") if err != nil { return err } - defer imageChroot.Close(chrootLeaveOnDisk) + defer imageConnection.Close() // Do the actual customizations. - err = doCustomizations(buildDir, baseConfigPath, config, imageChroot, rpmsSources, useBaseImageRpmRepos) - if err != nil { - return err - } - - // Close. - err = imageChroot.Close(chrootLeaveOnDisk) + err = doCustomizations(buildDir, baseConfigPath, config, imageConnection.Chroot(), rpmsSources, useBaseImageRpmRepos) if err != nil { return err } - err = loopback.CleanClose() + err = imageConnection.CleanClose() if err != nil { return err } diff --git a/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer_test.go b/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer_test.go index 723616a948e..dbcb31523b4 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer_test.go +++ b/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer_test.go @@ -11,10 +11,8 @@ import ( "testing" "github.com/microsoft/CBL-Mariner/toolkit/tools/imagecustomizerapi" - "github.com/microsoft/CBL-Mariner/toolkit/tools/imagegen/configuration" - "github.com/microsoft/CBL-Mariner/toolkit/tools/imagegen/diskutils" - "github.com/microsoft/CBL-Mariner/toolkit/tools/imagegen/installutils" "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/buildpipeline" + "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/ptrutils" "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/safechroot" "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/safeloopback" "github.com/stretchr/testify/assert" @@ -206,130 +204,55 @@ func createFakeEfiImage(buildDir string) (string, error) { } // Use a prototypical Mariner image partition config. - diskConfig := configuration.Disk{ - PartitionTableType: configuration.PartitionTableTypeGpt, + diskConfig := imagecustomizerapi.Disk{ + PartitionTableType: imagecustomizerapi.PartitionTableTypeGpt, MaxSize: 4096, - Partitions: []configuration.Partition{ + Partitions: []imagecustomizerapi.Partition{ { ID: "boot", - Flags: []configuration.PartitionFlag{"esp", "boot"}, + Flags: []imagecustomizerapi.PartitionFlag{"esp", "boot"}, Start: 1, - End: 9, + End: ptrutils.PtrTo(uint64(9)), FsType: "fat32", }, { ID: "rootfs", Start: 9, - End: 0, + End: nil, FsType: "ext4", }, }, } - partitionSettings := []configuration.PartitionSetting{ + partitionSettings := []imagecustomizerapi.PartitionSetting{ { ID: "boot", MountPoint: "/boot/efi", MountOptions: "umask=0077", - MountIdentifier: configuration.MountIdentifierDefault, + MountIdentifier: imagecustomizerapi.MountIdentifierTypeDefault, }, { ID: "rootfs", MountPoint: "/", - MountIdentifier: configuration.MountIdentifierDefault, + MountIdentifier: imagecustomizerapi.MountIdentifierTypeDefault, }, } - // Create raw disk image file. - rawDisk, err := diskutils.CreateEmptyDisk(buildDir, "disk.raw", diskConfig.MaxSize) - if err != nil { - return "", fmt.Errorf("failed to create empty disk file in (%s):\n%w", buildDir, err) - } - - // Connect raw disk image file. - loopback, err := safeloopback.NewLoopback(rawDisk) - if err != nil { - return "", fmt.Errorf("failed to mount raw disk (%s) as a loopback device:\n%w", rawDisk, err) - } - defer loopback.Close() - - // Set up partitions. - partIDToDevPathMap, partIDToFsTypeMap, _, _, err := diskutils.CreatePartitions(loopback.DevicePath(), diskConfig, - configuration.RootEncryption{}, configuration.ReadOnlyVerityRoot{}) - if err != nil { - return "", fmt.Errorf("failed to create partitions on disk (%s):\n%w", loopback.DevicePath(), err) - } - - // Create partition mount config. - bootPartitionDevPath := fmt.Sprintf("%sp1", loopback.DevicePath()) - osPartitionDevPath := fmt.Sprintf("%sp2", loopback.DevicePath()) - - newMountDirectories := []string{} - mountPoints := []*safechroot.MountPoint{ - safechroot.NewPreDefaultsMountPoint(osPartitionDevPath, "/", "ext4", 0, ""), - safechroot.NewMountPoint(bootPartitionDevPath, "/boot/efi", "vfat", 0, ""), - } - - // Mount the partitions. - chrootLeaveOnDisk := false - imageChroot := safechroot.NewChroot(filepath.Join(buildDir, "imageroot"), chrootLeaveOnDisk) - err = imageChroot.Initialize("", newMountDirectories, mountPoints) - if err != nil { - return "", err - } - defer imageChroot.Close(chrootLeaveOnDisk) - - // Write a fake grub.cfg file so that the partition discovery logic works. - bootPrefix := "/boot" - - osUuid, err := installutils.GetUUID(osPartitionDevPath) - if err != nil { - return "", fmt.Errorf("failed get OS partition UUID:\n%w", err) - } - - rootDevice, err := installutils.FormatMountIdentifier(configuration.MountIdentifierUuid, osPartitionDevPath) - if err != nil { - return "", fmt.Errorf("failed to format mount identifier:\n%w", err) - } - - err = installutils.InstallBootloader(imageChroot, false, "efi", osUuid, bootPrefix, "") - if err != nil { - return "", fmt.Errorf("failed to install bootloader:\n%w", err) - } + rawDisk := filepath.Join(buildDir, "disk.raw") - err = installutils.InstallGrubCfg(imageChroot.RootDir(), rootDevice, osUuid, bootPrefix, - diskutils.EncryptedRootDevice{}, configuration.KernelCommandLine{}, diskutils.VerityDevice{}, false) - if err != nil { - return "", fmt.Errorf("failed to install main grub config file:\n%w", err) - } - - err = installutils.InstallGrubEnv(imageChroot.RootDir()) - if err != nil { - return "", fmt.Errorf("failed to install grubenv file:\n%w", err) - } - - // Write a fake fstab file so that the partition discovery logic works. - mountPointMap, mountPointToFsTypeMap, mountPointToMountArgsMap, _ := installutils.CreateMountPointPartitionMap( - partIDToDevPathMap, partIDToFsTypeMap, partitionSettings, - ) - - err = installutils.UpdateFstab(imageChroot.RootDir(), partitionSettings, mountPointMap, mountPointToFsTypeMap, - mountPointToMountArgsMap, partIDToDevPathMap, partIDToFsTypeMap, false, /*hidepidEnabled*/ - ) - if err != nil { - return "", fmt.Errorf("failed to install fstab file:\n%w", err) - } - - // Close. - err = imageChroot.Close(chrootLeaveOnDisk) - if err != nil { - return "", err + installOS := func(imageChroot *safechroot.Chroot) error { + // Don't write anything for the OS. + // The createNewImage function will still write the bootloader and fstab file, which will allow the partition + // discovery logic to work. This allows for a limited set of tests to run without needing any of the RPM files. + return nil } - err = loopback.CleanClose() + imageConnection, err := createNewImage(rawDisk, diskConfig, partitionSettings, "efi", buildDir, "imageroot", + installOS) if err != nil { return "", err } + defer imageConnection.Close() return rawDisk, nil } diff --git a/toolkit/tools/pkg/imagecustomizerlib/imageutils.go b/toolkit/tools/pkg/imagecustomizerlib/imageutils.go new file mode 100644 index 00000000000..ae573e6db23 --- /dev/null +++ b/toolkit/tools/pkg/imagecustomizerlib/imageutils.go @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerlib + +import ( + "fmt" + "path/filepath" + "sort" + + "github.com/microsoft/CBL-Mariner/toolkit/tools/imagecustomizerapi" + "github.com/microsoft/CBL-Mariner/toolkit/tools/imagegen/configuration" + "github.com/microsoft/CBL-Mariner/toolkit/tools/imagegen/diskutils" + "github.com/microsoft/CBL-Mariner/toolkit/tools/imagegen/installutils" + "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/file" + "github.com/microsoft/CBL-Mariner/toolkit/tools/internal/safechroot" +) + +type installOSFunc func(imageChroot *safechroot.Chroot) error + +func connectToExistingImage(imageFilePath string, buildDir string, chrootDirName string) (*ImageConnection, error) { + imageConnection := NewImageConnection() + + err := connectToExistingImageHelper(imageConnection, imageFilePath, buildDir, chrootDirName) + if err != nil { + imageConnection.Close() + return nil, err + } + + return imageConnection, nil + +} + +func connectToExistingImageHelper(imageConnection *ImageConnection, imageFilePath string, + buildDir string, chrootDirName string, +) error { + // Connect to image file using loopback device. + err := imageConnection.ConnectLoopback(imageFilePath) + if err != nil { + return err + } + + // Look for all the partitions on the image. + newMountDirectories, mountPoints, err := findPartitions(buildDir, imageConnection.Loopback().DevicePath()) + if err != nil { + return fmt.Errorf("failed to find disk partitions:\n%w", err) + } + + // Create chroot environment. + imageChrootDir := filepath.Join(buildDir, chrootDirName) + + err = imageConnection.ConnectChroot(imageChrootDir, false, newMountDirectories, mountPoints) + if err != nil { + return err + } + + return nil +} + +func createNewImage(filename string, diskConfig imagecustomizerapi.Disk, + partitionSettings []imagecustomizerapi.PartitionSetting, bootType imagecustomizerapi.BootType, buildDir string, + chrootDirName string, installOS installOSFunc, +) (*ImageConnection, error) { + imageConnection := &ImageConnection{} + + err := createNewImageHelper(imageConnection, filename, diskConfig, partitionSettings, bootType, buildDir, + chrootDirName, installOS, + ) + if err != nil { + imageConnection.Close() + return nil, fmt.Errorf("failed to create new image:\n%w", err) + } + + return imageConnection, nil +} + +func createNewImageHelper(imageConnection *ImageConnection, filename string, diskConfig imagecustomizerapi.Disk, + partitionSettings []imagecustomizerapi.PartitionSetting, bootType imagecustomizerapi.BootType, buildDir string, + chrootDirName string, installOS installOSFunc, +) error { + // Convert config to image config types, so that the imager's utils can be used. + imagerBootType, err := bootTypeToImager(bootType) + if err != nil { + return err + } + + imagerDiskConfig, err := diskConfigToImager(diskConfig) + if err != nil { + return err + } + + imagerPartitionSettings, err := partitionSettingsToImager(partitionSettings) + if err != nil { + return err + } + + // Sort the partitions so that they are mounted in the correct oder. + sort.Slice(imagerPartitionSettings, func(i, j int) bool { + return imagerPartitionSettings[i].MountPoint < imagerPartitionSettings[j].MountPoint + }) + + // Create imager boilerplate. + mountPointMap, err := createImageBoilerplate(imageConnection, filename, buildDir, chrootDirName, imagerDiskConfig, + imagerPartitionSettings) + if err != nil { + return err + } + + // Install the OS. + err = installOS(imageConnection.Chroot()) + if err != nil { + return err + } + + // Configure the boot loader. + err = installutils.ConfigureDiskBootloader(imagerBootType, false, false, imagerPartitionSettings, + configuration.KernelCommandLine{}, imageConnection.Chroot(), imageConnection.Loopback().DevicePath(), + mountPointMap, diskutils.EncryptedRootDevice{}, diskutils.VerityDevice{}) + if err != nil { + return fmt.Errorf("failed to install bootloader:\n%w", err) + } + + return nil +} + +func createImageBoilerplate(imageConnection *ImageConnection, filename string, buildDir string, chrootDirName string, + imagerDiskConfig configuration.Disk, imagerPartitionSettings []configuration.PartitionSetting, +) (map[string]string, error) { + // Create raw disk image file. + err := diskutils.CreateSparseDisk(filename, imagerDiskConfig.MaxSize, 0o644) + if err != nil { + return nil, fmt.Errorf("failed to create empty disk file (%s):\n%w", filename, err) + } + + // Connect raw disk image file. + err = imageConnection.ConnectLoopback(filename) + if err != nil { + return nil, err + } + + // Set up partitions. + partIDToDevPathMap, partIDToFsTypeMap, _, _, err := diskutils.CreatePartitions( + imageConnection.Loopback().DevicePath(), imagerDiskConfig, configuration.RootEncryption{}, + configuration.ReadOnlyVerityRoot{}) + if err != nil { + return nil, fmt.Errorf("failed to create partitions on disk (%s):\n%w", imageConnection.Loopback().DevicePath(), err) + } + + // Read the disk partitions. + diskPartitions, err := diskutils.GetDiskPartitions(imageConnection.Loopback().DevicePath()) + if err != nil { + return nil, err + } + + // Create the fstab file. + // This is done so that we can read back the file using findmnt, which conveniently splits the vfs and fs mount + // options for us. If we wanted to handle this more directly, we could create a golang wrapper around libmount + // (which is what findmnt uses). But we are already using the findmnt in other places. + tmpFstabFile := filepath.Join(buildDir, chrootDirName+"_fstab") + + mountPointMap, mountPointToFsTypeMap, mountPointToMountArgsMap, _ := installutils.CreateMountPointPartitionMap( + partIDToDevPathMap, partIDToFsTypeMap, imagerPartitionSettings, + ) + + err = installutils.UpdateFstabFile(tmpFstabFile, imagerPartitionSettings, mountPointMap, mountPointToFsTypeMap, + mountPointToMountArgsMap, partIDToDevPathMap, partIDToFsTypeMap, false, /*hidepidEnabled*/ + ) + if err != nil { + return nil, fmt.Errorf("failed to write temp fstab file:\n%w", err) + } + + // Read back the fstab file. + mountPoints, err := findMountsFromFstabFile(tmpFstabFile, diskPartitions) + if err != nil { + return nil, err + } + + // Create chroot environment. + imageChrootDir := filepath.Join(buildDir, chrootDirName) + + err = imageConnection.ConnectChroot(imageChrootDir, false, nil, mountPoints) + if err != nil { + return nil, err + } + + // Move the fstab file into the image. + imageFstabFilePath := filepath.Join(imageConnection.Chroot().RootDir(), "etc/fstab") + + err = file.Move(tmpFstabFile, imageFstabFilePath) + if err != nil { + return nil, fmt.Errorf("failed to move fstab into new image:\n%w", err) + } + + return mountPointMap, nil +} diff --git a/toolkit/tools/pkg/imagecustomizerlib/partitionutils.go b/toolkit/tools/pkg/imagecustomizerlib/partitionutils.go index 30d08faba8b..407c6233a24 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/partitionutils.go +++ b/toolkit/tools/pkg/imagecustomizerlib/partitionutils.go @@ -273,7 +273,8 @@ func findMountsFromRootfs(rootfsPartition *diskutils.PartitionInfo, diskPartitio tmpDir := filepath.Join(buildDir, tmpParitionDirName) // Temporarily mount the rootfs partition so that the fstab file can be read. - rootfsPartitionMount, err := safemount.NewMount(rootfsPartition.Path, tmpDir, rootfsPartition.FileSystemType, 0, "", true) + rootfsPartitionMount, err := safemount.NewMount(rootfsPartition.Path, tmpDir, rootfsPartition.FileSystemType, 0, "", + true) if err != nil { return nil, fmt.Errorf("failed to mount rootfs partition (%s):\n%w", rootfsPartition.Path, err) } @@ -281,7 +282,8 @@ func findMountsFromRootfs(rootfsPartition *diskutils.PartitionInfo, diskPartitio // Read the fstab file. fstabPath := filepath.Join(tmpDir, "/etc/fstab") - fstabEntries, err := diskutils.ReadFstabFile(fstabPath) + + mountPoints, err := findMountsFromFstabFile(fstabPath, diskPartitions) if err != nil { return nil, err } @@ -292,6 +294,17 @@ func findMountsFromRootfs(rootfsPartition *diskutils.PartitionInfo, diskPartitio return nil, fmt.Errorf("failed to close rootfs partition mount (%s):\n%w", rootfsPartition.Path, err) } + return mountPoints, nil +} + +func findMountsFromFstabFile(fstabPath string, diskPartitions []diskutils.PartitionInfo, +) ([]*safechroot.MountPoint, error) { + // Read the fstab file. + fstabEntries, err := diskutils.ReadFstabFile(fstabPath) + if err != nil { + return nil, err + } + mountPoints, err := fstabEntriesToMountPoints(fstabEntries, diskPartitions) if err != nil { return nil, err @@ -300,7 +313,8 @@ func findMountsFromRootfs(rootfsPartition *diskutils.PartitionInfo, diskPartitio return mountPoints, nil } -func fstabEntriesToMountPoints(fstabEntries []diskutils.FstabEntry, diskPartitions []diskutils.PartitionInfo) ([]*safechroot.MountPoint, error) { +func fstabEntriesToMountPoints(fstabEntries []diskutils.FstabEntry, diskPartitions []diskutils.PartitionInfo, +) ([]*safechroot.MountPoint, error) { // Convert fstab entries into mount points. var mountPoints []*safechroot.MountPoint var foundRoot bool diff --git a/toolkit/tools/pkg/imagecustomizerlib/testdata/legacyboot-config.yaml b/toolkit/tools/pkg/imagecustomizerlib/testdata/legacyboot-config.yaml new file mode 100644 index 00000000000..b17a3857e06 --- /dev/null +++ b/toolkit/tools/pkg/imagecustomizerlib/testdata/legacyboot-config.yaml @@ -0,0 +1,22 @@ +Disks: +- PartitionTableType: gpt + MaxSize: 4096 + Partitions: + - ID: boot + Flags: + - bios_grub + Start: 1 + Size: 8 + FsType: fat32 + + - ID: rootfs + Start: 9 + FsType: ext4 + +SystemConfig: + BootType: legacy + PartitionSettings: + - ID: boot + + - ID: rootfs + MountPoint: / diff --git a/toolkit/tools/pkg/imagecustomizerlib/testdata/partitions-config.yaml b/toolkit/tools/pkg/imagecustomizerlib/testdata/partitions-config.yaml new file mode 100644 index 00000000000..922612591a4 --- /dev/null +++ b/toolkit/tools/pkg/imagecustomizerlib/testdata/partitions-config.yaml @@ -0,0 +1,41 @@ +Disks: +- PartitionTableType: gpt + MaxSize: 4096 + Partitions: + - ID: esp + Flags: + - esp + - boot + Start: 1 + End: 9 + FsType: fat32 + + - ID: boot + Start: 9 + End: 108 + FsType: ext4 + + - ID: rootfs + Start: 108 + End: 2048 + FsType: xfs + + - ID: var + Start: 2048 + FsType: xfs + +SystemConfig: + BootType: efi + PartitionSettings: + - ID: esp + MountPoint: /boot/efi + MountOptions: umask=0077 + + - ID: boot + MountPoint: /boot + + - ID: rootfs + MountPoint: / + + - ID: var + MountPoint: /var diff --git a/toolkit/tools/pkg/imagecustomizerlib/typeConversion.go b/toolkit/tools/pkg/imagecustomizerlib/typeConversion.go new file mode 100644 index 00000000000..00305b536aa --- /dev/null +++ b/toolkit/tools/pkg/imagecustomizerlib/typeConversion.go @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imagecustomizerlib + +import ( + "fmt" + + "github.com/microsoft/CBL-Mariner/toolkit/tools/imagecustomizerapi" + "github.com/microsoft/CBL-Mariner/toolkit/tools/imagegen/configuration" +) + +func bootTypeToImager(bootType imagecustomizerapi.BootType) (string, error) { + switch bootType { + case imagecustomizerapi.BootTypeEfi: + return "efi", nil + + case imagecustomizerapi.BootTypeLegacy: + return "legacy", nil + + default: + return "", fmt.Errorf("invalid BootType value (%s)", bootType) + } +} + +func diskConfigToImager(diskConfig imagecustomizerapi.Disk) (configuration.Disk, error) { + imagerPartitionTableType, err := partitionTableTypeToImager(diskConfig.PartitionTableType) + if err != nil { + return configuration.Disk{}, err + } + + imagerPartitions, err := partitionsToImager(diskConfig.Partitions) + if err != nil { + return configuration.Disk{}, err + } + + imagerDisk := configuration.Disk{ + PartitionTableType: imagerPartitionTableType, + MaxSize: diskConfig.MaxSize, + Partitions: imagerPartitions, + } + return imagerDisk, err +} + +func partitionTableTypeToImager(partitionTableType imagecustomizerapi.PartitionTableType, +) (configuration.PartitionTableType, error) { + switch partitionTableType { + case imagecustomizerapi.PartitionTableTypeGpt: + return configuration.PartitionTableTypeGpt, nil + + default: + return "", fmt.Errorf("unknown partition table type (%s)", partitionTableType) + } +} + +func partitionsToImager(partitions []imagecustomizerapi.Partition) ([]configuration.Partition, error) { + imagerPartitions := []configuration.Partition(nil) + for _, partition := range partitions { + imagerPartition, err := partitionToImager(partition) + if err != nil { + return nil, err + } + + imagerPartitions = append(imagerPartitions, imagerPartition) + } + + return imagerPartitions, nil +} + +func partitionToImager(partition imagecustomizerapi.Partition) (configuration.Partition, error) { + imagerEnd, _ := partition.GetEnd() + + imagerFlags, err := partitionFlagsToImager(partition.Flags) + if err != nil { + return configuration.Partition{}, err + } + + imagerPartition := configuration.Partition{ + ID: partition.ID, + FsType: string(partition.FsType), + Name: partition.Name, + Start: partition.Start, + End: imagerEnd, + Flags: imagerFlags, + } + return imagerPartition, nil +} + +func partitionFlagsToImager(flags []imagecustomizerapi.PartitionFlag) ([]configuration.PartitionFlag, error) { + imagerFlags := []configuration.PartitionFlag(nil) + for _, flag := range flags { + imagerFlag, err := partitionFlagToImager(flag) + if err != nil { + return nil, err + } + + imagerFlags = append(imagerFlags, imagerFlag) + } + + return imagerFlags, nil +} + +func partitionFlagToImager(flag imagecustomizerapi.PartitionFlag) (configuration.PartitionFlag, error) { + switch flag { + case imagecustomizerapi.PartitionFlagESP: + return configuration.PartitionFlagESP, nil + + case imagecustomizerapi.PartitionFlagBiosGrub: + return configuration.PartitionFlagBiosGrub, nil + + case imagecustomizerapi.PartitionFlagBoot: + return configuration.PartitionFlagBoot, nil + + default: + return "", fmt.Errorf("unknown partition flag (%s)", flag) + } +} + +func partitionSettingsToImager(partitionSettings []imagecustomizerapi.PartitionSetting, +) ([]configuration.PartitionSetting, error) { + imagerPartitionSettings := []configuration.PartitionSetting(nil) + for _, partitionSetting := range partitionSettings { + imagerPartitionSetting, err := partitionSettingToImager(partitionSetting) + if err != nil { + return nil, err + } + imagerPartitionSettings = append(imagerPartitionSettings, imagerPartitionSetting) + } + return imagerPartitionSettings, nil +} + +func partitionSettingToImager(partitionSettings imagecustomizerapi.PartitionSetting, +) (configuration.PartitionSetting, error) { + imagerMountIdentifierType, err := mountIdentifierTypeToImager(partitionSettings.MountIdentifier) + if err != nil { + return configuration.PartitionSetting{}, err + } + + imagerPartitionSetting := configuration.PartitionSetting{ + ID: partitionSettings.ID, + MountIdentifier: imagerMountIdentifierType, + MountOptions: partitionSettings.MountOptions, + MountPoint: partitionSettings.MountPoint, + } + return imagerPartitionSetting, nil +} + +func mountIdentifierTypeToImager(mountIdentifierType imagecustomizerapi.MountIdentifierType, +) (configuration.MountIdentifier, error) { + switch mountIdentifierType { + case imagecustomizerapi.MountIdentifierTypeUuid: + return configuration.MountIdentifierUuid, nil + + case imagecustomizerapi.MountIdentifierTypePartUuid, imagecustomizerapi.MountIdentifierTypeDefault: + return configuration.MountIdentifierPartUuid, nil + + case imagecustomizerapi.MountIdentifierTypePartLabel: + return configuration.MountIdentifierPartLabel, nil + + default: + return "", fmt.Errorf("unknwon MountIdentifierType value (%s)", mountIdentifierType) + } +}