diff --git a/pkg/blueprint/customizations.go b/pkg/blueprint/customizations.go index 28ea761051..d4d8746a35 100644 --- a/pkg/blueprint/customizations.go +++ b/pkg/blueprint/customizations.go @@ -19,6 +19,7 @@ type Customizations struct { Firewall *FirewallCustomization `json:"firewall,omitempty" toml:"firewall,omitempty"` Services *ServicesCustomization `json:"services,omitempty" toml:"services,omitempty"` Filesystem []FilesystemCustomization `json:"filesystem,omitempty" toml:"filesystem,omitempty"` + Disk *DiskCustomization `json:"disk,omitempty" toml:"disk,omitempty"` InstallationDevice string `json:"installation_device,omitempty" toml:"installation_device,omitempty"` FDO *FDOCustomization `json:"fdo,omitempty" toml:"fdo,omitempty"` OpenSCAP *OpenSCAPCustomization `json:"openscap,omitempty" toml:"openscap,omitempty"` @@ -311,6 +312,13 @@ func (c *Customizations) GetFilesystemsMinSize() uint64 { return agg } +func (c *Customizations) GetPartitioning() *DiskCustomization { + if c == nil { + return nil + } + return c.Disk +} + func (c *Customizations) GetInstallationDevice() string { if c == nil || c.InstallationDevice == "" { return "" diff --git a/pkg/blueprint/filesystem_customizations.go b/pkg/blueprint/filesystem_customizations.go index 68c7126f06..e783930052 100644 --- a/pkg/blueprint/filesystem_customizations.go +++ b/pkg/blueprint/filesystem_customizations.go @@ -24,26 +24,13 @@ func (fsc *FilesystemCustomization) UnmarshalTOML(data interface{}) error { case string: fsc.Mountpoint = d["mountpoint"].(string) default: - return fmt.Errorf("TOML unmarshal: mountpoint must be string, got %v of type %T", d["mountpoint"], d["mountpoint"]) + return fmt.Errorf("TOML unmarshal: mountpoint must be string, got \"%v\" of type %T", d["mountpoint"], d["mountpoint"]) } - - switch d["minsize"].(type) { - case int64: - minSize := d["minsize"].(int64) - if minSize < 0 { - return fmt.Errorf("TOML unmarshal: minsize cannot be negative") - } - fsc.MinSize = uint64(minSize) - case string: - minSize, err := datasizes.Parse(d["minsize"].(string)) - if err != nil { - return fmt.Errorf("TOML unmarshal: minsize is not valid filesystem size (%w)", err) - } - fsc.MinSize = minSize - default: - return fmt.Errorf("TOML unmarshal: minsize must be integer or string, got %v of type %T", d["minsize"], d["minsize"]) + minSize, err := decodeSize(d["minsize"]) + if err != nil { + return fmt.Errorf("TOML unmarshal: error decoding minsize value for mountpoint %q: %w", fsc.Mountpoint, err) } - + fsc.MinSize = minSize return nil } @@ -58,23 +45,14 @@ func (fsc *FilesystemCustomization) UnmarshalJSON(data []byte) error { case string: fsc.Mountpoint = d["mountpoint"].(string) default: - return fmt.Errorf("JSON unmarshal: mountpoint must be string, got %v of type %T", d["mountpoint"], d["mountpoint"]) + return fmt.Errorf("JSON unmarshal: mountpoint must be string, got \"%v\" of type %T", d["mountpoint"], d["mountpoint"]) } - // The JSON specification only mentions float64 and Go defaults to it: https://go.dev/blog/json - switch d["minsize"].(type) { - case float64: - fsc.MinSize = uint64(d["minsize"].(float64)) - case string: - minSize, err := datasizes.Parse(d["minsize"].(string)) - if err != nil { - return fmt.Errorf("JSON unmarshal: minsize is not valid filesystem size (%w)", err) - } - fsc.MinSize = minSize - default: - return fmt.Errorf("JSON unmarshal: minsize must be float64 number or string, got %v of type %T", d["minsize"], d["minsize"]) + minSize, err := decodeSize(d["minsize"]) + if err != nil { + return fmt.Errorf("JSON unmarshal: error decoding minsize value for mountpoint %q: %w", fsc.Mountpoint, err) } - + fsc.MinSize = minSize return nil } @@ -93,3 +71,27 @@ func CheckMountpointsPolicy(mountpoints []FilesystemCustomization, mountpointAll return nil } + +// decodeSize takes an integer or string representing a data size (with a data +// suffix) and returns the uint64 representation. +func decodeSize(size any) (uint64, error) { + switch s := size.(type) { + case string: + return datasizes.Parse(s) + case int64: + if s < 0 { + return 0, fmt.Errorf("cannot be negative") + } + return uint64(s), nil + case float64: + if s < 0 { + return 0, fmt.Errorf("cannot be negative") + } + // TODO: emit warning of possible truncation? + return uint64(s), nil + case uint64: + return s, nil + default: + return 0, fmt.Errorf("failed to convert value \"%v\" to number", size) + } +} diff --git a/pkg/blueprint/filesystem_customizations_test.go b/pkg/blueprint/filesystem_customizations_test.go index 4ea1dcf8f9..cb4860c525 100644 --- a/pkg/blueprint/filesystem_customizations_test.go +++ b/pkg/blueprint/filesystem_customizations_test.go @@ -47,19 +47,19 @@ func TestFilesystemCustomizationUnmarshalTOMLUnhappy(t *testing.T) { name: "mountpoint not string", input: `mountpoint = 42 minsize = 42`, - err: "toml: line 0: TOML unmarshal: mountpoint must be string, got 42 of type int64", + err: `toml: line 0: TOML unmarshal: mountpoint must be string, got "42" of type int64`, }, { name: "misize nor string nor int", input: `mountpoint="/" minsize = true`, - err: "toml: line 0: TOML unmarshal: minsize must be integer or string, got true of type bool", + err: `toml: line 0: TOML unmarshal: error decoding minsize value for mountpoint "/": failed to convert value "true" to number`, }, { name: "misize not parseable", input: `mountpoint="/" minsize = "20 KG"`, - err: "toml: line 0: TOML unmarshal: minsize is not valid filesystem size (unknown data size units in string: 20 KG)", + err: `toml: line 0: TOML unmarshal: error decoding minsize value for mountpoint "/": unknown data size units in string: 20 KG`, }, } @@ -81,17 +81,17 @@ func TestFilesystemCustomizationUnmarshalJSONUnhappy(t *testing.T) { { name: "mountpoint not string", input: `{"mountpoint": 42, "minsize": 42}`, - err: "JSON unmarshal: mountpoint must be string, got 42 of type float64", + err: `JSON unmarshal: mountpoint must be string, got "42" of type float64`, }, { name: "misize nor string nor int", input: `{"mountpoint":"/", "minsize": true}`, - err: "JSON unmarshal: minsize must be float64 number or string, got true of type bool", + err: `JSON unmarshal: error decoding minsize value for mountpoint "/": failed to convert value "true" to number`, }, { name: "misize not parseable", input: `{ "mountpoint": "/", "minsize": "20 KG"}`, - err: "JSON unmarshal: minsize is not valid filesystem size (unknown data size units in string: 20 KG)", + err: `JSON unmarshal: error decoding minsize value for mountpoint "/": unknown data size units in string: 20 KG`, }, } diff --git a/pkg/blueprint/partitioning_customizations.go b/pkg/blueprint/partitioning_customizations.go new file mode 100644 index 0000000000..800063a2b9 --- /dev/null +++ b/pkg/blueprint/partitioning_customizations.go @@ -0,0 +1,545 @@ +package blueprint + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "slices" + "strings" + + "github.com/osbuild/images/pkg/pathpolicy" +) + +type DiskCustomization struct { + // TODO: Add partition table type: gpt or dos + MinSize uint64 `json:"minsize,omitempty" toml:"minsize,omitempty"` + Partitions []PartitionCustomization `json:"partitions,omitempty" toml:"partitions,omitempty"` +} + +// PartitionCustomization defines a single partition on a disk. The Type +// defines the kind of "payload" for the partition: plain, lvm, or btrfs. +// - plain: the payload will be a filesystem on a partition (e.g. xfs, ext4). +// See [FilesystemTypedCustomization] for extra fields. +// - lvm: the payload will be an LVM volume group. See [VGCustomization] for +// extra fields +// - btrfs: the payload will be a btrfs volume. See +// [BtrfsVolumeCustomization] for extra fields. +type PartitionCustomization struct { + // The type of payload for the partition (optional, defaults to "plain"). + Type string `json:"type" toml:"type"` + + // Minimum size of the partition that contains the filesystem (for "plain" + // filesystem), volume group ("lvm"), or btrfs volume ("btrfs"). The final + // size of the partition will be larger than the minsize if the sum of the + // contained volumes (logical volumes or subvolumes) is larger. In + // addition, certain mountpoints have required minimum sizes. See + // https://osbuild.org/docs/user-guide/partitioning for more details. + // (optional, defaults depend on payload and mountpoints). + MinSize uint64 `json:"minsize" toml:"minsize"` + + BtrfsVolumeCustomization + + VGCustomization + + FilesystemTypedCustomization +} + +// A filesystem on a plain partition or LVM logical volume. +// Note the differences from [FilesystemCustomization]: +// - Adds a label. +// - Adds a filesystem type (fs_type). +// - Does not define a size. The size is defined by its container: a +// partition ([PartitionCustomization]) or LVM logical volume +// ([LVCustomization]). +type FilesystemTypedCustomization struct { + Mountpoint string `json:"mountpoint" toml:"mountpoint"` + Label string `json:"label,omitempty" toml:"label,omitempty"` + FSType string `json:"fs_type,omitempty" toml:"fs_type,omitempty"` +} + +// An LVM volume group with one or more logical volumes. +type VGCustomization struct { + // Volume group name (optional, default will be automatically generated). + Name string `json:"name" toml:"name"` + LogicalVolumes []LVCustomization `json:"logical_volumes,omitempty" toml:"logical_volumes,omitempty"` +} + +type LVCustomization struct { + // Logical volume name + Name string `json:"name,omitempty" toml:"name,omitempty"` + + // Minimum size of the logical volume + MinSize uint64 `json:"minsize,omitempty" toml:"minsize,omitempty"` + + FilesystemTypedCustomization +} + +// Custom JSON unmarshaller for LVCustomization for handling the conversion of +// data sizes (minsize) expressed as strings to uint64. +func (lv *LVCustomization) UnmarshalJSON(data []byte) error { + var lvAnySize struct { + Name string `json:"name,omitempty" toml:"name,omitempty"` + MinSize any `json:"minsize,omitempty" toml:"minsize,omitempty"` + FilesystemTypedCustomization + } + if err := json.Unmarshal(data, &lvAnySize); err != nil { + return err + } + + lv.Name = lvAnySize.Name + lv.FilesystemTypedCustomization = lvAnySize.FilesystemTypedCustomization + + if lvAnySize.MinSize != nil { + size, err := decodeSize(lvAnySize.MinSize) + if err != nil { + return err + } + lv.MinSize = size + } + return nil +} + +// A btrfs volume consisting of one or more subvolumes. +type BtrfsVolumeCustomization struct { + Subvolumes []BtrfsSubvolumeCustomization +} + +type BtrfsSubvolumeCustomization struct { + // The name of the subvolume, which defines the location (path) on the + // root volume (required). + // See https://btrfs.readthedocs.io/en/latest/Subvolumes.html + Name string `json:"name" toml:"name"` + + // Mountpoint for the subvolume. + Mountpoint string `json:"mountpoint" toml:"mountpoint"` +} + +// Custom JSON unmarshaller that first reads the value of the "type" field and +// then deserialises the whole object into a struct that only contains the +// fields valid for that partition type. This ensures that no fields are set +// for the substructure of a different type than the one defined in the "type" +// fields. +func (v *PartitionCustomization) UnmarshalJSON(data []byte) error { + errPrefix := "JSON unmarshal:" + var typeSniffer struct { + Type string `json:"type"` + MinSize any `json:"minsize"` + } + if err := json.Unmarshal(data, &typeSniffer); err != nil { + return fmt.Errorf("%s %w", errPrefix, err) + } + + partType := "plain" + if typeSniffer.Type != "" { + partType = typeSniffer.Type + } + + switch partType { + case "plain": + if err := decodePlain(v, data); err != nil { + return fmt.Errorf("%s %w", errPrefix, err) + } + case "btrfs": + if err := decodeBtrfs(v, data); err != nil { + return fmt.Errorf("%s %w", errPrefix, err) + } + case "lvm": + if err := decodeLVM(v, data); err != nil { + return fmt.Errorf("%s %w", errPrefix, err) + } + default: + return fmt.Errorf("%s unknown partition type: %s", errPrefix, partType) + } + + v.Type = partType + + if typeSniffer.MinSize != nil { + minsize, err := decodeSize(typeSniffer.MinSize) + if err != nil { + return fmt.Errorf("%s error decoding minsize for partition: %w", errPrefix, err) + } + v.MinSize = minsize + } + + return nil +} + +// decodePlain decodes the data into a struct that only embeds the +// FilesystemCustomization with DisallowUnknownFields. This ensures that when +// 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 + // satisfy "DisallowUnknownFields" when decoding. + Type string `json:"type"` + MinSize any `json:"minsize"` + FilesystemTypedCustomization + } + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err := decoder.Decode(&plain) + if err != nil { + return fmt.Errorf("error decoding partition with type \"plain\": %w", err) + } + + v.FilesystemTypedCustomization = plain.FilesystemTypedCustomization + return nil +} + +// descodeBtrfs decodes the data into a struct that only embeds the +// BtrfsVolumeCustomization with DisallowUnknownFields. This ensures that when +// 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 + // satisfy "DisallowUnknownFields" when decoding. + Type string `json:"type"` + MinSize any `json:"minsize"` + BtrfsVolumeCustomization + } + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err := decoder.Decode(&btrfs) + if err != nil { + return fmt.Errorf("error decoding partition with type \"btrfs\": %w", err) + } + + v.BtrfsVolumeCustomization = btrfs.BtrfsVolumeCustomization + return nil +} + +// decodeLVM decodes the data into a struct that only embeds the +// VGCustomization with DisallowUnknownFields. This ensures that when the type +// 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 + // satisfy "DisallowUnknownFields" when decoding. + Type string `json:"type"` + MinSize any `json:"minsize"` + VGCustomization + } + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&vg); err != nil { + return fmt.Errorf("error decoding partition with type \"lvm\": %w", err) + } + + v.VGCustomization = vg.VGCustomization + return nil +} + +// Custom TOML unmarshaller that first reads the value of the "type" field and +// then deserialises the whole object into a struct that only contains the +// fields valid for that partition type. This ensures that no fields are set +// for the substructure of a different type than the one defined in the "type" +// fields. +func (v *PartitionCustomization) UnmarshalTOML(data any) error { + errPrefix := "TOML unmarshal:" + d, ok := data.(map[string]any) + if !ok { + return fmt.Errorf("%s customizations.partition is not an object", errPrefix) + } + + partType := "plain" + if typeField, ok := d["type"]; ok { + typeStr, ok := typeField.(string) + if !ok { + return fmt.Errorf("%s type must be a string, got \"%v\" of type %T", errPrefix, typeField, typeField) + } + partType = typeStr + } + + // serialise the data to JSON and reuse the subobject decoders + dataJSON, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("%s error while decoding partition customization: %w", errPrefix, err) + } + switch partType { + case "plain": + if err := decodePlain(v, dataJSON); err != nil { + return fmt.Errorf("%s %w", errPrefix, err) + } + case "btrfs": + if err := decodeBtrfs(v, dataJSON); err != nil { + return fmt.Errorf("%s %w", errPrefix, err) + } + case "lvm": + if err := decodeLVM(v, dataJSON); err != nil { + return fmt.Errorf("%s %w", errPrefix, err) + } + default: + return fmt.Errorf("%s unknown partition type: %s", errPrefix, partType) + } + + v.Type = partType + + if minsizeField, ok := d["minsize"]; ok { + minsize, err := decodeSize(minsizeField) + if err != nil { + return fmt.Errorf("%s error decoding minsize for partition: %w", errPrefix, err) + } + v.MinSize = minsize + } + + return nil +} + +// Validate checks for customization combinations that are generally not +// supported or can create conflicts, regardless of specific distro or image +// type policies. The validator ensures all of the following properties: +// - All mountpoints are valid +// - All mountpoints are unique +// - All LVM volume group names are unique +// - All LVM logical volume names are unique within a given volume group +// - All btrfs subvolume names are unique within a given btrfs volume +// - All btrfs subvolume names are valid and non-empty +// - All filesystems are valid for their mountpoints (e.g. xfs or ext4 for /boot) +// - No LVM logical volume has an invalid mountpoint (/boot or /boot/efi) +// - Plain filesystem types are valid for the partition type +// - All non-empty properties are valid for the partition type (e.g. +// LogicalVolumes is empty when the type is "plain" or "btrfs") +func (p *DiskCustomization) Validate() error { + if p == nil { + return nil + } + + mountpoints := make(map[string]bool) + vgnames := make(map[string]bool) + var errs []error + for _, part := range p.Partitions { + switch part.Type { + case "lvm": + errs = append(errs, part.validateLVM(mountpoints, vgnames)) + case "btrfs": + errs = append(errs, part.validateBtrfs(mountpoints)) + case "plain", "": + errs = append(errs, part.validatePlain(mountpoints)) + default: + errs = append(errs, fmt.Errorf("unknown partition type: %s", part.Type)) + } + } + + // will discard all nil errors + if err := errors.Join(errs...); err != nil { + return fmt.Errorf("invalid partitioning customizations:\n%w", err) + } + return nil +} + +func validateMountpoint(path string) error { + if path == "" { + return fmt.Errorf("mountpoint is empty") + } + + if !strings.HasPrefix(path, "/") { + return fmt.Errorf("mountpoint %q is not an absolute path", path) + } + + if cleanPath := filepath.Clean(path); path != cleanPath { + return fmt.Errorf("mountpoint %q is not a canonical path (did you mean %q?)", path, cleanPath) + } + + return nil +} + +// ValidateLayoutConstraints checks that at most one LVM Volume Group or btrfs +// volume is defined. Returns an error if both LVM and btrfs are set and if +// either has more than one element. +func (p *DiskCustomization) ValidateLayoutConstraints() error { + if p == nil { + return nil + } + + var btrfsVols, lvmVGs uint + for _, part := range p.Partitions { + switch part.Type { + case "lvm": + lvmVGs++ + case "btrfs": + btrfsVols++ + } + if lvmVGs > 0 && btrfsVols > 0 { + return fmt.Errorf("btrfs and lvm partitioning cannot be combined") + } + } + + if btrfsVols > 1 { + return fmt.Errorf("multiple btrfs volumes are not yet supported") + } + + if lvmVGs > 1 { + return fmt.Errorf("multiple LVM volume groups are not yet supported") + } + + return nil +} + +// Check that the fs type is valid for the mountpoint. +func validateFilesystemType(path, fstype string) error { + badfsMsg := "unsupported filesystem type for %q: %s" + switch path { + case "/boot": + switch fstype { + case "xfs", "ext4": + default: + return fmt.Errorf(badfsMsg, path, fstype) + } + case "/boot/efi": + switch fstype { + case "vfat": + default: + return fmt.Errorf(badfsMsg, path, fstype) + } + } + return nil +} + +// These mountpoints must be on a plain partition (i.e. not on LVM or btrfs). +var plainOnlyMountpoints = []string{ + "/boot", + "/boot/efi", // not allowed by our global policies, but that might change +} + +var validPlainFSTypes = []string{ + "ext4", + "vfat", + "xfs", +} + +func (p *PartitionCustomization) validatePlain(mountpoints map[string]bool) error { + if err := validateMountpoint(p.Mountpoint); err != nil { + return err + } + if mountpoints[p.Mountpoint] { + return fmt.Errorf("duplicate mountpoint %q in partitioning customizations", p.Mountpoint) + } + // TODO: allow empty fstype with default from distro + if !slices.Contains(validPlainFSTypes, p.FSType) { + return fmt.Errorf("unknown or invalid filesystem type for mountpoint %q: %s", p.Mountpoint, p.FSType) + } + if err := validateFilesystemType(p.Mountpoint, p.FSType); err != nil { + return err + } + + mountpoints[p.Mountpoint] = true + return nil +} + +func (p *PartitionCustomization) validateLVM(mountpoints, vgnames map[string]bool) error { + if p.Name != "" && vgnames[p.Name] { // VGs with no name get autogenerated names + return fmt.Errorf("duplicate LVM volume group name %q in partitioning customizations", p.Name) + } + + // check for invalid property usage + if len(p.Subvolumes) > 0 { + return fmt.Errorf("subvolumes defined for LVM volume group (partition type \"lvm\")") + } + + if p.Label != "" { + return fmt.Errorf("label %q defined for LVM volume group (partition type \"lvm\")", p.Label) + } + + vgnames[p.Name] = true + lvnames := make(map[string]bool) + for _, lv := range p.LogicalVolumes { + if lv.Name != "" && lvnames[lv.Name] { // LVs with no name get autogenerated names + return fmt.Errorf("duplicate LVM logical volume name %q in volume group %q in partitioning customizations", lv.Name, p.Name) + } + lvnames[lv.Name] = true + + if err := validateMountpoint(lv.Mountpoint); err != nil { + return fmt.Errorf("invalid logical volume customization: %w", err) + } + if mountpoints[lv.Mountpoint] { + return fmt.Errorf("duplicate mountpoint %q in partitioning customizations", lv.Mountpoint) + } + mountpoints[lv.Mountpoint] = true + + if slices.Contains(plainOnlyMountpoints, lv.Mountpoint) { + return fmt.Errorf("invalid mountpoint %q for logical volume", lv.Mountpoint) + } + + // TODO: allow empty fstype with default from distro + if !slices.Contains(validPlainFSTypes, lv.FSType) { + return fmt.Errorf("unknown or invalid filesystem type for logical volume with mountpoint %q: %s", lv.Mountpoint, lv.FSType) + } + } + return nil +} + +func (p *PartitionCustomization) validateBtrfs(mountpoints map[string]bool) error { + if p.Mountpoint != "" { + return fmt.Errorf(`"mountpoint" is not supported for btrfs volumes (only subvolumes can have mountpoints)`) + } + + if len(p.Subvolumes) == 0 { + return fmt.Errorf("btrfs volume requires subvolumes") + } + + if len(p.LogicalVolumes) > 0 { + return fmt.Errorf("LVM logical volumes defined for btrfs volume (partition type \"btrfs\")") + } + + subvolnames := make(map[string]bool) + for _, subvol := range p.Subvolumes { + if subvol.Name == "" { + return fmt.Errorf("btrfs subvolume with empty name in partitioning customizations") + } + if subvolnames[subvol.Name] { + return fmt.Errorf("duplicate btrfs subvolume name %q in partitioning customizations", subvol.Name) + } + subvolnames[subvol.Name] = true + + if err := validateMountpoint(subvol.Mountpoint); err != nil { + return fmt.Errorf("invalid btrfs subvolume customization: %w", err) + } + if mountpoints[subvol.Mountpoint] { + return fmt.Errorf("duplicate mountpoint %q in partitioning customizations", subvol.Mountpoint) + } + if slices.Contains(plainOnlyMountpoints, subvol.Mountpoint) { + return fmt.Errorf("invalid mountpoint %q for btrfs subvolume", subvol.Mountpoint) + } + mountpoints[subvol.Mountpoint] = true + } + return nil +} + +// CheckDiskMountpointsPolicy checks if the mountpoints under a [DiskCustomization] are allowed by the policy. +func CheckDiskMountpointsPolicy(partitioning *DiskCustomization, mountpointAllowList *pathpolicy.PathPolicies) error { + if partitioning == nil { + return nil + } + + // collect all mountpoints + var mountpoints []string + for _, part := range partitioning.Partitions { + if part.Mountpoint != "" { + mountpoints = append(mountpoints, part.Mountpoint) + } + for _, lv := range part.LogicalVolumes { + mountpoints = append(mountpoints, lv.Mountpoint) + } + for _, subvol := range part.Subvolumes { + mountpoints = append(mountpoints, subvol.Mountpoint) + } + } + + var errs []error + for _, mp := range mountpoints { + if err := mountpointAllowList.Check(mp); err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return fmt.Errorf("The following errors occurred while setting up custom mountpoints:\n%w", errors.Join(errs...)) + } + + return nil +} diff --git a/pkg/blueprint/partitioning_customizations_test.go b/pkg/blueprint/partitioning_customizations_test.go new file mode 100644 index 0000000000..980190015b --- /dev/null +++ b/pkg/blueprint/partitioning_customizations_test.go @@ -0,0 +1,1650 @@ +package blueprint_test + +import ( + "encoding/json" + "testing" + + "github.com/BurntSushi/toml" + "github.com/osbuild/images/pkg/blueprint" + "github.com/osbuild/images/pkg/datasizes" + "github.com/osbuild/images/pkg/pathpolicy" + "github.com/stretchr/testify/assert" +) + +func TestPartitioningValidation(t *testing.T) { + type testCase struct { + partitioning *blueprint.DiskCustomization + expectedMsg string + } + + testCases := map[string]testCase{ + "null": { + partitioning: nil, + expectedMsg: "", + }, + "happy-plain": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "xfs", + Mountpoint: "/data", + }, + }, + }, + }, + expectedMsg: "", + }, + "happy-plain+btrfs": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "xfs", + Mountpoint: "/data", + }, + }, + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "root", + Mountpoint: "/", + }, + }, + }, + }, + }, + }, + expectedMsg: "", + }, + "happy-plain+lvm": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "plain", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "xfs", + Mountpoint: "/data", + }, + }, + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + Name: "root", + LogicalVolumes: []blueprint.LVCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + }, + }, + }, + }, + }, + expectedMsg: "", + }, + "happy-plain-with-boot-and-efi": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "xfs", + Mountpoint: "/data", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/home", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/boot", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "vfat", + Mountpoint: "/boot/efi", + }, + }, + }, + }, + expectedMsg: "", + }, + "unhappy-plain-dupes": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "xfs", + Mountpoint: "/data", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/home", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "xfs", + Mountpoint: "/data", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nduplicate mountpoint \"/data\" in partitioning customizations", + }, + "unhappy-plain-badfstype": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ntfs", + Mountpoint: "/home", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nunknown or invalid filesystem type for mountpoint \"/home\": ntfs", + }, + "unhappy-plain-badfstype-boot": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "xfs", + Mountpoint: "/data", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/home", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "zfs", + Mountpoint: "/boot", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nunknown or invalid filesystem type for mountpoint \"/boot\": zfs", + }, + "unhappy-plain-badfstype-efi": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/home", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "vfat", + Mountpoint: "/boot", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/boot/efi", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nunsupported filesystem type for \"/boot\": vfat\nunsupported filesystem type for \"/boot/efi\": ext4", + }, + "unhappy-plain-btrfstype": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "btrfs", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\n\"mountpoint\" is not supported for btrfs volumes (only subvolumes can have mountpoints)", + }, + "unhappy-plain+btrfs-dupes": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "plain", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/data", + FSType: "xfs", + }, + }, + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "root", + Mountpoint: "/", + }, + { + Name: "home", + Mountpoint: "/home", + }, + { + Name: "data", + Mountpoint: "/data", + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nduplicate mountpoint \"/data\" in partitioning customizations", + }, + "unhappy-plain+lvm-dupes": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "plain", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/dupydupe", + FSType: "ext4", + }, + }, + { + Type: "plain", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/data", + FSType: "ext4", + }, + }, + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/home", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/dupydupe", + }, + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nduplicate mountpoint \"/dupydupe\" in partitioning customizations", + }, + "unhappy-emptymp": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nmountpoint is empty", + }, + "unhappy-relativemountpoint": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "xfs", + Mountpoint: "i-am-not-absolute", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nmountpoint \"i-am-not-absolute\" is not an absolute path", + }, + "unhappy-badmp": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "vfat", + Mountpoint: "/home/../root", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nmountpoint \"/home/../root\" is not a canonical path (did you mean \"/root\"?)", + }, + "unhappy-emptymp-btrfs": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "test", + Mountpoint: "/test", + }, + { + Name: "test2", + Mountpoint: "", + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\ninvalid btrfs subvolume customization: mountpoint is empty", + }, + "unhappy-relativemountpoint-btrfs": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "blorps", + Mountpoint: "blorpsmp", + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\ninvalid btrfs subvolume customization: mountpoint \"blorpsmp\" is not an absolute path", + }, + "unhappy-badmp-btrfs": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "borkage", + Mountpoint: "/home//bork", + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\ninvalid btrfs subvolume customization: mountpoint \"/home//bork\" is not a canonical path (did you mean \"/home/bork\"?)", + }, + "unhappy-emptymp-lvm": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "testlv", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + + FSType: "ext4", + Mountpoint: "/stuff", + }, + }, + { + Name: "testlv2", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "", + }, + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\ninvalid logical volume customization: mountpoint is empty", + }, + "unhappy-relativemountpoint-lvm": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "testlv", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "xfs", + Mountpoint: "/stuff", + }, + }, + { + Name: "testlv2", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "i/like/relative/paths", + }, + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\ninvalid logical volume customization: mountpoint \"i/like/relative/paths\" is not an absolute path", + }, + "unhappy-badmp-lvm": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "testlv", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/../../../what/", + }, + }, + { + Name: "testlv2", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/test", + }, + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\ninvalid logical volume customization: mountpoint \"/../../../what/\" is not a canonical path (did you mean \"/what\"?)", + }, + "unhappy-dupesubvolname": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "root", + Mountpoint: "/", + }, + { + Name: "root", + Mountpoint: "/root", + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nduplicate btrfs subvolume name \"root\" in partitioning customizations", + }, + "unhappy-dupelvname": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "testlv", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/stuff", + }, + }, + { + Name: "testlv", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/stuff2", + }, + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nduplicate LVM logical volume name \"testlv\" in volume group \"\" in partitioning customizations", + }, + "unhappy-vg-with-subvols": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{{}}, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nsubvolumes defined for LVM volume group (partition type \"lvm\")", + }, + "unhappy-vg-with-label": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Label: "volume-group", + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nlabel \"volume-group\" defined for LVM volume group (partition type \"lvm\")", + }, + "unhappy-dupevgname": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + Name: "testvg", + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "testlv", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/test", + }, + }, + }, + }, + }, + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + Name: "testvg", + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "testlv", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "ext4", + Mountpoint: "/stuff", + }, + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nduplicate LVM volume group name \"testvg\" in partitioning customizations", + }, + "unhappy-emptyname-btrfs": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "test", + Mountpoint: "/test", + }, + { + Name: "", + Mountpoint: "/test2", + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nbtrfs subvolume with empty name in partitioning customizations", + }, + "unhappy-emptysubvols-btrfs": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{}, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nbtrfs volume requires subvolumes", + }, + "unhappy-btrfs-with-lvs": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "test", + Mountpoint: "/test2", + }, + }, + }, + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{{}}, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nLVM logical volumes defined for btrfs volume (partition type \"btrfs\")", + }, + "boot-on-lvm": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "bewt", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/boot", + }, + }, + { + Name: "testlv", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/stuff2", + }, + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\ninvalid mountpoint \"/boot\" for logical volume", + }, + "bootefi-on-lvm": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "bewtefi", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/boot/efi", + }, + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\ninvalid mountpoint \"/boot/efi\" for logical volume", + }, + "boot-on-btrfs": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "test", + Mountpoint: "/test", + }, + { + Name: "bootbootboot", + Mountpoint: "/boot", + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\ninvalid mountpoint \"/boot\" for btrfs subvolume", + }, + "bootefi-on-btrfs": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "test", + Mountpoint: "/test", + }, + { + Name: "esp", + Mountpoint: "/boot/efi", + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\ninvalid mountpoint \"/boot/efi\" for btrfs subvolume", + }, + "unhappy-btrfs-on-lvm": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + FSType: "btrfs", + Mountpoint: "/var/log", + }, + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nunknown or invalid filesystem type for logical volume with mountpoint \"/var/log\": btrfs", + }, + "unhappy-lv-notype": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/var/log", + }, + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nunknown or invalid filesystem type for logical volume with mountpoint \"/var/log\": ", + }, + "unhappy-bad-part-type": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "what", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/var/log", + }, + }, + }, + }, + }, + }, + }, + expectedMsg: "invalid partitioning customizations:\nunknown partition type: what", + }, + } + + for name := range testCases { + tc := testCases[name] + t.Run(name, func(t *testing.T) { + err := tc.partitioning.Validate() + if tc.expectedMsg != "" { + assert.EqualError(t, err, tc.expectedMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestPartitioningLayoutConstraints(t *testing.T) { + type testCase struct { + partitioning *blueprint.DiskCustomization + expectedMsg string + } + + testCases := map[string]testCase{ + "unhappy-btrfs+lvm": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "ext4", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/data", + }, + }, + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Mountpoint: "/backup", + }, + }, + }, + }, + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{Mountpoint: "/"}, + }, + }, + }, + }, + }, + }, + expectedMsg: `btrfs and lvm partitioning cannot be combined`, + }, + "unhappy-multibtrfs": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "xfs", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/data", + }, + }, + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "root", + Mountpoint: "/", + }, + }, + }, + }, + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "home", + Mountpoint: "/home", + }, + }, + }, + }, + }, + }, + expectedMsg: `multiple btrfs volumes are not yet supported`, + }, + "unhappy-multivg": { + partitioning: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "xfs", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/data", + }, + }, + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{Mountpoint: "/"}, + }, + }, + }, + }, + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{Mountpoint: "/var/log"}, + }, + }, + }, + }, + }, + }, + expectedMsg: `multiple LVM volume groups are not yet supported`, + }, + } + + for name := range testCases { + tc := testCases[name] + t.Run(name, func(t *testing.T) { + err := tc.partitioning.ValidateLayoutConstraints() + if tc.expectedMsg != "" { + assert.EqualError(t, err, tc.expectedMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestCheckDiskMountpointsPolicy(t *testing.T) { + strict := pathpolicy.NewPathPolicies(map[string]pathpolicy.PathPolicy{ + "/": {Exact: true}, + }) + + noEtc := pathpolicy.NewPathPolicies(map[string]pathpolicy.PathPolicy{ + "/etc": {Deny: true}, + }) + + disk := blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/some/stuff", + }, + }, + { + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Mountpoint: "/data/", + }, + { + Mountpoint: "/scratch", + }, + }, + }, + }, + { + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/logicalvolumes/a", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/logicalvolumes/b", + }, + }, + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/etc", + }, + }, + }, + }, + }, + }, + } + + strictErr := `The following errors occurred while setting up custom mountpoints: +path "/some/stuff" is not allowed +path "/data/" must be canonical +path "/scratch" is not allowed +path "/logicalvolumes/a" is not allowed +path "/logicalvolumes/b" is not allowed +path "/etc" is not allowed` + err := blueprint.CheckDiskMountpointsPolicy(&disk, strict) + assert.EqualError(t, err, strictErr) + + noEtcErr := `The following errors occurred while setting up custom mountpoints: +path "/data/" must be canonical +path "/etc" is not allowed` + err = blueprint.CheckDiskMountpointsPolicy(&disk, noEtc) + assert.EqualError(t, err, noEtcErr) +} + +func TestPartitionCustomizationUnmarshalJSON(t *testing.T) { + type testCase struct { + input string + expected *blueprint.PartitionCustomization + errorMsg string + } + + testCases := map[string]testCase{ + "nothing": { + input: "{}", + expected: &blueprint.PartitionCustomization{ + Type: "plain", + MinSize: 0, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "", + Label: "", + FSType: "", + }, + }, + }, + "plain": { + input: `{ + "type": "plain", + "minsize": "1 GiB", + "mountpoint": "/", + "label": "root", + "fs_type": "xfs" + }`, + expected: &blueprint.PartitionCustomization{ + Type: "plain", + MinSize: 1 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/", + Label: "root", + FSType: "xfs", + }, + }, + }, + "plain-with-int": { + input: `{ + "type": "plain", + "minsize": 1073741824, + "mountpoint": "/", + "label": "root", + "fs_type": "xfs" + }`, + expected: &blueprint.PartitionCustomization{ + Type: "plain", + MinSize: 1 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/", + Label: "root", + FSType: "xfs", + }, + }, + }, + "btrfs": { + input: `{ + "type": "btrfs", + "minsize": "10 GiB", + "subvolumes": [ + { + "name": "subvols/root", + "mountpoint": "/" + }, + { + "name": "subvols/data", + "mountpoint": "/data" + } + ] + }`, + expected: &blueprint.PartitionCustomization{ + Type: "btrfs", + MinSize: 10 * datasizes.GiB, + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "subvols/root", + Mountpoint: "/", + }, + { + Name: "subvols/data", + Mountpoint: "/data", + }, + }, + }, + }, + }, + "btrfs-with-int": { + input: `{ + "type": "btrfs", + "minsize": 10737418240, + "subvolumes": [ + { + "name": "subvols/root", + "mountpoint": "/" + }, + { + "name": "subvols/data", + "mountpoint": "/data" + } + ] + }`, + expected: &blueprint.PartitionCustomization{ + Type: "btrfs", + MinSize: 10 * datasizes.GiB, + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "subvols/root", + Mountpoint: "/", + }, + { + Name: "subvols/data", + Mountpoint: "/data", + }, + }, + }, + }, + }, + "lvm": { + input: `{ + "type": "lvm", + "name": "myvg", + "minsize": "99 GiB", + "logical_volumes": [ + { + "name": "homelv", + "mountpoint": "/home", + "label": "home", + "fs_type": "ext4", + "minsize": "2 GiB" + }, + { + "name": "loglv", + "mountpoint": "/var/log", + "label": "log", + "fs_type": "xfs", + "minsize": "3 GiB" + } + ] + }`, + expected: &blueprint.PartitionCustomization{ + Type: "lvm", + MinSize: 99 * datasizes.GiB, + VGCustomization: blueprint.VGCustomization{ + Name: "myvg", + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "homelv", + MinSize: 2 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/home", + Label: "home", + FSType: "ext4", + }, + }, + { + Name: "loglv", + MinSize: 3 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/var/log", + Label: "log", + FSType: "xfs", + }, + }, + }, + }, + }, + }, + "lvm-with-int": { + input: `{ + "type": "lvm", + "name": "myvg", + "minsize": 106300440576, + "logical_volumes": [ + { + "name": "homelv", + "mountpoint": "/home", + "label": "home", + "fs_type": "ext4", + "minsize": 2147483648 + }, + { + "name": "loglv", + "mountpoint": "/var/log", + "label": "log", + "fs_type": "xfs", + "minsize": 3221225472 + } + ] + }`, + expected: &blueprint.PartitionCustomization{ + Type: "lvm", + MinSize: 99 * datasizes.GiB, + VGCustomization: blueprint.VGCustomization{ + Name: "myvg", + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "homelv", + MinSize: 2 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/home", + Label: "home", + FSType: "ext4", + }, + }, + { + Name: "loglv", + MinSize: 3 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/var/log", + Label: "log", + FSType: "xfs", + }, + }, + }, + }, + }, + }, + "bad-type": { + input: `{"type":"not-a-partition-type"}`, + errorMsg: "JSON unmarshal: unknown partition type: not-a-partition-type", + }, + "number": { + input: `{"type":5}`, + errorMsg: "JSON unmarshal: json: cannot unmarshal number into Go struct field .type of type string", + }, + "negative-size": { + input: `{ + "minsize": -10, + "mountpoint": "/", + "fs_type": "xfs" + }`, + errorMsg: "JSON unmarshal: error decoding minsize for partition: cannot be negative", + }, + "wrong-type/btrfs-with-lvm": { + input: `{ + "type": "btrfs", + "name": "myvg", + "logical_volumes": [ + { + "name": "homelv", + "mountpoint": "/home", + "label": "home", + "fs_type": "ext4" + }, + { + "name": "loglv", + "mountpoint": "/var/log", + "label": "log", + "fs_type": "xfs" + } + ] + }`, + errorMsg: `JSON unmarshal: error decoding partition with type "btrfs": json: unknown field "name"`, + }, + "wrong-type/plain-with-lvm": { + input: `{ + "type": "plain", + "name": "myvg", + "logical_volumes": [ + { + "name": "loglv", + "mountpoint": "/var/log", + "label": "log", + "fs_type": "xfs" + } + ] + }`, + errorMsg: `JSON unmarshal: error decoding partition with type "plain": json: unknown field "name"`, + }, + "wrong-type/lvm-with-btrfs": { + input: `{ + "type": "lvm", + "minsize": "10 GiB", + "subvolumes": [ + { + "name": "subvols/data", + "mountpoint": "/data" + } + ] + }`, + errorMsg: `JSON unmarshal: error decoding partition with type "lvm": json: unknown field "subvolumes"`, + }, + "wrong-type/plain-with-btrfs": { + input: `{ + "type": "plain", + "minsize": "10 GiB", + "subvolumes": [ + { + "name": "subvols/data", + "mountpoint": "/data" + } + ] + }`, + errorMsg: `JSON unmarshal: error decoding partition with type "plain": json: unknown field "subvolumes"`, + }, + } + + for name := range testCases { + tc := testCases[name] + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + var pc blueprint.PartitionCustomization + + err := json.Unmarshal([]byte(tc.input), &pc) + if tc.errorMsg == "" { + assert.NoError(err) + assert.Equal(tc.expected, &pc) + } else { + assert.EqualError(err, tc.errorMsg) + } + }) + } +} + +func TestPartitionCustomizationUnmarshalTOML(t *testing.T) { + type testCase struct { + input string + expected *blueprint.PartitionCustomization + errorMsg string + } + + testCases := map[string]testCase{ + "nothing": { + input: "", + expected: &blueprint.PartitionCustomization{ + Type: "plain", + MinSize: 0, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "", + Label: "", + FSType: "", + }, + }, + }, + "plain": { + input: `type = "plain" + minsize = "1 GiB" + mountpoint = "/" + label = "root" + fs_type = "xfs"`, + expected: &blueprint.PartitionCustomization{ + Type: "plain", + MinSize: 1 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/", + Label: "root", + FSType: "xfs", + }, + }, + }, + "plain-with-int": { + input: `type = "plain" + minsize = 1073741824 + mountpoint = "/" + label = "root" + fs_type = "xfs"`, + expected: &blueprint.PartitionCustomization{ + Type: "plain", + MinSize: 1 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/", + Label: "root", + FSType: "xfs", + }, + }, + }, + "btrfs": { + input: `type = "btrfs" + minsize = "10 GiB" + + [[subvolumes]] + name = "subvols/root" + mountpoint = "/" + + [[subvolumes]] + name = "subvols/data" + mountpoint = "/data" + `, + expected: &blueprint.PartitionCustomization{ + Type: "btrfs", + MinSize: 10 * datasizes.GiB, + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "subvols/root", + Mountpoint: "/", + }, + { + Name: "subvols/data", + Mountpoint: "/data", + }, + }, + }, + }, + }, + "btrfs-with-int": { + input: `type = "btrfs" + minsize = 10737418240 + + [[subvolumes]] + name = "subvols/root" + mountpoint = "/" + + [[subvolumes]] + name = "subvols/data" + mountpoint = "/data" + `, + expected: &blueprint.PartitionCustomization{ + Type: "btrfs", + MinSize: 10 * datasizes.GiB, + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "subvols/root", + Mountpoint: "/", + }, + { + Name: "subvols/data", + Mountpoint: "/data", + }, + }, + }, + }, + }, + "lvm": { + input: `type = "lvm" + name = "myvg" + minsize = "99 GiB" + + [[logical_volumes]] + name = "homelv" + mountpoint = "/home" + label = "home" + fs_type = "ext4" + minsize = "2 GiB" + + [[logical_volumes]] + name = "loglv" + mountpoint = "/var/log" + label = "log" + fs_type = "xfs" + minsize = "3 GiB" + `, + expected: &blueprint.PartitionCustomization{ + Type: "lvm", + MinSize: 99 * datasizes.GiB, + VGCustomization: blueprint.VGCustomization{ + Name: "myvg", + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "homelv", + MinSize: 2 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/home", + Label: "home", + FSType: "ext4", + }, + }, + { + Name: "loglv", + MinSize: 3 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/var/log", + Label: "log", + FSType: "xfs", + }, + }, + }, + }, + }, + }, + "lvm-with-int": { + input: `type = "lvm" + name = "myvg" + minsize = 106300440576 + + [[logical_volumes]] + name = "homelv" + mountpoint = "/home" + label = "home" + fs_type = "ext4" + minsize = 2147483648 + + [[logical_volumes]] + name = "loglv" + mountpoint = "/var/log" + label = "log" + fs_type = "xfs" + minsize = 3221225472 + `, + expected: &blueprint.PartitionCustomization{ + Type: "lvm", + MinSize: 99 * datasizes.GiB, + VGCustomization: blueprint.VGCustomization{ + Name: "myvg", + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "homelv", + MinSize: 2 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/home", + Label: "home", + FSType: "ext4", + }, + }, + { + Name: "loglv", + MinSize: 3 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/var/log", + Label: "log", + FSType: "xfs", + }, + }, + }, + }, + }, + }, + "bad-type": { + input: `type = "not-a-partition-type"`, + errorMsg: "toml: line 0: TOML unmarshal: unknown partition type: not-a-partition-type", + }, + "number": { + input: `type = 5`, + errorMsg: `toml: line 0: TOML unmarshal: type must be a string, got "5" of type int64`, + }, + "negative-size": { + input: `minsize = -10 + mountpoint = "/" + fs_type = "xfs" + `, + errorMsg: "toml: line 0: TOML unmarshal: error decoding minsize for partition: cannot be negative", + }, + "wrong-type/btrfs-with-lvm": { + input: `type = "btrfs" + name = "myvg" + + [[logical_volumes]] + name = "homelv" + mountpoint = "/home" + label = "home" + fs_type = "ext4" + + [[logical_volumes]] + name = "loglv" + mountpoint = "/var/log" + label = "log" + fs_type = "xfs" + `, + errorMsg: `toml: line 0: TOML unmarshal: error decoding partition with type "btrfs": json: unknown field "logical_volumes"`, + }, + "wrong-type/plain-with-lvm": { + input: `type = "plain" + name = "myvg" + + [[logical_volumes]] + name = "homelv" + mountpoint = "/home" + label = "home" + fs_type = "ext4" + + [[logical_volumes]] + name = "loglv" + mountpoint = "/var/log" + label = "log" + fs_type = "xfs" + `, + errorMsg: `toml: line 0: TOML unmarshal: error decoding partition with type "plain": json: unknown field "logical_volumes"`, + }, + "wrong-type/lvm-with-btrfs": { + input: `type = "lvm" + minsize = "10 GiB" + + [[subvolumes]] + name = "subvols/root" + mountpoint = "/" + + [[subvolumes]] + name = "subvols/data" + mountpoint = "/data" + `, + errorMsg: `toml: line 0: TOML unmarshal: error decoding partition with type "lvm": json: unknown field "subvolumes"`, + }, + "wrong-type/plain-with-btrfs": { + input: `type = "plain" + minsize = "10 GiB" + + [[subvolumes]] + name = "subvols/root" + mountpoint = "/" + + [[subvolumes]] + name = "subvols/data" + mountpoint = "/data" + `, + errorMsg: `toml: line 0: TOML unmarshal: error decoding partition with type "plain": json: unknown field "subvolumes"`, + }, + } + + for name := range testCases { + tc := testCases[name] + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + var pc blueprint.PartitionCustomization + + err := toml.Unmarshal([]byte(tc.input), &pc) + if tc.errorMsg == "" { + assert.NoError(err) + assert.Equal(tc.expected, &pc) + } else { + assert.EqualError(err, tc.errorMsg) + } + }) + } +} diff --git a/pkg/disk/disk.go b/pkg/disk/disk.go index a47f5409ab..fdfd027cba 100644 --- a/pkg/disk/disk.go +++ b/pkg/disk/disk.go @@ -72,6 +72,36 @@ const ( DosESPID = "ef00" ) +// pt type -> type -> ID mapping for convenience +var idMap = map[PartitionTableType]map[string]string{ + PT_DOS: { + "bios": DosBIOSBootID, + "boot": DosLinuxTypeID, + "data": DosLinuxTypeID, + "esp": DosESPID, + "lvm": DosLinuxTypeID, + }, + PT_GPT: { + "bios": BIOSBootPartitionGUID, + "boot": XBootLDRPartitionGUID, + "data": FilesystemDataGUID, + "esp": EFISystemPartitionGUID, + "lvm": LVMPartitionGUID, + }, +} + +func getPartitionTypeIDfor(ptType PartitionTableType, partTypeName string) (string, error) { + ptMap, ok := idMap[ptType] + if !ok { + return "", fmt.Errorf("unknown or unsupported partition table enum: %d", ptType) + } + id, ok := ptMap[partTypeName] + if !ok { + return "", fmt.Errorf("unknown or unsupported partition type name: %s", partTypeName) + } + return id, 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 9930f8666b..9bed8eb611 100644 --- a/pkg/disk/partition_table.go +++ b/pkg/disk/partition_table.go @@ -793,10 +793,9 @@ func (pt *PartitionTable) ensureBtrfs() error { // reset the btrfs partition size - it will be grown later part.Size = 0 - if pt.Type == PT_GPT { - part.Type = FilesystemDataGUID - } else { - part.Type = DosLinuxTypeID + part.Type, err = getPartitionTypeIDfor(pt.Type, "data") + if err != nil { + return fmt.Errorf("error converting partition table to btrfs: %w", err) } } else { @@ -979,14 +978,9 @@ func EnsureRootFilesystem(pt *PartitionTable, defaultFsType FSType) error { return fmt.Errorf("error creating root partition: %w", err) } - var partType string - switch pt.Type { - case PT_DOS: - partType = DosLinuxTypeID - case PT_GPT: - partType = FilesystemDataGUID - default: - return fmt.Errorf("error creating root partition: unknown or unsupported partition table type: %s", pt.Type) + partType, err := getPartitionTypeIDfor(pt.Type, "data") + if err != nil { + return fmt.Errorf("error creating root partition: %w", err) } rootpart := Partition{ Type: partType, @@ -1002,27 +996,16 @@ func EnsureRootFilesystem(pt *PartitionTable, defaultFsType FSType) error { return nil } -// EnsureBootPartition creates a boot partition if one does not already exist. -// The function will append the boot partition to the end of the existing -// partition table therefore it is best to call this function early to put boot -// near the front (as is conventional). -func EnsureBootPartition(pt *PartitionTable, bootFsType FSType) error { +// AddBootPartition creates a boot partition. The function will append the boot +// partition to the end of the existing partition table therefore it is best to +// call this function early to put boot near the front (as is conventional). +func AddBootPartition(pt *PartitionTable, bootFsType FSType) error { // collect all labels to avoid conflicts labels := make(map[string]bool) - var foundBoot bool _ = pt.ForEachMountable(func(mnt Mountable, path []Entity) error { - if mnt.GetMountpoint() == "/boot" { - foundBoot = true - return nil - } - labels[mnt.GetFSSpec().Label] = true return nil }) - if foundBoot { - // nothing to do - return nil - } if bootFsType == FS_NONE { return fmt.Errorf("error creating boot partition: no filesystem type") @@ -1033,14 +1016,9 @@ func EnsureBootPartition(pt *PartitionTable, bootFsType FSType) error { return fmt.Errorf("error creating boot partition: %w", err) } - var partType string - switch pt.Type { - case PT_DOS: - partType = DosLinuxTypeID - case PT_GPT: - partType = XBootLDRPartitionGUID - default: - return fmt.Errorf("error creating boot partition: unknown or unsupported partition table type: %s", pt.Type) + partType, err := getPartitionTypeIDfor(pt.Type, "boot") + if err != nil { + return fmt.Errorf("error creating boot partition: %w", err) } bootPart := Partition{ Type: partType, @@ -1102,14 +1080,9 @@ func AddPartitionsForBootMode(pt *PartitionTable, bootMode platform.BootMode) er } func mkBIOSBoot(ptType PartitionTableType) (Partition, error) { - var partType string - switch ptType { - case PT_DOS: - partType = DosBIOSBootID - case PT_GPT: - partType = BIOSBootPartitionGUID - default: - return Partition{}, fmt.Errorf("error creating BIOS boot partition: unknown or unsupported partition table enum: %d", ptType) + partType, err := getPartitionTypeIDfor(ptType, "bios") + if err != nil { + return Partition{}, fmt.Errorf("error creating BIOS boot partition: %w", err) } return Partition{ Size: 1 * datasizes.MiB, @@ -1120,14 +1093,9 @@ func mkBIOSBoot(ptType PartitionTableType) (Partition, error) { } func mkESP(size uint64, ptType PartitionTableType) (Partition, error) { - var partType string - switch ptType { - case PT_DOS: - partType = DosESPID - case PT_GPT: - partType = EFISystemPartitionGUID - default: - return Partition{}, fmt.Errorf("error creating EFI system partition: unknown or unsupported partition table enum: %d", ptType) + partType, err := getPartitionTypeIDfor(ptType, "esp") + if err != nil { + return Partition{}, fmt.Errorf("error creating EFI system partition: %w", err) } return Partition{ Size: size, @@ -1144,3 +1112,286 @@ func mkESP(size uint64, ptType PartitionTableType) (Partition, error) { }, }, nil } + +type CustomPartitionTableOptions struct { + // PartitionTableType must be either "dos" or "gpt". Defaults to "gpt". + PartitionTableType PartitionTableType + + // BootMode determines the types of boot-related partitions that are + // automatically added, BIOS boot (legacy), ESP (UEFI), or both (hybrid). + // If none, no boot-related partitions are created. + BootMode platform.BootMode + + // DefaultFSType determines the filesystem type for automatically created + // filesystems and custom mountpoints that don't specify a type. + // None is only valid if no partitions are created and all mountpoints + // partitions specify a type. + // The default type is also used for the automatically created /boot + // filesystem if it is a supported type for that fileystem. If it is not, + // xfs is used as a fallback. + DefaultFSType FSType + + // RequiredMinSizes defines a map of minimum sizes for specific + // directories. These indirectly control the minimum sizes of partitions. A + // directory with a required size will set the minimum size of the + // partition with the mountpoint that contains the directory. Additional + // directory requirements are additive, meaning the minimum size for a + // mountpoint's partition is the sum of all the required directory sizes it + // will contain. + RequiredMinSizes map[string]uint64 +} + +// Returns the default filesystem type if the fstype is empty. If both are +// empty/none, returns an error. +func (options *CustomPartitionTableOptions) getfstype(fstype string) (string, error) { + if fstype != "" { + return fstype, nil + } + + if options.DefaultFSType == FS_NONE { + return "", fmt.Errorf("no filesystem type defined and no default set") + } + + return options.DefaultFSType.String(), nil +} + +// NewCustomPartitionTable creates a partition table based almost entirely on the disk customizations from a blueprint. +func NewCustomPartitionTable(customizations *blueprint.DiskCustomization, options *CustomPartitionTableOptions, rng *rand.Rand) (*PartitionTable, error) { + if options == nil { + // init options with defaults + options = &CustomPartitionTableOptions{ + PartitionTableType: PT_GPT, + } + } + + if customizations == nil { + customizations = &blueprint.DiskCustomization{} + } + + errPrefix := "error generating partition table:" + + // validate the partitioning customizations before using them + if err := customizations.Validate(); err != nil { + return nil, fmt.Errorf("%s %w", errPrefix, err) + } + + pt := &PartitionTable{} + + // TODO: Handle partition table type in customizations + switch options.PartitionTableType { + case PT_GPT, PT_DOS: + pt.Type = options.PartitionTableType + case PT_NONE: + // default to "gpt" + pt.Type = PT_GPT + default: + return nil, fmt.Errorf("%s invalid partition table type enum value: %d", errPrefix, options.PartitionTableType) + } + + // TODO: switch to ensure ESP in case customizations already include it + if err := AddPartitionsForBootMode(pt, options.BootMode); err != nil { + return nil, fmt.Errorf("%s %w", errPrefix, err) + } + + // The boot type will be the default only if it's a supported filesystem + // type for /boot (ext4 or xfs). Otherwise, we default to xfs. + // FS_NONE also falls back to xfs. + var bootFsType FSType + switch options.DefaultFSType { + case FS_EXT4, FS_XFS: + bootFsType = options.DefaultFSType + default: + bootFsType = FS_XFS + } + + if needsBoot(customizations) { + // we need a /boot partition to boot LVM or Btrfs, create boot + // partition if it does not already exist + if err := AddBootPartition(pt, bootFsType); err != nil { + return nil, fmt.Errorf("%s %w", errPrefix, err) + } + } + + for _, part := range customizations.Partitions { + switch part.Type { + case "plain", "": + if err := addPlainPartition(pt, part, options); err != nil { + return nil, fmt.Errorf("%s %w", errPrefix, err) + } + case "lvm": + if err := addLVMPartition(pt, part, options); err != nil { + return nil, fmt.Errorf("%s %w", errPrefix, err) + } + case "btrfs": + addBtrfsPartition(pt, part) + default: + return nil, fmt.Errorf("%s invalid partition type: %s", errPrefix, part.Type) + } + } + + if err := EnsureRootFilesystem(pt, options.DefaultFSType); err != nil { + return nil, fmt.Errorf("%s %w", errPrefix, err) + } + + if len(options.RequiredMinSizes) != 0 { + pt.EnsureDirectorySizes(options.RequiredMinSizes) + } + + pt.relayout(customizations.MinSize) + pt.GenerateUUIDs(rng) + + return pt, nil +} + +func addPlainPartition(pt *PartitionTable, partition blueprint.PartitionCustomization, options *CustomPartitionTableOptions) error { + fstype, err := options.getfstype(partition.FSType) + if err != nil { + return fmt.Errorf("error creating partition with mountpoint %q: %w", partition.Mountpoint, err) + } + // all user-defined partitions are data partitions except boot + typeName := "data" + if partition.Mountpoint == "/boot" { + typeName = "boot" + } + partType, err := getPartitionTypeIDfor(pt.Type, typeName) + if err != nil { + return fmt.Errorf("error creating root partition: %w", err) + } + newpart := Partition{ + Type: partType, + Bootable: false, + Size: partition.MinSize, + Payload: &Filesystem{ + Type: fstype, + Label: partition.Label, + Mountpoint: partition.Mountpoint, + FSTabOptions: "defaults", // TODO: add customization + }, + } + pt.Partitions = append(pt.Partitions, newpart) + return nil +} + +func addLVMPartition(pt *PartitionTable, partition blueprint.PartitionCustomization, options *CustomPartitionTableOptions) error { + vgname := partition.Name + if vgname == "" { + // count existing volume groups and generate unique name + existing := make(map[string]bool) + for _, part := range pt.Partitions { + vg, ok := part.Payload.(*LVMVolumeGroup) + if !ok { + continue + } + existing[vg.Name] = true + } + // unlike other unique name generation cases, here we want the first + // name to have the 00 suffix, so we add the base to the existing set + base := "vg" + existing[base] = true + uniqueName, err := genUniqueString(base, existing) + if err != nil { + return fmt.Errorf("error creating volume group: %w", err) + } + vgname = uniqueName + } + + newvg := &LVMVolumeGroup{ + Name: vgname, + Description: "created via lvm2 and osbuild", + } + for _, lv := range partition.LogicalVolumes { + fstype, err := options.getfstype(lv.FSType) + if err != nil { + return fmt.Errorf("error creating logical volume %q (%s): %w", lv.Name, lv.Mountpoint, err) + } + newfs := &Filesystem{ + Type: fstype, + Label: lv.Label, + Mountpoint: lv.Mountpoint, + FSTabOptions: "defaults", // TODO: add customization + } + if _, err := newvg.CreateLogicalVolume(lv.Name, lv.MinSize, newfs); err != nil { + return fmt.Errorf("error creating logical volume %q (%s): %w", lv.Name, lv.Mountpoint, err) + } + } + + // create partition for volume group + newpart := Partition{ + Type: LVMPartitionGUID, + Size: partition.MinSize, + Bootable: false, + Payload: newvg, + } + pt.Partitions = append(pt.Partitions, newpart) + return nil +} + +func addBtrfsPartition(pt *PartitionTable, partition blueprint.PartitionCustomization) { + subvols := make([]BtrfsSubvolume, len(partition.Subvolumes)) + for idx, subvol := range partition.Subvolumes { + newsubvol := BtrfsSubvolume{ + Name: subvol.Name, + Mountpoint: subvol.Mountpoint, + } + subvols[idx] = newsubvol + } + + newvol := &Btrfs{ + Subvolumes: subvols, + } + + // create partition for btrfs volume + newpart := Partition{ + Type: FilesystemDataGUID, + Bootable: false, + Payload: newvol, + Size: partition.MinSize, + } + + pt.Partitions = append(pt.Partitions, newpart) +} + +// Determine if a boot partition is needed based on the customizations. A boot +// partition is needed if any of the following conditions apply: +// - / is on LVM or btrfs and /boot is not defined. +// - / is not defined and btrfs or lvm volumes are defined. +// +// In the second case, a root partition will be created automatically on either +// btrfs or lvm. +func needsBoot(disk *blueprint.DiskCustomization) bool { + if disk == nil { + return false + } + + var foundBtrfsOrLVM bool + for _, part := range disk.Partitions { + switch part.Type { + case "plain", "": + if part.Mountpoint == "/" { + return false + } + if part.Mountpoint == "/boot" { + return false + } + case "lvm": + foundBtrfsOrLVM = true + // check if any of the LVs is root + for _, lv := range part.LogicalVolumes { + if lv.Mountpoint == "/" { + return true + } + } + case "btrfs": + foundBtrfsOrLVM = true + // check if any of the subvols is root + for _, subvol := range part.Subvolumes { + if subvol.Mountpoint == "/" { + return true + } + } + default: + // NOTE: invalid types should be validated elsewhere + } + } + return foundBtrfsOrLVM +} diff --git a/pkg/disk/partition_table_test.go b/pkg/disk/partition_table_test.go index 1f3fcb75bf..2344ccbd17 100644 --- a/pkg/disk/partition_table_test.go +++ b/pkg/disk/partition_table_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/osbuild/images/internal/testdisk" + "github.com/osbuild/images/pkg/blueprint" "github.com/osbuild/images/pkg/datasizes" "github.com/osbuild/images/pkg/disk" "github.com/osbuild/images/pkg/platform" @@ -661,7 +662,7 @@ func TestEnsureRootFilesystemErrors(t *testing.T) { "err-no-pt-type": { pt: disk.PartitionTable{}, defaultFsType: disk.FS_EXT4, - errmsg: "error creating root partition: unknown or unsupported partition table type: ", + errmsg: "error creating root partition: unknown or unsupported partition table enum: 0", }, "err-plain": { pt: disk.PartitionTable{ @@ -722,7 +723,7 @@ func TestEnsureRootFilesystemErrors(t *testing.T) { } } -func TestEnsureBootPartition(t *testing.T) { +func TestAddBootPartition(t *testing.T) { type testCase struct { pt disk.PartitionTable expected disk.PartitionTable @@ -859,108 +860,6 @@ func TestEnsureBootPartition(t *testing.T) { }, }, }, - "noop": { - pt: disk.PartitionTable{ - Partitions: []disk.Partition{ - { - Start: 0, - Size: 0, - Type: disk.XBootLDRPartitionGUID, - Bootable: false, - UUID: "", - Payload: &disk.Filesystem{ - Type: "ext4", - Label: "boot", - Mountpoint: "/boot", - FSTabOptions: "defaults", - }, - }, - { - Payload: &disk.LVMVolumeGroup{ - Name: "testvg", - LogicalVolumes: []disk.LVMLogicalVolume{ - { - Name: "varloglv", - Payload: &disk.Filesystem{ - Label: "var-log", - Type: "xfs", - Mountpoint: "/var/log", - }, - }, - { - Name: "datalv", - Payload: &disk.Filesystem{ - Label: "data", - Type: "ext4", - Mountpoint: "/data", - FSTabOptions: "defaults", - }, - }, - { - Name: "rootlv", - Payload: &disk.Filesystem{ - Label: "root", - Type: "ext4", - Mountpoint: "/", - FSTabOptions: "defaults", - }, - }, - }, - }, - }, - }, - }, - expected: disk.PartitionTable{ - Partitions: []disk.Partition{ - { - Start: 0, - Size: 0, - Type: disk.XBootLDRPartitionGUID, - Bootable: false, - UUID: "", - Payload: &disk.Filesystem{ - Type: "ext4", - Label: "boot", - Mountpoint: "/boot", - FSTabOptions: "defaults", - }, - }, - { - Payload: &disk.LVMVolumeGroup{ - Name: "testvg", - LogicalVolumes: []disk.LVMLogicalVolume{ - { - Name: "varloglv", - Payload: &disk.Filesystem{ - Label: "var-log", - Type: "xfs", - Mountpoint: "/var/log", - }, - }, - { - Name: "datalv", - Payload: &disk.Filesystem{ - Label: "data", - Type: "ext4", - Mountpoint: "/data", - FSTabOptions: "defaults", - }, - }, - { - Name: "rootlv", - Payload: &disk.Filesystem{ - Label: "root", - Type: "ext4", - Mountpoint: "/", - FSTabOptions: "defaults", - }, - }, - }, - }, - }, - }, - }, - }, "label-collision": { pt: disk.PartitionTable{ Type: disk.PT_GPT, @@ -1014,7 +913,7 @@ func TestEnsureBootPartition(t *testing.T) { t.Run(name, func(t *testing.T) { assert := assert.New(t) pt := tc.pt - err := disk.EnsureBootPartition(&pt, tc.fsType) + err := disk.AddBootPartition(&pt, tc.fsType) if tc.errmsg == "" { assert.NoError(err) assert.Equal(tc.expected, pt) @@ -1221,3 +1120,1013 @@ func TestAddPartitionsForBootMode(t *testing.T) { }) } } + +func TestNewCustomPartitionTable(t *testing.T) { + type testCase struct { + customizations *blueprint.DiskCustomization + options *disk.CustomPartitionTableOptions + expected *disk.PartitionTable + } + + testCases := map[string]testCase{ + "dos-hybrid": { + customizations: nil, + options: &disk.CustomPartitionTableOptions{ + DefaultFSType: disk.FS_XFS, + BootMode: platform.BOOT_HYBRID, + PartitionTableType: disk.PT_DOS, + }, + expected: &disk.PartitionTable{ + Type: disk.PT_DOS, + Size: 202 * datasizes.MiB, + UUID: "0194fdc2-fa2f-4cc0-81d3-ff12045b73c8", + Partitions: []disk.Partition{ + { + Start: 1 * datasizes.MiB, // header + Bootable: true, + Size: 1 * datasizes.MiB, + Type: disk.DosBIOSBootID, + UUID: disk.BIOSBootPartitionUUID, + }, + { + Start: 2 * datasizes.MiB, + Size: 200 * datasizes.MiB, + Type: disk.DosESPID, + UUID: disk.EFISystemPartitionUUID, + Payload: &disk.Filesystem{ + Type: "vfat", + UUID: disk.EFIFilesystemUUID, + Mountpoint: "/boot/efi", + Label: "EFI-SYSTEM", + FSTabOptions: "defaults,uid=0,gid=0,umask=077,shortname=winnt", + FSTabFreq: 0, + FSTabPassNo: 2, + }, + }, + { + Start: 202 * datasizes.MiB, + Size: 0, + Type: disk.DosLinuxTypeID, + Bootable: false, + Payload: &disk.Filesystem{ + Type: "xfs", + Label: "root", + Mountpoint: "/", + FSTabOptions: "defaults", + UUID: "6e4ff95f-f662-45ee-a82a-bdf44a2d0b75", + }, + }, + }, + }, + }, + "plain": { + customizations: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + MinSize: 20 * datasizes.MiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/data", + Label: "data", + FSType: "ext4", + }, + }, + }, + }, + options: &disk.CustomPartitionTableOptions{ + DefaultFSType: disk.FS_XFS, + BootMode: platform.BOOT_HYBRID, + PartitionTableType: disk.PT_DOS, + }, + expected: &disk.PartitionTable{ + Type: disk.PT_DOS, + Size: 222 * datasizes.MiB, + UUID: "0194fdc2-fa2f-4cc0-81d3-ff12045b73c8", + Partitions: []disk.Partition{ + { + Start: 1 * datasizes.MiB, // header + Size: 1 * datasizes.MiB, + Bootable: true, + Type: disk.DosBIOSBootID, + UUID: disk.BIOSBootPartitionUUID, + }, + { + Start: 2 * datasizes.MiB, + Size: 200 * datasizes.MiB, + Type: disk.DosESPID, + UUID: disk.EFISystemPartitionUUID, + Payload: &disk.Filesystem{ + Type: "vfat", + UUID: disk.EFIFilesystemUUID, + Mountpoint: "/boot/efi", + Label: "EFI-SYSTEM", + FSTabOptions: "defaults,uid=0,gid=0,umask=077,shortname=winnt", + FSTabFreq: 0, + FSTabPassNo: 2, + }, + }, + { + Start: 202 * datasizes.MiB, + Size: 20 * datasizes.MiB, + Type: disk.DosLinuxTypeID, + Bootable: false, + UUID: "", // partitions on dos PTs don't have UUIDs + Payload: &disk.Filesystem{ + Type: "ext4", + Label: "data", + Mountpoint: "/data", + UUID: "6e4ff95f-f662-45ee-a82a-bdf44a2d0b75", + FSTabOptions: "defaults", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + { + Start: 222 * datasizes.MiB, + Size: 0, + Type: disk.DosLinuxTypeID, + UUID: "", // partitions on dos PTs don't have UUIDs + Bootable: false, + Payload: &disk.Filesystem{ + Type: "xfs", + Label: "root", + Mountpoint: "/", + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", + FSTabOptions: "defaults", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + }, + }, + }, + "plain-legacy": { + customizations: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + MinSize: 20 * datasizes.MiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/data", + Label: "data", + FSType: "ext4", + }, + }, + }, + }, + options: &disk.CustomPartitionTableOptions{ + DefaultFSType: disk.FS_XFS, + BootMode: platform.BOOT_LEGACY, + PartitionTableType: disk.PT_DOS, + }, + expected: &disk.PartitionTable{ + Type: disk.PT_DOS, + Size: 22 * datasizes.MiB, + UUID: "0194fdc2-fa2f-4cc0-81d3-ff12045b73c8", + Partitions: []disk.Partition{ + { + Start: 1 * datasizes.MiB, // header + Size: 1 * datasizes.MiB, + Bootable: true, + Type: disk.DosBIOSBootID, + UUID: disk.BIOSBootPartitionUUID, + }, + { + Start: 2 * datasizes.MiB, + Size: 20 * datasizes.MiB, + Type: disk.DosLinuxTypeID, + Bootable: false, + UUID: "", // partitions on dos PTs don't have UUIDs + Payload: &disk.Filesystem{ + Type: "ext4", + Label: "data", + Mountpoint: "/data", + UUID: "6e4ff95f-f662-45ee-a82a-bdf44a2d0b75", + FSTabOptions: "defaults", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + { + Start: 22 * datasizes.MiB, + Size: 0, + Type: disk.DosLinuxTypeID, + UUID: "", // partitions on dos PTs don't have UUIDs + Bootable: false, + Payload: &disk.Filesystem{ + Type: "xfs", + Label: "root", + Mountpoint: "/", + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", + FSTabOptions: "defaults", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + }, + }, + }, + "plain-uefi": { + customizations: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + MinSize: 20 * datasizes.MiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/data", + Label: "data", + FSType: "ext4", + }, + }, + }, + }, + options: &disk.CustomPartitionTableOptions{ + DefaultFSType: disk.FS_XFS, + BootMode: platform.BOOT_UEFI, + PartitionTableType: disk.PT_DOS, + }, + expected: &disk.PartitionTable{ + Type: disk.PT_DOS, + Size: 221 * datasizes.MiB, + UUID: "0194fdc2-fa2f-4cc0-81d3-ff12045b73c8", + Partitions: []disk.Partition{ + { + Start: 1 * datasizes.MiB, + Size: 200 * datasizes.MiB, + Type: disk.DosESPID, + UUID: disk.EFISystemPartitionUUID, + Payload: &disk.Filesystem{ + Type: "vfat", + UUID: disk.EFIFilesystemUUID, + Mountpoint: "/boot/efi", + Label: "EFI-SYSTEM", + FSTabOptions: "defaults,uid=0,gid=0,umask=077,shortname=winnt", + FSTabFreq: 0, + FSTabPassNo: 2, + }, + }, + { + Start: 201 * datasizes.MiB, + Size: 20 * datasizes.MiB, + Type: disk.DosLinuxTypeID, + Bootable: false, + UUID: "", // partitions on dos PTs don't have UUIDs + Payload: &disk.Filesystem{ + Type: "ext4", + Label: "data", + Mountpoint: "/data", + UUID: "6e4ff95f-f662-45ee-a82a-bdf44a2d0b75", + FSTabOptions: "defaults", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + { + Start: 221 * datasizes.MiB, + Size: 0, + Type: disk.DosLinuxTypeID, + UUID: "", // partitions on dos PTs don't have UUIDs + Bootable: false, + Payload: &disk.Filesystem{ + Type: "xfs", + Label: "root", + Mountpoint: "/", + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", + FSTabOptions: "defaults", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + }, + }, + }, + "plain-reqsizes": { + customizations: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + MinSize: 20 * datasizes.MiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/data", + Label: "data", + FSType: "ext4", + }, + }, + }, + }, + options: &disk.CustomPartitionTableOptions{ + DefaultFSType: disk.FS_XFS, + BootMode: platform.BOOT_HYBRID, + RequiredMinSizes: map[string]uint64{"/": 1 * datasizes.GiB, "/usr": 2 * datasizes.GiB}, // the default for our distro definitions + PartitionTableType: disk.PT_DOS, + }, + expected: &disk.PartitionTable{ + Type: disk.PT_DOS, + Size: 222*datasizes.MiB + 3*datasizes.GiB, + UUID: "0194fdc2-fa2f-4cc0-81d3-ff12045b73c8", + Partitions: []disk.Partition{ + { + Start: 1 * datasizes.MiB, // header + Size: 1 * datasizes.MiB, + Bootable: true, + Type: disk.DosBIOSBootID, + UUID: disk.BIOSBootPartitionUUID, + }, + { + Start: 2 * datasizes.MiB, + Size: 200 * datasizes.MiB, + Type: disk.DosESPID, + UUID: disk.EFISystemPartitionUUID, + Payload: &disk.Filesystem{ + Type: "vfat", + UUID: disk.EFIFilesystemUUID, + Mountpoint: "/boot/efi", + Label: "EFI-SYSTEM", + FSTabOptions: "defaults,uid=0,gid=0,umask=077,shortname=winnt", + FSTabFreq: 0, + FSTabPassNo: 2, + }, + }, + { + Start: 202 * datasizes.MiB, + Size: 20 * datasizes.MiB, + Type: disk.DosLinuxTypeID, + Bootable: false, + UUID: "", // partitions on dos PTs don't have UUIDs + Payload: &disk.Filesystem{ + Type: "ext4", + Label: "data", + Mountpoint: "/data", + UUID: "6e4ff95f-f662-45ee-a82a-bdf44a2d0b75", + FSTabOptions: "defaults", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + { + Start: 222 * datasizes.MiB, + Size: 3 * datasizes.GiB, + Type: disk.DosLinuxTypeID, + UUID: "", // partitions on dos PTs don't have UUIDs + Bootable: false, + Payload: &disk.Filesystem{ + Type: "xfs", + Label: "root", + Mountpoint: "/", + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", + FSTabOptions: "defaults", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + }, + }, + }, + "plain+": { + customizations: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + MinSize: 50 * datasizes.MiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/", + Label: "root", + FSType: "xfs", + }, + }, + { + MinSize: 20 * datasizes.MiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/home", + Label: "home", + FSType: "ext4", + }, + }, + }, + }, + options: &disk.CustomPartitionTableOptions{ + DefaultFSType: disk.FS_EXT4, + BootMode: platform.BOOT_HYBRID, + PartitionTableType: disk.PT_GPT, + RequiredMinSizes: map[string]uint64{"/": 3 * datasizes.GiB}, + }, + expected: &disk.PartitionTable{ + Type: disk.PT_GPT, + Size: 222*datasizes.MiB + 3*datasizes.GiB + datasizes.MiB, // start + size of last partition + footer + + UUID: "0194fdc2-fa2f-4cc0-81d3-ff12045b73c8", + Partitions: []disk.Partition{ + { + Start: 1 * datasizes.MiB, // header + Size: 1 * datasizes.MiB, + Bootable: true, + Type: disk.BIOSBootPartitionGUID, + UUID: disk.BIOSBootPartitionUUID, + }, + { + Start: 2 * datasizes.MiB, + Size: 200 * datasizes.MiB, + Type: disk.EFISystemPartitionGUID, + UUID: disk.EFISystemPartitionUUID, + Payload: &disk.Filesystem{ + Type: "vfat", + UUID: disk.EFIFilesystemUUID, + Mountpoint: "/boot/efi", + Label: "EFI-SYSTEM", + FSTabOptions: "defaults,uid=0,gid=0,umask=077,shortname=winnt", + FSTabFreq: 0, + FSTabPassNo: 2, + }, + }, + // root is aligned to the end but not reindexed + { + Start: 222 * datasizes.MiB, + Size: 3*datasizes.GiB + datasizes.MiB - (disk.DefaultSectorSize + (128 * 128)), // grows by 1 grain size (1 MiB) minus the unaligned size of the header to fit the gpt footer + Type: disk.FilesystemDataGUID, + UUID: "a178892e-e285-4ce1-9114-55780875d64e", + Bootable: false, + Payload: &disk.Filesystem{ + Type: "xfs", + Label: "root", + Mountpoint: "/", + FSTabOptions: "defaults", + UUID: "6e4ff95f-f662-45ee-a82a-bdf44a2d0b75", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + { + Start: 202 * datasizes.MiB, + Size: 20 * datasizes.MiB, + Type: disk.FilesystemDataGUID, + UUID: "e2d3d0d0-de6b-48f9-b44c-e85ff044c6b1", + Bootable: false, + Payload: &disk.Filesystem{ + Type: "ext4", + Label: "home", + Mountpoint: "/home", + FSTabOptions: "defaults", + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + }, + }, + }, + "lvm": { + customizations: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + MinSize: 100 * datasizes.MiB, + VGCustomization: blueprint.VGCustomization{ + Name: "testvg", + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "varloglv", + MinSize: 10 * datasizes.MiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/var/log", + Label: "var-log", + FSType: "xfs", + }, + }, + { + Name: "rootlv", + MinSize: 50 * datasizes.MiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/", + Label: "root", + FSType: "xfs", + }, + }, + { // unnamed + untyped logical volume + MinSize: 100 * datasizes.MiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/data", + Label: "data", + FSType: "ext4", // TODO: remove when we reintroduce the default fs + }, + }, + }, + }, + }, + }, + }, + options: &disk.CustomPartitionTableOptions{ + DefaultFSType: disk.FS_EXT4, + BootMode: platform.BOOT_HYBRID, + }, + expected: &disk.PartitionTable{ + Type: disk.PT_GPT, // default when unspecified + UUID: "0194fdc2-fa2f-4cc0-81d3-ff12045b73c8", + Size: 714*datasizes.MiB + 168*datasizes.MiB + datasizes.MiB, // start + size of last partition (VG) + footer + Partitions: []disk.Partition{ + { + Start: 1 * datasizes.MiB, // header + Size: 1 * datasizes.MiB, + Bootable: true, + Type: disk.BIOSBootPartitionGUID, + UUID: disk.BIOSBootPartitionUUID, + }, + { + Start: 2 * datasizes.MiB, + Size: 200 * datasizes.MiB, + Type: disk.EFISystemPartitionGUID, + UUID: disk.EFISystemPartitionUUID, + Payload: &disk.Filesystem{ + Type: "vfat", + UUID: disk.EFIFilesystemUUID, + Mountpoint: "/boot/efi", + Label: "EFI-SYSTEM", + FSTabOptions: "defaults,uid=0,gid=0,umask=077,shortname=winnt", + FSTabFreq: 0, + FSTabPassNo: 2, + }, + }, + { + Start: 202 * datasizes.MiB, + Size: 512 * datasizes.MiB, + Type: disk.XBootLDRPartitionGUID, + UUID: "f83b8e88-3bbf-457a-ab99-c5b252c7429c", + Bootable: false, + Payload: &disk.Filesystem{ + Type: "ext4", + Label: "boot", + Mountpoint: "/boot", + FSTabOptions: "defaults", + UUID: "6e4ff95f-f662-45ee-a82a-bdf44a2d0b75", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + { + Start: 714 * datasizes.MiB, + Size: 168*datasizes.MiB + datasizes.MiB - (disk.DefaultSectorSize + (128 * 128)), // the sum of the LVs (rounded to the next 4 MiB extent) grows by 1 grain size (1 MiB) minus the unaligned size of the header to fit the gpt footer + Type: disk.LVMPartitionGUID, + UUID: "32f3a8ae-b79e-4856-b659-c18f0dcecc77", + Bootable: false, + Payload: &disk.LVMVolumeGroup{ + Name: "testvg", + Description: "created via lvm2 and osbuild", + LogicalVolumes: []disk.LVMLogicalVolume{ + { + Name: "varloglv", + Size: 12 * datasizes.MiB, // rounded up to next extent (4 MiB) + Payload: &disk.Filesystem{ + Label: "var-log", + Type: "xfs", + Mountpoint: "/var/log", + FSTabOptions: "defaults", + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", + }, + }, + { + Name: "rootlv", + Size: 52 * datasizes.MiB, // rounded up to the next extent (4 MiB) + Payload: &disk.Filesystem{ + Label: "root", + Type: "xfs", + Mountpoint: "/", + FSTabOptions: "defaults", + UUID: "a178892e-e285-4ce1-9114-55780875d64e", + }, + }, + { + Name: "datalv", + Size: 100 * datasizes.MiB, + Payload: &disk.Filesystem{ + Label: "data", + Type: "ext4", // the defaultType + Mountpoint: "/data", + FSTabOptions: "defaults", + UUID: "e2d3d0d0-de6b-48f9-b44c-e85ff044c6b1", + }, + }, + }, + }, + }, + }, + }, + }, + "lvm-multivg": { + // two volume groups, both unnamed, and no root lv defined + // NOTE: this is currently not supported by customizations but the + // PT creation function can handle it + customizations: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + MinSize: 100 * datasizes.MiB, + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "varloglv", + MinSize: 10 * datasizes.MiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/var/log", + Label: "var-log", + FSType: "xfs", + }, + }, + }, + }, + }, + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { // unnamed + untyped logical volume + MinSize: 100 * datasizes.MiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/data", + Label: "data", + FSType: "ext4", // TODO: remove when we reintroduce the default fs + }, + }, + }, + }, + }, + }, + }, + options: &disk.CustomPartitionTableOptions{ + DefaultFSType: disk.FS_EXT4, + BootMode: platform.BOOT_HYBRID, + RequiredMinSizes: map[string]uint64{"/": 3 * datasizes.GiB}, + }, + expected: &disk.PartitionTable{ + Type: disk.PT_GPT, // default when unspecified + UUID: "0194fdc2-fa2f-4cc0-81d3-ff12045b73c8", + Size: 818*datasizes.MiB + 16*datasizes.MiB + 3*datasizes.GiB + datasizes.MiB, // start + size of last partition (VG) + footer + Partitions: []disk.Partition{ + { + Start: 1 * datasizes.MiB, // header + Size: 1 * datasizes.MiB, + Bootable: true, + Type: disk.BIOSBootPartitionGUID, + UUID: disk.BIOSBootPartitionUUID, + }, + { + Start: 2 * datasizes.MiB, + Size: 200 * datasizes.MiB, + Type: disk.EFISystemPartitionGUID, + UUID: disk.EFISystemPartitionUUID, + Payload: &disk.Filesystem{ + Type: "vfat", + UUID: disk.EFIFilesystemUUID, + Mountpoint: "/boot/efi", + Label: "EFI-SYSTEM", + FSTabOptions: "defaults,uid=0,gid=0,umask=077,shortname=winnt", + FSTabFreq: 0, + FSTabPassNo: 2, + }, + }, + { + Start: 202 * datasizes.MiB, + Size: 512 * datasizes.MiB, + Type: disk.XBootLDRPartitionGUID, + UUID: "f83b8e88-3bbf-457a-ab99-c5b252c7429c", + Bootable: false, + Payload: &disk.Filesystem{ + Type: "ext4", + Label: "boot", + Mountpoint: "/boot", + FSTabOptions: "defaults", + UUID: "6e4ff95f-f662-45ee-a82a-bdf44a2d0b75", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + { + Start: 818 * datasizes.MiB, // the root vg is moved to the end of the partition table by relayout() + Size: 3*datasizes.GiB + 16*datasizes.MiB + datasizes.MiB - (disk.DefaultSectorSize + (128 * 128)), // the sum of the LVs (rounded to the next 4 MiB extent) grows by 1 grain size (1 MiB) minus the unaligned size of the header to fit the gpt footer + Type: disk.LVMPartitionGUID, + UUID: "32f3a8ae-b79e-4856-b659-c18f0dcecc77", + Bootable: false, + Payload: &disk.LVMVolumeGroup{ + Name: "vg00", + Description: "created via lvm2 and osbuild", + LogicalVolumes: []disk.LVMLogicalVolume{ + { + Name: "varloglv", + Size: 12 * datasizes.MiB, // rounded up to next extent (4 MiB) + Payload: &disk.Filesystem{ + Label: "var-log", + Type: "xfs", + Mountpoint: "/var/log", + FSTabOptions: "defaults", + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", + }, + }, + { + Name: "rootlv", + Size: 3 * datasizes.GiB, + Payload: &disk.Filesystem{ + Label: "root", + Type: "ext4", // the defaultType + Mountpoint: "/", + FSTabOptions: "defaults", + UUID: "a178892e-e285-4ce1-9114-55780875d64e", + }, + }, + }, + }, + }, + { + Start: 714 * datasizes.MiB, + Size: 104 * datasizes.MiB, // the sum of the LVs (rounded to the next 4 MiB extent) grows by 1 grain size (1 MiB) minus the unaligned size of the header to fit the gpt footer + Type: disk.LVMPartitionGUID, + UUID: "c75e7a81-bfde-475f-a7cf-e242cf3cc354", + Bootable: false, + Payload: &disk.LVMVolumeGroup{ + Name: "vg01", + Description: "created via lvm2 and osbuild", + LogicalVolumes: []disk.LVMLogicalVolume{ + { + Name: "datalv", + Size: 100 * datasizes.MiB, + Payload: &disk.Filesystem{ + Label: "data", + Type: "ext4", // the defaultType + Mountpoint: "/data", + FSTabOptions: "defaults", + UUID: "e2d3d0d0-de6b-48f9-b44c-e85ff044c6b1", + }, + }, + }, + }, + }, + }, + }, + }, + "btrfs": { + customizations: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "btrfs", + MinSize: 230 * datasizes.MiB, + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "subvol/root", + Mountpoint: "/", + }, + { + Name: "subvol/home", + Mountpoint: "/home", + }, + { + Name: "subvol/varlog", + Mountpoint: "/var/log", + }, + }, + }, + }, + }, + }, + options: &disk.CustomPartitionTableOptions{ + DefaultFSType: disk.FS_EXT4, + BootMode: platform.BOOT_HYBRID, + PartitionTableType: disk.PT_GPT, + }, + expected: &disk.PartitionTable{ + Type: disk.PT_GPT, + Size: 714*datasizes.MiB + 230*datasizes.MiB + datasizes.MiB, // start + size of last partition + footer + UUID: "0194fdc2-fa2f-4cc0-81d3-ff12045b73c8", + Partitions: []disk.Partition{ + { + Start: 1 * datasizes.MiB, // header + Size: 1 * datasizes.MiB, + Bootable: true, + Type: disk.BIOSBootPartitionGUID, + UUID: disk.BIOSBootPartitionUUID, + }, + { + Start: 2 * datasizes.MiB, // header + Size: 200 * datasizes.MiB, + Type: disk.EFISystemPartitionGUID, + UUID: disk.EFISystemPartitionUUID, + Payload: &disk.Filesystem{ + Type: "vfat", + UUID: disk.EFIFilesystemUUID, + Mountpoint: "/boot/efi", + Label: "EFI-SYSTEM", + FSTabOptions: "defaults,uid=0,gid=0,umask=077,shortname=winnt", + FSTabFreq: 0, + FSTabPassNo: 2, + }, + }, + { + Start: 202 * datasizes.MiB, + Size: 512 * datasizes.MiB, + Type: disk.XBootLDRPartitionGUID, + UUID: "a178892e-e285-4ce1-9114-55780875d64e", + Bootable: false, + Payload: &disk.Filesystem{ + Type: "ext4", + Label: "boot", + Mountpoint: "/boot", + UUID: "6e4ff95f-f662-45ee-a82a-bdf44a2d0b75", + FSTabOptions: "defaults", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + { + Start: 714 * datasizes.MiB, + Size: 231*datasizes.MiB - (disk.DefaultSectorSize + (128 * 128)), // grows by 1 grain size (1 MiB) minus the unaligned size of the header to fit the gpt footer + Type: disk.FilesystemDataGUID, + UUID: "e2d3d0d0-de6b-48f9-b44c-e85ff044c6b1", + Bootable: false, + Payload: &disk.Btrfs{ + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", + Subvolumes: []disk.BtrfsSubvolume{ + { + Name: "subvol/root", + Mountpoint: "/", + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", // same as volume UUID + }, + { + Name: "subvol/home", + Mountpoint: "/home", + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", // same as volume UUID + }, + { + Name: "subvol/varlog", + Mountpoint: "/var/log", + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", // same as volume UUID + }, + }, + }, + }, + }, + }, + }, + "autorootbtrfs": { + customizations: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "btrfs", + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Name: "data", + Mountpoint: "/data", + }, + }, + }, + }, + }, + }, + options: nil, + expected: &disk.PartitionTable{ + Type: disk.PT_GPT, + Size: 514 * datasizes.MiB, + UUID: "0194fdc2-fa2f-4cc0-81d3-ff12045b73c8", + Partitions: []disk.Partition{ + { + Start: 1 * datasizes.MiB, + Size: 512 * datasizes.MiB, + Type: disk.XBootLDRPartitionGUID, + UUID: "a178892e-e285-4ce1-9114-55780875d64e", + Bootable: false, + Payload: &disk.Filesystem{ + Type: "xfs", + Label: "boot", + Mountpoint: "/boot", + UUID: "6e4ff95f-f662-45ee-a82a-bdf44a2d0b75", + FSTabOptions: "defaults", + FSTabFreq: 0, + FSTabPassNo: 0, + }, + }, + { + Start: 513 * datasizes.MiB, + Size: 1*datasizes.MiB - (disk.DefaultSectorSize + (128 * 128)), + + Type: disk.FilesystemDataGUID, + UUID: "e2d3d0d0-de6b-48f9-b44c-e85ff044c6b1", + Bootable: false, + Payload: &disk.Btrfs{ + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", + Subvolumes: []disk.BtrfsSubvolume{ + { + Name: "data", + Mountpoint: "/data", + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", + }, + { + Name: "root", + Mountpoint: "/", + UUID: "fb180daf-48a7-4ee0-b10d-394651850fd4", + }, + }, + }, + }, + }, + }, + }, + } + + for name := range testCases { + tc := testCases[name] + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + // Initialise rng for each test separately, otherwise test run + // order will affect results + /* #nosec G404 */ + rnd := rand.New(rand.NewSource(0)) + pt, err := disk.NewCustomPartitionTable(tc.customizations, tc.options, rnd) + + assert.NoError(err) + assert.Equal(tc.expected, pt) + }) + } + +} + +func TestNewCustomPartitionTableErrors(t *testing.T) { + type testCase struct { + customizations *blueprint.DiskCustomization + options *disk.CustomPartitionTableOptions + errmsg string + } + + testCases := map[string]testCase{ + "autoroot-notype": { + customizations: nil, + options: nil, + errmsg: "error generating partition table: error creating root partition: no default filesystem type", + }, + "autorootlv-notype": { + customizations: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + Name: "vg-without-root", + }, + }, + }, + }, + options: nil, + errmsg: "error generating partition table: error creating root logical volume: no default filesystem type", + }, + "notype-nodefault": { + customizations: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/", + }, + }, + }, + }, + options: nil, + // NOTE: this error message will change when we allow empty fs_type + // in customizations but with a requirement to define a default + errmsg: "error generating partition table: invalid partitioning customizations:\nunknown or invalid filesystem type for mountpoint \"/\": ", + }, + "lvm-notype-nodefault": { + customizations: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{ + Name: "rootvg", + LogicalVolumes: []blueprint.LVCustomization{ + { + Name: "rootlv", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/", + }, + }, + }, + }, + }, + }, + }, + options: nil, + // NOTE: this error message will change when we allow empty fs_type + // in customizations but with a requirement to define a default + errmsg: "error generating partition table: invalid partitioning customizations:\nunknown or invalid filesystem type for logical volume with mountpoint \"/\": ", + }, + "bad-pt-type": { + options: &disk.CustomPartitionTableOptions{ + PartitionTableType: 100, + }, + errmsg: `error generating partition table: invalid partition table type enum value: 100`, + }, + } + + // we don't care about the rng for error tests + /* #nosec G404 */ + rnd := rand.New(rand.NewSource(0)) + + for name := range testCases { + tc := testCases[name] + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + _, err := disk.NewCustomPartitionTable(tc.customizations, tc.options, rnd) + assert.EqualError(err, tc.errmsg) + }) + } +} diff --git a/pkg/distro/fedora/distro.go b/pkg/distro/fedora/distro.go index 24e20c31e7..37660d3d64 100644 --- a/pkg/distro/fedora/distro.go +++ b/pkg/distro/fedora/distro.go @@ -51,6 +51,12 @@ var ( oscap.Standard, } + // Default directory size minimums for all image types. + requiredDirectorySizes = map[string]uint64{ + "/": 1 * datasizes.GiB, + "/usr": 2 * datasizes.GiB, + } + // Services iotServices = []string{ "NetworkManager.service", @@ -92,10 +98,11 @@ var ( rpmOstree: false, image: imageInstallerImage, // We don't know the variant of the OS pipeline being installed - isoLabel: getISOLabelFunc("Unknown"), - buildPipelines: []string{"build"}, - payloadPipelines: []string{"anaconda-tree", "rootfs-image", "efiboot-tree", "os", "bootiso-tree", "bootiso"}, - exports: []string{"bootiso"}, + isoLabel: getISOLabelFunc("Unknown"), + buildPipelines: []string{"build"}, + payloadPipelines: []string{"anaconda-tree", "rootfs-image", "efiboot-tree", "os", "bootiso-tree", "bootiso"}, + exports: []string{"bootiso"}, + requiredPartitionSizes: requiredDirectorySizes, } liveInstallerImgType = imageType{ @@ -106,14 +113,15 @@ var ( packageSets: map[string]packageSetFunc{ installerPkgsKey: liveInstallerPackageSet, }, - bootable: true, - bootISO: true, - rpmOstree: false, - image: liveInstallerImage, - isoLabel: getISOLabelFunc("Workstation"), - buildPipelines: []string{"build"}, - payloadPipelines: []string{"anaconda-tree", "rootfs-image", "efiboot-tree", "bootiso-tree", "bootiso"}, - exports: []string{"bootiso"}, + bootable: true, + bootISO: true, + rpmOstree: false, + image: liveInstallerImage, + isoLabel: getISOLabelFunc("Workstation"), + buildPipelines: []string{"build"}, + payloadPipelines: []string{"anaconda-tree", "rootfs-image", "efiboot-tree", "bootiso-tree", "bootiso"}, + exports: []string{"bootiso"}, + requiredPartitionSizes: requiredDirectorySizes, } iotCommitImgType = imageType{ @@ -128,11 +136,12 @@ var ( EnabledServices: iotServices, DracutConf: []*osbuild.DracutConfStageOptions{osbuild.FIPSDracutConfStageOptions}, }, - rpmOstree: true, - image: iotCommitImage, - buildPipelines: []string{"build"}, - payloadPipelines: []string{"os", "ostree-commit", "commit-archive"}, - exports: []string{"commit-archive"}, + rpmOstree: true, + image: iotCommitImage, + buildPipelines: []string{"build"}, + payloadPipelines: []string{"os", "ostree-commit", "commit-archive"}, + exports: []string{"commit-archive"}, + requiredPartitionSizes: requiredDirectorySizes, } iotBootableContainer = imageType{ @@ -142,11 +151,12 @@ var ( packageSets: map[string]packageSetFunc{ osPkgsKey: bootableContainerPackageSet, }, - rpmOstree: true, - image: bootableContainerImage, - buildPipelines: []string{"build"}, - payloadPipelines: []string{"os", "ostree-commit", "ostree-encapsulate"}, - exports: []string{"ostree-encapsulate"}, + rpmOstree: true, + image: bootableContainerImage, + buildPipelines: []string{"build"}, + payloadPipelines: []string{"os", "ostree-commit", "ostree-encapsulate"}, + exports: []string{"ostree-encapsulate"}, + requiredPartitionSizes: requiredDirectorySizes, } iotOCIImgType = imageType{ @@ -164,12 +174,13 @@ var ( EnabledServices: iotServices, DracutConf: []*osbuild.DracutConfStageOptions{osbuild.FIPSDracutConfStageOptions}, }, - rpmOstree: true, - bootISO: false, - image: iotContainerImage, - buildPipelines: []string{"build"}, - payloadPipelines: []string{"os", "ostree-commit", "container-tree", "container"}, - exports: []string{"container"}, + rpmOstree: true, + bootISO: false, + image: iotContainerImage, + buildPipelines: []string{"build"}, + payloadPipelines: []string{"os", "ostree-commit", "container-tree", "container"}, + exports: []string{"container"}, + requiredPartitionSizes: requiredDirectorySizes, } iotInstallerImgType = imageType{ @@ -184,13 +195,14 @@ var ( Locale: common.ToPtr("en_US.UTF-8"), EnabledServices: iotServices, }, - rpmOstree: true, - bootISO: true, - image: iotInstallerImage, - isoLabel: getISOLabelFunc("IoT"), - buildPipelines: []string{"build"}, - payloadPipelines: []string{"anaconda-tree", "rootfs-image", "efiboot-tree", "bootiso-tree", "bootiso"}, - exports: []string{"bootiso"}, + rpmOstree: true, + bootISO: true, + image: iotInstallerImage, + isoLabel: getISOLabelFunc("IoT"), + buildPipelines: []string{"build"}, + payloadPipelines: []string{"anaconda-tree", "rootfs-image", "efiboot-tree", "bootiso-tree", "bootiso"}, + exports: []string{"bootiso"}, + requiredPartitionSizes: requiredDirectorySizes, } iotSimplifiedInstallerImgType = imageType{ @@ -210,17 +222,18 @@ var ( LockRootUser: common.ToPtr(true), IgnitionPlatform: common.ToPtr("metal"), }, - defaultSize: 10 * datasizes.GibiByte, - rpmOstree: true, - bootable: true, - bootISO: true, - image: iotSimplifiedInstallerImage, - isoLabel: getISOLabelFunc("IoT"), - buildPipelines: []string{"build"}, - payloadPipelines: []string{"ostree-deployment", "image", "xz", "coi-tree", "efiboot-tree", "bootiso-tree", "bootiso"}, - exports: []string{"bootiso"}, - basePartitionTables: iotSimplifiedInstallerPartitionTables, - kernelOptions: ostreeDeploymentKernelOptions, + defaultSize: 10 * datasizes.GibiByte, + rpmOstree: true, + bootable: true, + bootISO: true, + image: iotSimplifiedInstallerImage, + isoLabel: getISOLabelFunc("IoT"), + buildPipelines: []string{"build"}, + payloadPipelines: []string{"ostree-deployment", "image", "xz", "coi-tree", "efiboot-tree", "bootiso-tree", "bootiso"}, + exports: []string{"bootiso"}, + basePartitionTables: iotSimplifiedInstallerPartitionTables, + kernelOptions: ostreeDeploymentKernelOptions, + requiredPartitionSizes: requiredDirectorySizes, } iotRawImgType = imageType{ @@ -269,15 +282,16 @@ var ( LockRootUser: common.ToPtr(true), IgnitionPlatform: common.ToPtr("qemu"), }, - defaultSize: 10 * datasizes.GibiByte, - rpmOstree: true, - bootable: true, - image: iotImage, - buildPipelines: []string{"build"}, - payloadPipelines: []string{"ostree-deployment", "image", "qcow2"}, - exports: []string{"qcow2"}, - basePartitionTables: iotBasePartitionTables, - kernelOptions: ostreeDeploymentKernelOptions, + defaultSize: 10 * datasizes.GibiByte, + rpmOstree: true, + bootable: true, + image: iotImage, + buildPipelines: []string{"build"}, + payloadPipelines: []string{"ostree-deployment", "image", "qcow2"}, + exports: []string{"qcow2"}, + basePartitionTables: iotBasePartitionTables, + kernelOptions: ostreeDeploymentKernelOptions, + requiredPartitionSizes: requiredDirectorySizes, } qcow2ImgType = imageType{ @@ -291,14 +305,15 @@ var ( defaultImageConfig: &distro.ImageConfig{ DefaultTarget: common.ToPtr("multi-user.target"), }, - kernelOptions: cloudKernelOptions, - bootable: true, - defaultSize: 5 * datasizes.GibiByte, - image: diskImage, - buildPipelines: []string{"build"}, - payloadPipelines: []string{"os", "image", "qcow2"}, - exports: []string{"qcow2"}, - basePartitionTables: defaultBasePartitionTables, + kernelOptions: cloudKernelOptions, + bootable: true, + defaultSize: 5 * datasizes.GibiByte, + image: diskImage, + buildPipelines: []string{"build"}, + payloadPipelines: []string{"os", "image", "qcow2"}, + exports: []string{"qcow2"}, + basePartitionTables: defaultBasePartitionTables, + requiredPartitionSizes: requiredDirectorySizes, } vmdkDefaultImageConfig = &distro.ImageConfig{ @@ -318,15 +333,16 @@ var ( packageSets: map[string]packageSetFunc{ osPkgsKey: vmdkCommonPackageSet, }, - defaultImageConfig: vmdkDefaultImageConfig, - kernelOptions: cloudKernelOptions, - bootable: true, - defaultSize: 2 * datasizes.GibiByte, - image: diskImage, - buildPipelines: []string{"build"}, - payloadPipelines: []string{"os", "image", "vmdk"}, - exports: []string{"vmdk"}, - basePartitionTables: defaultBasePartitionTables, + defaultImageConfig: vmdkDefaultImageConfig, + kernelOptions: cloudKernelOptions, + bootable: true, + defaultSize: 2 * datasizes.GibiByte, + image: diskImage, + buildPipelines: []string{"build"}, + payloadPipelines: []string{"os", "image", "vmdk"}, + exports: []string{"vmdk"}, + basePartitionTables: defaultBasePartitionTables, + requiredPartitionSizes: requiredDirectorySizes, } ovaImgType = imageType{ @@ -336,15 +352,16 @@ var ( packageSets: map[string]packageSetFunc{ osPkgsKey: vmdkCommonPackageSet, }, - defaultImageConfig: vmdkDefaultImageConfig, - kernelOptions: cloudKernelOptions, - bootable: true, - defaultSize: 2 * datasizes.GibiByte, - image: diskImage, - buildPipelines: []string{"build"}, - payloadPipelines: []string{"os", "image", "vmdk", "ovf", "archive"}, - exports: []string{"archive"}, - basePartitionTables: defaultBasePartitionTables, + defaultImageConfig: vmdkDefaultImageConfig, + kernelOptions: cloudKernelOptions, + bootable: true, + defaultSize: 2 * datasizes.GibiByte, + image: diskImage, + buildPipelines: []string{"build"}, + payloadPipelines: []string{"os", "image", "vmdk", "ovf", "archive"}, + exports: []string{"archive"}, + basePartitionTables: defaultBasePartitionTables, + requiredPartitionSizes: requiredDirectorySizes, } containerImgType = imageType{ @@ -360,11 +377,12 @@ var ( Locale: common.ToPtr("C.UTF-8"), Timezone: common.ToPtr("Etc/UTC"), }, - image: containerImage, - bootable: false, - buildPipelines: []string{"build"}, - payloadPipelines: []string{"os", "container"}, - exports: []string{"container"}, + image: containerImage, + bootable: false, + buildPipelines: []string{"build"}, + payloadPipelines: []string{"os", "container"}, + exports: []string{"container"}, + requiredPartitionSizes: requiredDirectorySizes, } wslImgType = imageType{ @@ -385,11 +403,12 @@ var ( }, }, }, - image: containerImage, - bootable: false, - buildPipelines: []string{"build"}, - payloadPipelines: []string{"os", "container"}, - exports: []string{"container"}, + image: containerImage, + bootable: false, + buildPipelines: []string{"build"}, + payloadPipelines: []string{"os", "container"}, + exports: []string{"container"}, + requiredPartitionSizes: requiredDirectorySizes, } minimalrawImgType = imageType{ @@ -410,15 +429,16 @@ var ( Timeout: 5, }, }, - rpmOstree: false, - kernelOptions: defaultKernelOptions, - bootable: true, - defaultSize: 2 * datasizes.GibiByte, - image: diskImage, - buildPipelines: []string{"build"}, - payloadPipelines: []string{"os", "image", "xz"}, - exports: []string{"xz"}, - basePartitionTables: minimalrawPartitionTables, + rpmOstree: false, + kernelOptions: defaultKernelOptions, + bootable: true, + defaultSize: 2 * datasizes.GibiByte, + image: diskImage, + buildPipelines: []string{"build"}, + payloadPipelines: []string{"os", "image", "xz"}, + exports: []string{"xz"}, + basePartitionTables: minimalrawPartitionTables, + requiredPartitionSizes: requiredDirectorySizes, } ) diff --git a/pkg/distro/fedora/distro_test.go b/pkg/distro/fedora/distro_test.go index cd50693408..e75deea029 100644 --- a/pkg/distro/fedora/distro_test.go +++ b/pkg/distro/fedora/distro_test.go @@ -740,7 +740,7 @@ func TestDistro_CustomFileSystemManifestError(t *testing.T) { imgType, _ := arch.GetImageType(imgTypeName) _, _, err := imgType.Manifest(&bp, distro.ImageOptions{}, nil, 0) if imgTypeName == "iot-commit" || imgTypeName == "iot-container" || imgTypeName == "iot-bootable-container" { - assert.EqualError(t, err, "Custom mountpoints are not supported for ostree types") + assert.EqualError(t, err, "Custom mountpoints and partitioning are not supported for ostree types") } else if imgTypeName == "iot-raw-image" || imgTypeName == "iot-qcow2-image" { assert.EqualError(t, err, fmt.Sprintf(distro.UnsupportedCustomizationError, imgTypeName, "User, Group, Directories, Files, Services, FIPS")) } else if imgTypeName == "iot-installer" || imgTypeName == "iot-simplified-installer" || imgTypeName == "image-installer" { @@ -774,7 +774,7 @@ func TestDistro_TestRootMountPoint(t *testing.T) { imgType, _ := arch.GetImageType(imgTypeName) _, _, err := imgType.Manifest(&bp, distro.ImageOptions{}, nil, 0) if imgTypeName == "iot-commit" || imgTypeName == "iot-container" || imgTypeName == "iot-bootable-container" { - assert.EqualError(t, err, "Custom mountpoints are not supported for ostree types") + assert.EqualError(t, err, "Custom mountpoints and partitioning are not supported for ostree types") } else if imgTypeName == "iot-raw-image" || imgTypeName == "iot-qcow2-image" { assert.EqualError(t, err, fmt.Sprintf(distro.UnsupportedCustomizationError, imgTypeName, "User, Group, Directories, Files, Services, FIPS")) } else if imgTypeName == "iot-installer" || imgTypeName == "iot-simplified-installer" || imgTypeName == "image-installer" { @@ -922,7 +922,7 @@ func TestDistro_CustomUsrPartitionNotLargeEnough(t *testing.T) { imgType, _ := arch.GetImageType(imgTypeName) _, _, err := imgType.Manifest(&bp, distro.ImageOptions{}, nil, 0) if imgTypeName == "iot-commit" || imgTypeName == "iot-container" || imgTypeName == "iot-bootable-container" { - assert.EqualError(t, err, "Custom mountpoints are not supported for ostree types") + assert.EqualError(t, err, "Custom mountpoints and partitioning are not supported for ostree types") } else if imgTypeName == "iot-raw-image" || imgTypeName == "iot-qcow2-image" { assert.EqualError(t, err, fmt.Sprintf(distro.UnsupportedCustomizationError, imgTypeName, "User, Group, Directories, Files, Services, FIPS")) } else if imgTypeName == "iot-installer" || imgTypeName == "iot-simplified-installer" || imgTypeName == "image-installer" { @@ -937,6 +937,51 @@ func TestDistro_CustomUsrPartitionNotLargeEnough(t *testing.T) { } } +func TestDistro_PartitioningConflict(t *testing.T) { + bp := blueprint.Blueprint{ + Customizations: &blueprint.Customizations{ + Filesystem: []blueprint.FilesystemCustomization{ + { + MinSize: 1024, + Mountpoint: "/", + }, + }, + Disk: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + MinSize: 19, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/home", + }, + }, + }, + }, + }, + } + for _, dist := range fedoraFamilyDistros { + fedoraDistro := dist.distro + for _, archName := range fedoraDistro.ListArches() { + arch, _ := fedoraDistro.GetArch(archName) + for _, imgTypeName := range arch.ListImageTypes() { + imgType, _ := arch.GetImageType(imgTypeName) + _, _, err := imgType.Manifest(&bp, distro.ImageOptions{}, nil, 0) + if imgTypeName == "iot-commit" || imgTypeName == "iot-container" || imgTypeName == "iot-bootable-container" { + assert.EqualError(t, err, "Custom mountpoints and partitioning are not supported for ostree types") + } else if imgTypeName == "iot-raw-image" || imgTypeName == "iot-qcow2-image" { + assert.EqualError(t, err, fmt.Sprintf(distro.UnsupportedCustomizationError, imgTypeName, "User, Group, Directories, Files, Services, FIPS")) + } else if imgTypeName == "iot-installer" || imgTypeName == "iot-simplified-installer" || imgTypeName == "image-installer" { + continue + } else if imgTypeName == "live-installer" { + assert.EqualError(t, err, fmt.Sprintf(distro.NoCustomizationsAllowedError, imgTypeName)) + } else { + assert.EqualError(t, err, "partitioning customizations cannot be used with custom filesystems (mountpoints)") + } + } + } + } + +} + func TestDistroFactory(t *testing.T) { type testCase struct { strID string diff --git a/pkg/distro/fedora/images.go b/pkg/distro/fedora/images.go index c40c42e943..51e117cb95 100644 --- a/pkg/distro/fedora/images.go +++ b/pkg/distro/fedora/images.go @@ -329,7 +329,7 @@ func diskImage(workload workload.Workload, img.InstallWeakDeps = common.ToPtr(false) } // TODO: move generation into LiveImage - pt, err := t.getPartitionTable(bp.Customizations.GetFilesystems(), options, rng) + pt, err := t.getPartitionTable(bp.Customizations, options, rng) if err != nil { return nil, err } @@ -700,7 +700,7 @@ func iotImage(workload workload.Workload, img.OSName = "fedora-iot" // TODO: move generation into LiveImage - pt, err := t.getPartitionTable(customizations.GetFilesystems(), options, rng) + pt, err := t.getPartitionTable(customizations, options, rng) if err != nil { return nil, err } @@ -741,7 +741,7 @@ func iotSimplifiedInstallerImage(workload workload.Workload, rawImg.OSName = "fedora" // TODO: move generation into LiveImage - pt, err := t.getPartitionTable(customizations.GetFilesystems(), options, rng) + pt, err := t.getPartitionTable(customizations, options, rng) if err != nil { return nil, err } diff --git a/pkg/distro/fedora/imagetype.go b/pkg/distro/fedora/imagetype.go index 8978e063ba..60aea17cf7 100644 --- a/pkg/distro/fedora/imagetype.go +++ b/pkg/distro/fedora/imagetype.go @@ -138,7 +138,7 @@ func (t *imageType) BootMode() platform.BootMode { } func (t *imageType) getPartitionTable( - mountpoints []blueprint.FilesystemCustomization, + customizations *blueprint.Customizations, options distro.ImageOptions, rng *rand.Rand, ) (*disk.PartitionTable, error) { @@ -148,6 +148,24 @@ func (t *imageType) getPartitionTable( } imageSize := t.Size(options.Size) + partitioning := customizations.GetPartitioning() + if partitioning != nil { + // Use the new custom partition table to create a PT fully based on the user's customizations. + // This overrides FilesystemCustomizations, but we should never have both defined. + if options.Size > 0 { + // user specified a size on the command line, so let's override the + // customization with the calculated/rounded imageSize + partitioning.MinSize = imageSize + } + + partOptions := &disk.CustomPartitionTableOptions{ + PartitionTableType: basePartitionTable.Type, // PT type is not customizable, it is determined by the base PT for an image type or architecture + BootMode: t.BootMode(), + DefaultFSType: disk.FS_EXT4, // default fs type for Fedora + RequiredMinSizes: t.requiredPartitionSizes, + } + return disk.NewCustomPartitionTable(partitioning, partOptions, rng) + } partitioningMode := options.PartitioningMode if t.rpmOstree { @@ -160,6 +178,7 @@ func (t *imageType) getPartitionTable( partitioningMode = disk.AutoLVMPartitioningMode } + mountpoints := customizations.GetFilesystems() return disk.NewPartitionTable(&basePartitionTable, mountpoints, imageSize, partitioningMode, t.requiredPartitionSizes, rng) } @@ -355,13 +374,18 @@ func (t *imageType) checkOptions(bp *blueprint.Blueprint, options distro.ImageOp } mountpoints := customizations.GetFilesystems() - - if mountpoints != nil && t.rpmOstree { - return nil, fmt.Errorf("Custom mountpoints are not supported for ostree types") + partitioning := customizations.GetPartitioning() + if (len(mountpoints) > 0 || partitioning != nil) && t.rpmOstree { + return nil, fmt.Errorf("Custom mountpoints and partitioning are not supported for ostree types") + } + if len(mountpoints) > 0 && partitioning != nil { + return nil, fmt.Errorf("partitioning customizations cannot be used with custom filesystems (mountpoints)") } - err := blueprint.CheckMountpointsPolicy(mountpoints, policies.MountpointPolicies) - if err != nil { + if err := blueprint.CheckMountpointsPolicy(mountpoints, policies.MountpointPolicies); err != nil { + return nil, err + } + if err := blueprint.CheckDiskMountpointsPolicy(partitioning, policies.MountpointPolicies); err != nil { return nil, err } @@ -382,7 +406,7 @@ func (t *imageType) checkOptions(bp *blueprint.Blueprint, options distro.ImageOp dc := customizations.GetDirectories() fc := customizations.GetFiles() - err = blueprint.ValidateDirFileCustomizations(dc, fc) + err := blueprint.ValidateDirFileCustomizations(dc, fc) if err != nil { return nil, err } diff --git a/test/config-map.json b/test/config-map.json index 1637edd9c8..fa828e32d8 100644 --- a/test/config-map.json +++ b/test/config-map.json @@ -331,5 +331,29 @@ "rhel-9.5", "rhel-10.0" ] + }, + "./configs/partitioning-plain.json": { + "image-types": [ + "ami" + ], + "distros": [ + "fedora*" + ] + }, + "./configs/partitioning-btrfs.json": { + "image-types": [ + "ami" + ], + "distros": [ + "fedora*" + ] + }, + "./configs/partitioning-lvm.json": { + "image-types": [ + "ami" + ], + "distros": [ + "fedora*" + ] } } diff --git a/test/configs/partitioning-btrfs.json b/test/configs/partitioning-btrfs.json new file mode 100644 index 0000000000..3f7079908a --- /dev/null +++ b/test/configs/partitioning-btrfs.json @@ -0,0 +1,55 @@ +{ + "name": "partitioning-btrfs", + "blueprint": { + "customizations": { + "disk": { + "partitions": [ + { + "type": "plain", + "mountpoint": "/data", + "minsize": 1073741824, + "fs_type": "xfs" + }, + { + "type": "btrfs", + "minsize": "10 GiB", + "subvolumes": [ + { + "name": "subvol-home", + "mountpoint": "/home" + }, + { + "name": "subvol-shadowman", + "mountpoint": "/home/shadowman" + }, + { + "name": "subvol-foo", + "mountpoint": "/foo" + }, + { + "name": "subvol-usr", + "mountpoint": "/usr" + }, + { + "name": "subvol-opt", + "mountpoint": "/opt" + }, + { + "name": "subvol-media", + "mountpoint": "/media" + }, + { + "name": "subvol-root", + "mountpoint": "/root" + }, + { + "name": "subvol-srv", + "mountpoint": "/srv" + } + ] + } + ] + } + } + } +} diff --git a/test/configs/partitioning-lvm.json b/test/configs/partitioning-lvm.json new file mode 100644 index 0000000000..960ec93550 --- /dev/null +++ b/test/configs/partitioning-lvm.json @@ -0,0 +1,73 @@ +{ + "name": "partitioning-lvm", + "blueprint": { + "customizations": { + "disk": { + "partitions": [ + { + "mountpoint": "/data", + "minsize": "1 GiB", + "label": "data", + "fs_type": "ext4" + }, + { + "type": "lvm", + "name": "testvg", + "minsize": 10737418240, + "logical_volumes": [ + { + "name": "homelv", + "mountpoint": "/home", + "label": "home", + "fs_type": "ext4", + "minsize": "2 GiB" + }, + { + "name": "shadowmanlv", + "mountpoint": "/home/shadowman", + "fs_type": "ext4", + "minsize": "5 GiB" + }, + { + "name": "foolv", + "mountpoint": "/foo", + "fs_type": "ext4", + "minsize": "1 GiB" + }, + { + "name": "usrlv", + "mountpoint": "/usr", + "fs_type": "ext4", + "minsize": "4 GiB" + }, + { + "name": "optlv", + "mountpoint": "/opt", + "fs_type": "ext4", + "minsize": 1073741824 + }, + { + "name": "medialv", + "mountpoint": "/media", + "fs_type": "ext4", + "minsize": 1073741824 + }, + { + "name": "roothomelv", + "mountpoint": "/root", + "fs_type": "ext4", + "minsize": "1 GiB" + }, + { + "name": "srvlv", + "mountpoint": "/srv", + "fs_type": "ext4", + "minsize": 1073741824 + } + ] + } + ] + } + } + } +} diff --git a/test/configs/partitioning-plain.json b/test/configs/partitioning-plain.json new file mode 100644 index 0000000000..6c2e06ae33 --- /dev/null +++ b/test/configs/partitioning-plain.json @@ -0,0 +1,57 @@ +{ + "name": "partitioning-plain", + "blueprint": { + "customizations": { + "disk": { + "partitions": [ + { + "mountpoint": "/data", + "fs_type": "ext4", + "minsize": 1073741824 + }, + { + "mountpoint": "/home", + "label": "home", + "fs_type": "ext4", + "minsize": 2147483648 + }, + { + "mountpoint": "/home/shadowman", + "fs_type": "ext4", + "minsize": 524288000 + }, + { + "mountpoint": "/foo", + "fs_type": "ext4", + "minsize": 1073741824 + }, + { + "mountpoint": "/var", + "fs_type": "xfs", + "minsize": 4294967296 + }, + { + "mountpoint": "/opt", + "fs_type": "ext4", + "minsize": 1073741824 + }, + { + "mountpoint": "/media", + "fs_type": "ext4", + "minsize": 1073741824 + }, + { + "mountpoint": "/root", + "fs_type": "ext4", + "minsize": 1073741824 + }, + { + "mountpoint": "/srv", + "fs_type": "xfs", + "minsize": 1073741824 + } + ] + } + } + } +}