Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions doc/20-advanced/20-bootc/05-sources-of-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 28 additions & 10 deletions pkg/disk/partition_table.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Comment on lines +51 to +55

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like booleans defaulting to true. We do have it in a few places, but that's usually when interacting with external configs and their defaults. If we're making our own, I'd rather avoid it.

Negative bools (like DisableX = true) are also a bit tricky and error prone. But this whole discussion is moot anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made it default to true so it keep the same existing behavior when not specified, to not change people that haven't changed their configs.

}

type PartitionTablePolicy struct {
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -562,11 +580,12 @@ 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)

// If the sum of all partitions is bigger then the specified size,
// If the sum of all partitions is bigger than the specified size,
Comment thread
jbtrystram marked this conversation as resolved.
// we use that instead. Grow the partition table size if needed.
end := pt.AlignUp(datasizes.Size(root.Start) + footer + root.Size)
if end > size {
Expand All @@ -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()
Expand Down
158 changes: 158 additions & 0 deletions pkg/disk/partition_table_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand Down
61 changes: 61 additions & 0 deletions pkg/disk/partition_table_yaml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
})
}
}
Loading