Skip to content

Commit bd23b5d

Browse files
committed
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 coreos/fedora-coreos-tracker#2188 Assisted-By: Opencode.ai <Opus 4.6>
1 parent b3b7c12 commit bd23b5d

4 files changed

Lines changed: 282 additions & 9 deletions

File tree

doc/20-advanced/20-bootc/05-sources-of-configuration.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,42 @@ partition_table:
6969

7070
- `type`, an `enum` that can be `gpt` or `dos` and sets the partition table format to use.
7171
- `partitions`, a list of objects each of which represents a partition.
72+
- `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.
73+
- `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.
74+
75+
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:
76+
77+
```yaml
78+
mount_configuration: "units"
79+
partition_table:
80+
type: "gpt"
81+
size: "10 GiB"
82+
grow_root_to_fill_disk: false
83+
partitions:
84+
- size: "1 MiB"
85+
type: "21686148-6449-6e6F-744e-656564454649"
86+
bootable: true
87+
- size: "200 MiB"
88+
type: "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"
89+
payload_type: "filesystem"
90+
payload:
91+
type: "vfat"
92+
mountpoint: "/boot/efi"
93+
label: "ESP"
94+
fstab_options: "defaults,uid=0,gid=0,umask=077,shortname=winnt"
95+
fstab_freq: 0
96+
fstab_passno: 2
97+
- size: "4 GiB"
98+
type: "0fc63daf-8483-4772-8e79-3d69d8477de4"
99+
payload_type: "filesystem"
100+
payload:
101+
type: "ext4"
102+
label: "root"
103+
mountpoint: "/"
104+
fstab_options: "defaults"
105+
fstab_freq: 0
106+
fstab_passno: 0
107+
```
72108

73109
#### Partitions
74110

pkg/disk/partition_table.go

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ type PartitionTable struct {
4747
// Dictates if certain bits and bobs are required or not; uses the default
4848
// policy if not set.
4949
Policy *PartitionTablePolicy `json:"policy,omitempty" yaml:"policy,omitempty"`
50+
51+
// Controls whether the root partition (the one containing "/") is
52+
// grown to fill the remaining disk space during relayout. Defaults
53+
// to true (nil means true) to preserve backward compatibility. Set
54+
// to false to keep the root partition at its specified size.
55+
GrowRootToFillDisk *bool `json:"grow_root_to_fill_disk,omitempty" yaml:"grow_root_to_fill_disk,omitempty"`
5056
}
5157

5258
type PartitionTablePolicy struct {
@@ -56,6 +62,15 @@ type PartitionTablePolicy struct {
5662
EnsureXBOOTLDR bool `json:"ensure_xbootldr" yaml:"ensure_xbootldr"`
5763
}
5864

65+
// growRootToFillDisk returns whether the root partition should be grown
66+
// to fill remaining disk space. Defaults to true when not explicitly set.
67+
func (pt *PartitionTable) growRootToFillDisk() bool {
68+
if pt.GrowRootToFillDisk == nil {
69+
return true
70+
}
71+
return *pt.GrowRootToFillDisk
72+
}
73+
5974
var _ = MountpointCreator(&PartitionTable{})
6075

6176
// Offset describes the offset as a "size", this allows us to reuse
@@ -130,8 +145,9 @@ func NewDefaultPartitionTablePolicy() *PartitionTablePolicy {
130145
//
131146
// In the case of raw partitioning (no LVM and no Btrfs), the partition
132147
// containing the root filesystem is grown to fill any left over space on the
133-
// partition table. Logical Volumes are not grown to fill the space in the
134-
// Volume Group since they are trivial to grow on a live system.
148+
// partition table, unless the base partition table has GrowRootToFillDisk set
149+
// to false. Logical Volumes are not grown to fill the space in the Volume
150+
// Group since they are trivial to grow on a live system.
135151
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) {
136152
newPT := basePT.Clone().(*PartitionTable)
137153

@@ -237,6 +253,7 @@ func (pt *PartitionTable) Clone() Entity {
237253
AbsoluteStartOffset: pt.AbsoluteStartOffset,
238254
AlignFooter: pt.AlignFooter,
239255
Policy: pt.Policy,
256+
GrowRootToFillDisk: common.ClonePtr(pt.GrowRootToFillDisk),
240257
}
241258

242259
for idx, partition := range pt.Partitions {
@@ -519,7 +536,8 @@ func (pt *PartitionTable) applyCustomization(mountpoints []blueprint.FilesystemC
519536
// Dynamically calculate and update the start point for each of the existing
520537
// partitions. Adjusts the overall size of image to either the supplied value
521538
// in `size` or to the sum of all partitions if that is larger. Will grow the
522-
// root partition if there is any empty space. Returns the updated start point.
539+
// root partition if there is any empty space, unless GrowRootToFillDisk is
540+
// set to false. Returns the updated start point.
523541
func (pt *PartitionTable) relayout(size datasizes.Size) uint64 {
524542
header := pt.HeaderSize()
525543
footer := datasizes.Size(0)
@@ -562,6 +580,7 @@ func (pt *PartitionTable) relayout(size datasizes.Size) uint64 {
562580
root := &pt.Partitions[rootIdx]
563581
root.Start = start
564582
root.fitTo(root.Size)
583+
root.Size = pt.AlignUp(root.Size)
565584

566585
// add the extra padding specified in the partition table
567586
footer += datasizes.Size(pt.ExtraPadding)
@@ -577,12 +596,11 @@ func (pt *PartitionTable) relayout(size datasizes.Size) uint64 {
577596
pt.Size = datasizes.Size(size)
578597
}
579598

580-
// If there is space left in the partition table, grow root
581-
root.Size = pt.Size - datasizes.Size(root.Start)
582-
583-
// Finally we shrink the last partition, i.e. the root partition,
584-
// to leave space for the footer, e.g. the secondary GPT header.
585-
root.Size -= footer
599+
if pt.growRootToFillDisk() {
600+
// Grow root to fill remaining disk space, leaving room
601+
// for the footer (e.g. the secondary GPT header).
602+
root.Size = pt.Size - datasizes.Size(root.Start) - footer
603+
}
586604

587605
// Sort partitions by start sector
588606
pt.sortPartitions()

pkg/disk/partition_table_internal_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"testing"
66

7+
"github.com/osbuild/image-builder/internal/common"
78
"github.com/osbuild/image-builder/pkg/datasizes"
89
"github.com/stretchr/testify/assert"
910
)
@@ -889,6 +890,163 @@ func TestRelayout(t *testing.T) {
889890
},
890891
},
891892
},
893+
"no-grow-dos": {
894+
pt: &PartitionTable{
895+
Type: PT_DOS,
896+
Size: 100 * MiB,
897+
GrowRootToFillDisk: common.ToPtr(false),
898+
Partitions: []Partition{
899+
{
900+
Size: 10 * MiB,
901+
},
902+
{
903+
Payload: &Filesystem{
904+
Mountpoint: "/",
905+
},
906+
Size: 20 * MiB,
907+
},
908+
},
909+
},
910+
size: 100 * MiB,
911+
expected: &PartitionTable{
912+
Type: PT_DOS,
913+
Size: 100 * MiB,
914+
GrowRootToFillDisk: common.ToPtr(false),
915+
Partitions: []Partition{
916+
{
917+
Start: 1 * MiB,
918+
Size: 10 * MiB,
919+
},
920+
{
921+
Payload: &Filesystem{
922+
Mountpoint: "/",
923+
},
924+
Start: 11 * MiB,
925+
Size: 20 * MiB, // does NOT grow to fill
926+
},
927+
},
928+
},
929+
},
930+
"no-grow-gpt": {
931+
pt: &PartitionTable{
932+
Type: PT_GPT,
933+
Size: 100 * MiB,
934+
GrowRootToFillDisk: common.ToPtr(false),
935+
Partitions: []Partition{
936+
{
937+
Size: 10 * MiB,
938+
},
939+
{
940+
Payload: &Filesystem{
941+
Mountpoint: "/",
942+
},
943+
Size: 20 * MiB,
944+
},
945+
},
946+
},
947+
size: 100 * MiB,
948+
expected: &PartitionTable{
949+
Type: PT_GPT,
950+
Size: 100 * MiB,
951+
GrowRootToFillDisk: common.ToPtr(false),
952+
Partitions: []Partition{
953+
{
954+
Start: 1 * MiB,
955+
Size: 10 * MiB,
956+
},
957+
{
958+
Payload: &Filesystem{
959+
Mountpoint: "/",
960+
},
961+
Start: 11 * MiB,
962+
Size: 20 * MiB, // does NOT grow to fill
963+
},
964+
},
965+
},
966+
},
967+
"no-grow-gpt-disk-grows-to-fit-partitions": {
968+
// When the disk size is too small to fit all partitions,
969+
// the disk grows to fit even with no-grow set.
970+
pt: &PartitionTable{
971+
Type: PT_GPT,
972+
Size: 10 * MiB,
973+
GrowRootToFillDisk: common.ToPtr(false),
974+
Partitions: []Partition{
975+
{
976+
Size: 10 * MiB,
977+
},
978+
{
979+
Payload: &Filesystem{
980+
Mountpoint: "/",
981+
},
982+
Size: 20 * MiB,
983+
},
984+
},
985+
},
986+
size: 10 * MiB,
987+
expected: &PartitionTable{
988+
Type: PT_GPT,
989+
Size: 32 * MiB, // grown to fit: 1 MiB header + 10 MiB + 20 MiB + footer, aligned up
990+
GrowRootToFillDisk: common.ToPtr(false),
991+
Partitions: []Partition{
992+
{
993+
Start: 1 * MiB,
994+
Size: 10 * MiB,
995+
},
996+
{
997+
Payload: &Filesystem{
998+
Mountpoint: "/",
999+
},
1000+
Start: 11 * MiB,
1001+
Size: 20 * MiB, // does NOT grow beyond specified size
1002+
},
1003+
},
1004+
},
1005+
},
1006+
"no-grow-gpt-root-first": {
1007+
pt: &PartitionTable{
1008+
Type: PT_GPT,
1009+
Size: 100 * MiB,
1010+
GrowRootToFillDisk: common.ToPtr(false),
1011+
Partitions: []Partition{
1012+
{
1013+
Size: 10 * MiB,
1014+
Payload: &Filesystem{
1015+
Mountpoint: "/",
1016+
},
1017+
},
1018+
{
1019+
Size: 20 * MiB,
1020+
},
1021+
{
1022+
Size: 30 * MiB,
1023+
},
1024+
},
1025+
},
1026+
size: 100 * MiB,
1027+
expected: &PartitionTable{
1028+
Type: PT_GPT,
1029+
Size: 100 * MiB,
1030+
GrowRootToFillDisk: common.ToPtr(false),
1031+
Partitions: []Partition{
1032+
{
1033+
Start: 1 * MiB,
1034+
Size: 20 * MiB,
1035+
},
1036+
{
1037+
Start: 21 * MiB,
1038+
Size: 30 * MiB,
1039+
},
1040+
{
1041+
Start: 51 * MiB, // root gets moved to last position
1042+
Size: 10 * MiB, // does NOT grow beyond specified size
1043+
Payload: &Filesystem{
1044+
Mountpoint: "/",
1045+
},
1046+
},
1047+
},
1048+
},
1049+
},
8921050
}
8931051

8941052
for name := range testCases {

pkg/disk/partition_table_yaml_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/stretchr/testify/require"
88
"go.yaml.in/yaml/v3"
99

10+
"github.com/osbuild/image-builder/internal/common"
1011
"github.com/osbuild/image-builder/pkg/datasizes"
1112
"github.com/osbuild/image-builder/pkg/disk"
1213
)
@@ -196,3 +197,63 @@ partition_table:
196197
}
197198
assert.Equal(t, expected, ptWrapper.PartitionTable)
198199
}
200+
201+
func TestPartitionTableUnmarshalYAMLGrowRootToFillDisk(t *testing.T) {
202+
tests := map[string]struct {
203+
yaml string
204+
expected *bool
205+
}{
206+
"absent": {
207+
yaml: `
208+
partition_table:
209+
type: "gpt"
210+
partitions:
211+
- size: "1 MiB"
212+
payload_type: "filesystem"
213+
payload:
214+
type: "ext4"
215+
mountpoint: "/"
216+
`,
217+
expected: nil,
218+
},
219+
"true": {
220+
yaml: `
221+
partition_table:
222+
type: "gpt"
223+
grow_root_to_fill_disk: true
224+
partitions:
225+
- size: "1 MiB"
226+
payload_type: "filesystem"
227+
payload:
228+
type: "ext4"
229+
mountpoint: "/"
230+
`,
231+
expected: common.ToPtr(true),
232+
},
233+
"false": {
234+
yaml: `
235+
partition_table:
236+
type: "gpt"
237+
grow_root_to_fill_disk: false
238+
partitions:
239+
- size: "1 MiB"
240+
payload_type: "filesystem"
241+
payload:
242+
type: "ext4"
243+
mountpoint: "/"
244+
`,
245+
expected: common.ToPtr(false),
246+
},
247+
}
248+
249+
for name, tc := range tests {
250+
t.Run(name, func(t *testing.T) {
251+
var ptWrapper struct {
252+
PartitionTable disk.PartitionTable `yaml:"partition_table"`
253+
}
254+
err := yaml.Unmarshal([]byte(tc.yaml), &ptWrapper)
255+
require.NoError(t, err)
256+
assert.Equal(t, tc.expected, ptWrapper.PartitionTable.GrowRootToFillDisk)
257+
})
258+
}
259+
}

0 commit comments

Comments
 (0)