From bd23b5d6635526c7ea7cf119e1324feda5a8573a Mon Sep 17 00:00:00 2001 From: jbtrystram Date: Fri, 10 Jul 2026 17:07:14 +0200 Subject: [PATCH 1/2] pkg/disk.yaml: add an option to not grow root Add a `grow_root_to_fill_disk` boolean option to allow not growing the root partition to use all available space. Defaults to `true` for backwards compatibility. This allows creating disk images with empty available space, so users can create additionnal partitions or grow the root at first boot via e.g. `cloud-utils-growpart`. See discussion in https://github.com/coreos/fedora-coreos-tracker/issues/2188 Assisted-By: Opencode.ai --- .../20-bootc/05-sources-of-configuration.md | 36 ++++ pkg/disk/partition_table.go | 36 +++- pkg/disk/partition_table_internal_test.go | 158 ++++++++++++++++++ pkg/disk/partition_table_yaml_test.go | 61 +++++++ 4 files changed, 282 insertions(+), 9 deletions(-) diff --git a/doc/20-advanced/20-bootc/05-sources-of-configuration.md b/doc/20-advanced/20-bootc/05-sources-of-configuration.md index a5379f15a7..5bdb619a7f 100644 --- a/doc/20-advanced/20-bootc/05-sources-of-configuration.md +++ b/doc/20-advanced/20-bootc/05-sources-of-configuration.md @@ -69,6 +69,42 @@ partition_table: - `type`, an `enum` that can be `gpt` or `dos` and sets the partition table format to use. - `partitions`, a list of objects each of which represents a partition. +- `size`, an *optional* string with units to set the overall disk size (e.g. `"10 GiB"`). If omitted the disk will be sized to fit all partitions. +- `grow_root_to_fill_disk`, an *optional* boolean (defaults to `true`). When `true` (or omitted), the partition containing the root filesystem (`/`) is automatically grown to fill any remaining disk space. Set to `false` to keep the root partition at its specified size, leaving unallocated space on the disk. This is useful when the image is expected to be grown at first boot (e.g. via `cloud-utils-growpart`) or when you want a compact disk image. + +Here's an example using `grow_root_to_fill_disk: false` to produce a 10 GiB disk where the root partition stays at 4 GiB, leaving the remaining space unallocated: + +```yaml +mount_configuration: "units" +partition_table: + type: "gpt" + size: "10 GiB" + grow_root_to_fill_disk: false + partitions: + - size: "1 MiB" + type: "21686148-6449-6e6F-744e-656564454649" + bootable: true + - size: "200 MiB" + type: "c12a7328-f81f-11d2-ba4b-00a0c93ec93b" + payload_type: "filesystem" + payload: + type: "vfat" + mountpoint: "/boot/efi" + label: "ESP" + fstab_options: "defaults,uid=0,gid=0,umask=077,shortname=winnt" + fstab_freq: 0 + fstab_passno: 2 + - size: "4 GiB" + type: "0fc63daf-8483-4772-8e79-3d69d8477de4" + payload_type: "filesystem" + payload: + type: "ext4" + label: "root" + mountpoint: "/" + fstab_options: "defaults" + fstab_freq: 0 + fstab_passno: 0 +``` #### Partitions diff --git a/pkg/disk/partition_table.go b/pkg/disk/partition_table.go index bf7eb71724..7aac65ba27 100644 --- a/pkg/disk/partition_table.go +++ b/pkg/disk/partition_table.go @@ -47,6 +47,12 @@ type PartitionTable struct { // Dictates if certain bits and bobs are required or not; uses the default // policy if not set. Policy *PartitionTablePolicy `json:"policy,omitempty" yaml:"policy,omitempty"` + + // Controls whether the root partition (the one containing "/") is + // grown to fill the remaining disk space during relayout. Defaults + // to true (nil means true) to preserve backward compatibility. Set + // to false to keep the root partition at its specified size. + GrowRootToFillDisk *bool `json:"grow_root_to_fill_disk,omitempty" yaml:"grow_root_to_fill_disk,omitempty"` } type PartitionTablePolicy struct { @@ -56,6 +62,15 @@ type PartitionTablePolicy struct { EnsureXBOOTLDR bool `json:"ensure_xbootldr" yaml:"ensure_xbootldr"` } +// growRootToFillDisk returns whether the root partition should be grown +// to fill remaining disk space. Defaults to true when not explicitly set. +func (pt *PartitionTable) growRootToFillDisk() bool { + if pt.GrowRootToFillDisk == nil { + return true + } + return *pt.GrowRootToFillDisk +} + var _ = MountpointCreator(&PartitionTable{}) // Offset describes the offset as a "size", this allows us to reuse @@ -130,8 +145,9 @@ func NewDefaultPartitionTablePolicy() *PartitionTablePolicy { // // In the case of raw partitioning (no LVM and no Btrfs), the partition // containing the root filesystem is grown to fill any left over space on the -// partition table. Logical Volumes are not grown to fill the space in the -// Volume Group since they are trivial to grow on a live system. +// partition table, unless the base partition table has GrowRootToFillDisk set +// to false. Logical Volumes are not grown to fill the space in the Volume +// Group since they are trivial to grow on a live system. func NewPartitionTable(basePT *PartitionTable, mountpoints []blueprint.FilesystemCustomization, imageSize datasizes.Size, mode partition.PartitioningMode, architecture arch.Arch, requiredSizes map[string]datasizes.Size, defaultFs string, rng *rand.Rand) (*PartitionTable, error) { newPT := basePT.Clone().(*PartitionTable) @@ -237,6 +253,7 @@ func (pt *PartitionTable) Clone() Entity { AbsoluteStartOffset: pt.AbsoluteStartOffset, AlignFooter: pt.AlignFooter, Policy: pt.Policy, + GrowRootToFillDisk: common.ClonePtr(pt.GrowRootToFillDisk), } for idx, partition := range pt.Partitions { @@ -519,7 +536,8 @@ func (pt *PartitionTable) applyCustomization(mountpoints []blueprint.FilesystemC // Dynamically calculate and update the start point for each of the existing // partitions. Adjusts the overall size of image to either the supplied value // in `size` or to the sum of all partitions if that is larger. Will grow the -// root partition if there is any empty space. Returns the updated start point. +// root partition if there is any empty space, unless GrowRootToFillDisk is +// set to false. Returns the updated start point. func (pt *PartitionTable) relayout(size datasizes.Size) uint64 { header := pt.HeaderSize() footer := datasizes.Size(0) @@ -562,6 +580,7 @@ func (pt *PartitionTable) relayout(size datasizes.Size) uint64 { root := &pt.Partitions[rootIdx] root.Start = start root.fitTo(root.Size) + root.Size = pt.AlignUp(root.Size) // add the extra padding specified in the partition table footer += datasizes.Size(pt.ExtraPadding) @@ -577,12 +596,11 @@ func (pt *PartitionTable) relayout(size datasizes.Size) uint64 { pt.Size = datasizes.Size(size) } - // If there is space left in the partition table, grow root - root.Size = pt.Size - datasizes.Size(root.Start) - - // Finally we shrink the last partition, i.e. the root partition, - // to leave space for the footer, e.g. the secondary GPT header. - root.Size -= footer + if pt.growRootToFillDisk() { + // Grow root to fill remaining disk space, leaving room + // for the footer (e.g. the secondary GPT header). + root.Size = pt.Size - datasizes.Size(root.Start) - footer + } // Sort partitions by start sector pt.sortPartitions() diff --git a/pkg/disk/partition_table_internal_test.go b/pkg/disk/partition_table_internal_test.go index 5a45c208f8..fb3b2682f8 100644 --- a/pkg/disk/partition_table_internal_test.go +++ b/pkg/disk/partition_table_internal_test.go @@ -4,6 +4,7 @@ import ( "fmt" "testing" + "github.com/osbuild/image-builder/internal/common" "github.com/osbuild/image-builder/pkg/datasizes" "github.com/stretchr/testify/assert" ) @@ -889,6 +890,163 @@ func TestRelayout(t *testing.T) { }, }, }, + "no-grow-dos": { + pt: &PartitionTable{ + Type: PT_DOS, + Size: 100 * MiB, + GrowRootToFillDisk: common.ToPtr(false), + Partitions: []Partition{ + { + Size: 10 * MiB, + }, + { + Payload: &Filesystem{ + Mountpoint: "/", + }, + Size: 20 * MiB, + }, + }, + }, + size: 100 * MiB, + expected: &PartitionTable{ + Type: PT_DOS, + Size: 100 * MiB, + GrowRootToFillDisk: common.ToPtr(false), + Partitions: []Partition{ + { + Start: 1 * MiB, + Size: 10 * MiB, + }, + { + Payload: &Filesystem{ + Mountpoint: "/", + }, + Start: 11 * MiB, + Size: 20 * MiB, // does NOT grow to fill + }, + }, + }, + }, + "no-grow-gpt": { + pt: &PartitionTable{ + Type: PT_GPT, + Size: 100 * MiB, + GrowRootToFillDisk: common.ToPtr(false), + Partitions: []Partition{ + { + Size: 10 * MiB, + }, + { + Payload: &Filesystem{ + Mountpoint: "/", + }, + Size: 20 * MiB, + }, + }, + }, + size: 100 * MiB, + expected: &PartitionTable{ + Type: PT_GPT, + Size: 100 * MiB, + GrowRootToFillDisk: common.ToPtr(false), + Partitions: []Partition{ + { + Start: 1 * MiB, + Size: 10 * MiB, + }, + { + Payload: &Filesystem{ + Mountpoint: "/", + }, + Start: 11 * MiB, + Size: 20 * MiB, // does NOT grow to fill + }, + }, + }, + }, + "no-grow-gpt-disk-grows-to-fit-partitions": { + // When the disk size is too small to fit all partitions, + // the disk grows to fit even with no-grow set. + pt: &PartitionTable{ + Type: PT_GPT, + Size: 10 * MiB, + GrowRootToFillDisk: common.ToPtr(false), + Partitions: []Partition{ + { + Size: 10 * MiB, + }, + { + Payload: &Filesystem{ + Mountpoint: "/", + }, + Size: 20 * MiB, + }, + }, + }, + size: 10 * MiB, + expected: &PartitionTable{ + Type: PT_GPT, + Size: 32 * MiB, // grown to fit: 1 MiB header + 10 MiB + 20 MiB + footer, aligned up + GrowRootToFillDisk: common.ToPtr(false), + Partitions: []Partition{ + { + Start: 1 * MiB, + Size: 10 * MiB, + }, + { + Payload: &Filesystem{ + Mountpoint: "/", + }, + Start: 11 * MiB, + Size: 20 * MiB, // does NOT grow beyond specified size + }, + }, + }, + }, + "no-grow-gpt-root-first": { + pt: &PartitionTable{ + Type: PT_GPT, + Size: 100 * MiB, + GrowRootToFillDisk: common.ToPtr(false), + Partitions: []Partition{ + { + Size: 10 * MiB, + Payload: &Filesystem{ + Mountpoint: "/", + }, + }, + { + Size: 20 * MiB, + }, + { + Size: 30 * MiB, + }, + }, + }, + size: 100 * MiB, + expected: &PartitionTable{ + Type: PT_GPT, + Size: 100 * MiB, + GrowRootToFillDisk: common.ToPtr(false), + Partitions: []Partition{ + { + Start: 1 * MiB, + Size: 20 * MiB, + }, + { + Start: 21 * MiB, + Size: 30 * MiB, + }, + { + Start: 51 * MiB, // root gets moved to last position + Size: 10 * MiB, // does NOT grow beyond specified size + Payload: &Filesystem{ + Mountpoint: "/", + }, + }, + }, + }, + }, } for name := range testCases { diff --git a/pkg/disk/partition_table_yaml_test.go b/pkg/disk/partition_table_yaml_test.go index 03546c1da6..a7b3191e00 100644 --- a/pkg/disk/partition_table_yaml_test.go +++ b/pkg/disk/partition_table_yaml_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "go.yaml.in/yaml/v3" + "github.com/osbuild/image-builder/internal/common" "github.com/osbuild/image-builder/pkg/datasizes" "github.com/osbuild/image-builder/pkg/disk" ) @@ -196,3 +197,63 @@ partition_table: } assert.Equal(t, expected, ptWrapper.PartitionTable) } + +func TestPartitionTableUnmarshalYAMLGrowRootToFillDisk(t *testing.T) { + tests := map[string]struct { + yaml string + expected *bool + }{ + "absent": { + yaml: ` +partition_table: + type: "gpt" + partitions: + - size: "1 MiB" + payload_type: "filesystem" + payload: + type: "ext4" + mountpoint: "/" +`, + expected: nil, + }, + "true": { + yaml: ` +partition_table: + type: "gpt" + grow_root_to_fill_disk: true + partitions: + - size: "1 MiB" + payload_type: "filesystem" + payload: + type: "ext4" + mountpoint: "/" +`, + expected: common.ToPtr(true), + }, + "false": { + yaml: ` +partition_table: + type: "gpt" + grow_root_to_fill_disk: false + partitions: + - size: "1 MiB" + payload_type: "filesystem" + payload: + type: "ext4" + mountpoint: "/" +`, + expected: common.ToPtr(false), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var ptWrapper struct { + PartitionTable disk.PartitionTable `yaml:"partition_table"` + } + err := yaml.Unmarshal([]byte(tc.yaml), &ptWrapper) + require.NoError(t, err) + assert.Equal(t, tc.expected, ptWrapper.PartitionTable.GrowRootToFillDisk) + }) + } +} From c3343a0a00f28ffddda89c650ea826aaf9605dc0 Mon Sep 17 00:00:00 2001 From: jbtrystram Date: Fri, 10 Jul 2026 17:07:14 +0200 Subject: [PATCH 2/2] pkg/disk/parition_table.go typo fix --- pkg/disk/partition_table.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/disk/partition_table.go b/pkg/disk/partition_table.go index 7aac65ba27..6e69e1e99e 100644 --- a/pkg/disk/partition_table.go +++ b/pkg/disk/partition_table.go @@ -585,7 +585,7 @@ func (pt *PartitionTable) relayout(size datasizes.Size) uint64 { // add the extra padding specified in the partition table footer += datasizes.Size(pt.ExtraPadding) - // If the sum of all partitions is bigger then the specified size, + // If the sum of all partitions is bigger than the specified size, // we use that instead. Grow the partition table size if needed. end := pt.AlignUp(datasizes.Size(root.Start) + footer + root.Size) if end > size {