Skip to content

Commit a94a8a0

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 a94a8a0

4 files changed

Lines changed: 301 additions & 10 deletions

File tree

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,44 @@ 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+
- `policy`, an *optional* object containing partition table policy options:
74+
- `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.
75+
76+
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:
77+
78+
```yaml
79+
mount_configuration: "units"
80+
partition_table:
81+
type: "gpt"
82+
size: "10 GiB"
83+
policy:
84+
grow_root_to_fill_disk: false
85+
partitions:
86+
- size: "1 MiB"
87+
type: "21686148-6449-6e6F-744e-656564454649"
88+
bootable: true
89+
- size: "200 MiB"
90+
type: "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"
91+
payload_type: "filesystem"
92+
payload:
93+
type: "vfat"
94+
mountpoint: "/boot/efi"
95+
label: "ESP"
96+
fstab_options: "defaults,uid=0,gid=0,umask=077,shortname=winnt"
97+
fstab_freq: 0
98+
fstab_passno: 2
99+
- size: "4 GiB"
100+
type: "0fc63daf-8483-4772-8e79-3d69d8477de4"
101+
payload_type: "filesystem"
102+
payload:
103+
type: "ext4"
104+
label: "root"
105+
mountpoint: "/"
106+
fstab_options: "defaults"
107+
fstab_freq: 0
108+
fstab_passno: 0
109+
```
72110

73111
#### Partitions
74112

pkg/disk/partition_table.go

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,21 @@ type PartitionTablePolicy struct {
5454
// readable by firmware (LVM, btrfs). When set to false an XBOOTLDR partition
5555
// will not be created even for those filesystems.
5656
EnsureXBOOTLDR bool `json:"ensure_xbootldr" yaml:"ensure_xbootldr"`
57+
58+
// Controls whether the root partition (the one containing "/") is
59+
// grown to fill the remaining disk space during relayout. Defaults
60+
// to true (nil means true) to preserve backward compatibility. Set
61+
// to false to keep the root partition at its specified size.
62+
GrowRootToFillDisk *bool `json:"grow_root_to_fill_disk,omitempty" yaml:"grow_root_to_fill_disk,omitempty"`
63+
}
64+
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.Policy == nil || pt.Policy.GrowRootToFillDisk == nil {
69+
return true
70+
}
71+
return *pt.Policy.GrowRootToFillDisk
5772
}
5873

5974
var _ = MountpointCreator(&PartitionTable{})
@@ -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

@@ -225,6 +241,14 @@ func (pt *PartitionTable) Clone() Entity {
225241
return nil
226242
}
227243

244+
var policyClone *PartitionTablePolicy
245+
if pt.Policy != nil {
246+
policyClone = &PartitionTablePolicy{
247+
EnsureXBOOTLDR: pt.Policy.EnsureXBOOTLDR,
248+
GrowRootToFillDisk: common.ClonePtr(pt.Policy.GrowRootToFillDisk),
249+
}
250+
}
251+
228252
clone := &PartitionTable{
229253
Size: pt.Size,
230254
UUID: pt.UUID,
@@ -236,7 +260,7 @@ func (pt *PartitionTable) Clone() Entity {
236260
StartOffset: pt.StartOffset,
237261
AbsoluteStartOffset: pt.AbsoluteStartOffset,
238262
AlignFooter: pt.AlignFooter,
239-
Policy: pt.Policy,
263+
Policy: policyClone,
240264
}
241265

242266
for idx, partition := range pt.Partitions {
@@ -519,7 +543,8 @@ func (pt *PartitionTable) applyCustomization(mountpoints []blueprint.FilesystemC
519543
// Dynamically calculate and update the start point for each of the existing
520544
// partitions. Adjusts the overall size of image to either the supplied value
521545
// 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.
546+
// root partition if there is any empty space, unless GrowRootToFillDisk is
547+
// set to false. Returns the updated start point.
523548
func (pt *PartitionTable) relayout(size datasizes.Size) uint64 {
524549
header := pt.HeaderSize()
525550
footer := datasizes.Size(0)
@@ -562,6 +587,7 @@ func (pt *PartitionTable) relayout(size datasizes.Size) uint64 {
562587
root := &pt.Partitions[rootIdx]
563588
root.Start = start
564589
root.fitTo(root.Size)
590+
root.Size = pt.AlignUp(root.Size)
565591

566592
// add the extra padding specified in the partition table
567593
footer += datasizes.Size(pt.ExtraPadding)
@@ -577,12 +603,11 @@ func (pt *PartitionTable) relayout(size datasizes.Size) uint64 {
577603
pt.Size = datasizes.Size(size)
578604
}
579605

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
606+
if pt.growRootToFillDisk() {
607+
// Grow root to fill remaining disk space, leaving room
608+
// for the footer (e.g. the secondary GPT header).
609+
root.Size = pt.Size - datasizes.Size(root.Start) - footer
610+
}
586611

587612
// Sort partitions by start sector
588613
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+
Policy: &PartitionTablePolicy{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+
Policy: &PartitionTablePolicy{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+
Policy: &PartitionTablePolicy{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+
Policy: &PartitionTablePolicy{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+
Policy: &PartitionTablePolicy{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+
Policy: &PartitionTablePolicy{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+
Policy: &PartitionTablePolicy{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+
Policy: &PartitionTablePolicy{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: 70 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,72 @@ 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+
policy:
224+
grow_root_to_fill_disk: true
225+
partitions:
226+
- size: "1 MiB"
227+
payload_type: "filesystem"
228+
payload:
229+
type: "ext4"
230+
mountpoint: "/"
231+
`,
232+
expected: common.ToPtr(true),
233+
},
234+
"false": {
235+
yaml: `
236+
partition_table:
237+
type: "gpt"
238+
policy:
239+
grow_root_to_fill_disk: false
240+
partitions:
241+
- size: "1 MiB"
242+
payload_type: "filesystem"
243+
payload:
244+
type: "ext4"
245+
mountpoint: "/"
246+
`,
247+
expected: common.ToPtr(false),
248+
},
249+
}
250+
251+
for name, tc := range tests {
252+
t.Run(name, func(t *testing.T) {
253+
var ptWrapper struct {
254+
PartitionTable disk.PartitionTable `yaml:"partition_table"`
255+
}
256+
err := yaml.Unmarshal([]byte(tc.yaml), &ptWrapper)
257+
require.NoError(t, err)
258+
if tc.expected == nil {
259+
if ptWrapper.PartitionTable.Policy != nil {
260+
assert.Nil(t, ptWrapper.PartitionTable.Policy.GrowRootToFillDisk)
261+
}
262+
} else {
263+
require.NotNil(t, ptWrapper.PartitionTable.Policy)
264+
assert.Equal(t, tc.expected, ptWrapper.PartitionTable.Policy.GrowRootToFillDisk)
265+
}
266+
})
267+
}
268+
}

0 commit comments

Comments
 (0)