diff --git a/data/distrodefs/bootc-generic/imagetypes.yaml b/data/distrodefs/bootc-generic/imagetypes.yaml index 20365d81fc..4d5ad4a1f7 100644 --- a/data/distrodefs/bootc-generic/imagetypes.yaml +++ b/data/distrodefs/bootc-generic/imagetypes.yaml @@ -87,6 +87,7 @@ supported_options_lists: supported_options_disk: &supported_options_disk + - "customizations.bootloader" - "customizations.directories" - "customizations.disk" - "customizations.files" diff --git a/pkg/distro/generic/bootc_imagetype.go b/pkg/distro/generic/bootc_imagetype.go index f621d5cc4c..8437950eeb 100644 --- a/pkg/distro/generic/bootc_imagetype.go +++ b/pkg/distro/generic/bootc_imagetype.go @@ -255,6 +255,20 @@ func (t *bootcImageType) manifestForDisk(bp *blueprint.Blueprint, options distro img.OSCustomizations.KernelOptionsAppend = append(img.OSCustomizations.KernelOptionsAppend, kopts.Append) } + if bl := customizations.GetBootloader(); bl != nil && bl.Grub2 != nil { + grub2Cfg := &osbuild.GRUB2Config{} + if len(bl.Grub2.TerminalInput) > 0 { + grub2Cfg.TerminalInput = bl.Grub2.TerminalInput + } + if len(bl.Grub2.TerminalOutput) > 0 { + grub2Cfg.TerminalOutput = bl.Grub2.TerminalOutput + } + if bl.Grub2.Serial != nil { + grub2Cfg.Serial = *bl.Grub2.Serial + } + img.OSCustomizations.Grub2Config = grub2Cfg + } + rootfsMinSize := max(bd.rootfsMinSize, options.Size) pt, err := t.genPartitionTable(customizations, rootfsMinSize, rng) diff --git a/pkg/distro/generic/bootc_test.go b/pkg/distro/generic/bootc_test.go index f0ff05147e..4e54e2b673 100644 --- a/pkg/distro/generic/bootc_test.go +++ b/pkg/distro/generic/bootc_test.go @@ -678,6 +678,21 @@ func getUserConfig() *blueprint.Blueprint { } } +func getBootloaderConfig() *blueprint.Blueprint { + serial := "serial --unit=0 --speed=115200" + return &blueprint.Blueprint{ + Customizations: &blueprint.Customizations{ + Bootloader: &blueprint.BootloaderCustomization{ + Grub2: &blueprint.Grub2Customization{ + TerminalInput: []string{"serial", "console"}, + TerminalOutput: []string{"serial", "console"}, + Serial: &serial, + }, + }, + }, + } +} + func TestManifestGenerationUserConfig(t *testing.T) { userConfig := getUserConfig() testCases := map[string]manifestTestCase{ @@ -868,6 +883,27 @@ func TestManifestSerialization(t *testing.T) { imageType: "pxe-tar-xz", err: `cannot serialize pipeline "build": BuildrootFromContainer: serialization not started`, }, + "qcow2-bootloader-grub2": { + config: getBootloaderConfig(), + imageType: "qcow2", + containers: diskContainers, + expStages: map[string][]string{ + "image": { + "org.osbuild.bootc.install-to-filesystem", + "org.osbuild.grub2.d", + }, + }, + }, + "qcow2-no-bootloader": { + config: baseConfig, + imageType: "qcow2", + containers: diskContainers, + notExpectedStages: map[string][]string{ + "image": { + "org.osbuild.grub2.d", + }, + }, + }, } // Use an empty config: only the imgref is required @@ -971,6 +1007,27 @@ func TestManifestGenerationBlueprintValidation(t *testing.T) { } } +func TestManifestGenerationBootloaderValidation(t *testing.T) { + config := getBootloaderConfig() + imageOptions := distro.ImageOptions{} + + // bootloader should be accepted for disk images + t.Run("qcow2-bootloader-accepted", func(t *testing.T) { + imgType := NewTestBootcImageType(t, "qcow2") + _, warnings, err := imgType.Manifest(config, imageOptions, nil, common.ToPtr(int64(0))) + assert.NoError(t, err) + assert.Empty(t, warnings) + }) + + // bootloader should be accepted for raw images + t.Run("raw-bootloader-accepted", func(t *testing.T) { + imgType := NewTestBootcImageType(t, "raw") + _, warnings, err := imgType.Manifest(config, imageOptions, nil, common.ToPtr(int64(0))) + assert.NoError(t, err) + assert.Empty(t, warnings) + }) +} + func TestBootcIsoManifestSerialization(t *testing.T) { bd := NewTestBootcDistro(t) imgType, err := bd.arches["x86_64"].GetImageType("bootc-generic-iso") diff --git a/pkg/manifest/raw_bootc.go b/pkg/manifest/raw_bootc.go index 91c896bb46..13917a4f6f 100644 --- a/pkg/manifest/raw_bootc.go +++ b/pkg/manifest/raw_bootc.go @@ -309,6 +309,23 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { postStages = append(postStages, ignitionStage) } + // Apply grub2 console configuration (terminal_input, terminal_output, + // serial) via the grub2.d stage which writes a drop-in config file + // under boot/grub2/. Like ignition, this writes to /boot so we use + // bootupd mounts. + if grub2dCfg := osbuild.NewGrub2DConfigFromGrub2Config(p.OSCustomizations.Grub2Config); grub2dCfg != nil { + grub2dOpts := &osbuild.Grub2DStageOptions{ + Config: grub2dCfg, + Path: "tree:///boot/grub2/console.cfg", + } + grub2dStage := osbuild.NewGrub2DStage(grub2dOpts) + grub2dStage.Devices, grub2dStage.Mounts, err = osbuild.GenBootupdDevicesMounts(p.filename, p.PartitionTable, p.platform) + if err != nil { + return osbuild.Pipeline{}, fmt.Errorf("gen devices for grub2.d stage failed %w", err) + } + postStages = append(postStages, grub2dStage) + } + pipeline.AddStages(postStages...) // In case we created any files in the deploy directory we need to relabel diff --git a/pkg/manifest/raw_bootc_test.go b/pkg/manifest/raw_bootc_test.go index 0964f3fab0..4627b07fed 100644 --- a/pkg/manifest/raw_bootc_test.go +++ b/pkg/manifest/raw_bootc_test.go @@ -388,6 +388,73 @@ func TestRawBootcPipelineNoMountsStages(t *testing.T) { checkStagesForNoMounts(t, common.Must(pipeline.Serialize()).Stages) } +func TestRawBootcImageSerializeGrub2DStage(t *testing.T) { + for _, tc := range []struct { + name string + grub2Config *osbuild.GRUB2Config + expectStage bool + expectedPath string + }{ + { + name: "nil-config", + grub2Config: nil, + expectStage: false, + }, + { + name: "empty-config", + grub2Config: &osbuild.GRUB2Config{}, + expectStage: false, + }, + { + name: "only-unrelated-fields", + grub2Config: &osbuild.GRUB2Config{ + Default: "saved", + Timeout: 5, + }, + expectStage: false, + }, + { + name: "serial-only", + grub2Config: &osbuild.GRUB2Config{ + Serial: "serial --unit=0 --speed=115200", + }, + expectStage: true, + expectedPath: "tree:///boot/grub2/console.cfg", + }, + { + name: "all-console-fields", + grub2Config: &osbuild.GRUB2Config{ + TerminalInput: []string{"serial", "console"}, + TerminalOutput: []string{"serial", "console"}, + Serial: "serial --unit=0 --speed=115200", + }, + expectStage: true, + expectedPath: "tree:///boot/grub2/console.cfg", + }, + } { + t.Run(tc.name, func(t *testing.T) { + rawBootcPipeline := makeFakeRawBootcPipeline() + rawBootcPipeline.OSCustomizations.Grub2Config = tc.grub2Config + + pipeline, err := rawBootcPipeline.Serialize() + assert.NoError(t, err) + + grub2dStage := findStage("org.osbuild.grub2.d", pipeline.Stages) + if tc.expectStage { + require.NotNil(t, grub2dStage) + opts := grub2dStage.Options.(*osbuild.Grub2DStageOptions) + assert.Equal(t, tc.expectedPath, opts.Path) + assert.NotNil(t, opts.Config) + // verify bootupd mounts are set up + assert.NotEmpty(t, grub2dStage.Devices) + assert.NotEmpty(t, grub2dStage.Mounts) + } else { + assert.Nil(t, grub2dStage) + } + }) + } +} + func TestRawBootcPXE(t *testing.T) { rawBootcPipeline := makeFakeRawBootcPipeline() rawBootcPipeline.KernelVersion = "5.14.0-611.4.1.el9_7.x86_64" diff --git a/pkg/osbuild/grub2_d_stage.go b/pkg/osbuild/grub2_d_stage.go new file mode 100644 index 0000000000..29037c9438 --- /dev/null +++ b/pkg/osbuild/grub2_d_stage.go @@ -0,0 +1,48 @@ +package osbuild + +// Grub2DStageOptions represents options for the +// org.osbuild.grub2.d stage. +// +// This stage writes a GRUB2 drop-in configuration file at a +// configurable path relative to the filesystem root. +type Grub2DStageOptions struct { + // Path relative to the filesystem root + Path string `json:"path"` + + // GRUB2 configuration to write + Config *Grub2DConfig `json:"config"` +} + +// Grub2DConfig contains GRUB2 settings for a drop-in config file. +type Grub2DConfig struct { + TerminalInput []string `json:"terminal_input,omitempty"` + TerminalOutput []string `json:"terminal_output,omitempty"` + Serial string `json:"serial,omitempty"` +} + +func (Grub2DStageOptions) isStageOptions() {} + +func NewGrub2DStage(options *Grub2DStageOptions) *Stage { + return &Stage{ + Type: "org.osbuild.grub2.d", + Options: options, + } +} + +// NewGrub2DConfigFromGrub2Config creates a Grub2DConfig from a +// GRUB2Config, extracting only the console-related fields. +// Returns nil if no console settings are present. +func NewGrub2DConfigFromGrub2Config(cfg *GRUB2Config) *Grub2DConfig { + if cfg == nil { + return nil + } + c := &Grub2DConfig{ + TerminalInput: cfg.TerminalInput, + TerminalOutput: cfg.TerminalOutput, + Serial: cfg.Serial, + } + if len(c.TerminalInput) == 0 && len(c.TerminalOutput) == 0 && c.Serial == "" { + return nil + } + return c +} diff --git a/pkg/osbuild/grub2_d_stage_test.go b/pkg/osbuild/grub2_d_stage_test.go new file mode 100644 index 0000000000..7742446430 --- /dev/null +++ b/pkg/osbuild/grub2_d_stage_test.go @@ -0,0 +1,144 @@ +package osbuild + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewGrub2DStage(t *testing.T) { + opts := &Grub2DStageOptions{ + Path: "tree:///boot/grub2/console.cfg", + Config: &Grub2DConfig{ + Serial: "serial --unit=0 --speed=115200", + }, + } + stage := NewGrub2DStage(opts) + assert.Equal(t, "org.osbuild.grub2.d", stage.Type) + assert.Equal(t, opts, stage.Options) +} + +func TestNewGrub2DConfigFromGrub2Config(t *testing.T) { + tests := map[string]struct { + input *GRUB2Config + expected *Grub2DConfig + }{ + "nil": { + input: nil, + expected: nil, + }, + "empty": { + input: &GRUB2Config{}, + expected: nil, + }, + "only-unrelated-fields": { + // GRUB2Config fields that are not console-related + // should not produce a Grub2DConfig + input: &GRUB2Config{ + Default: "saved", + Distributor: "$(sed 's, release .*$,,g' /etc/system-release)", + Timeout: 5, + }, + expected: nil, + }, + "serial-only": { + input: &GRUB2Config{ + Serial: "serial --unit=0 --speed=115200", + }, + expected: &Grub2DConfig{ + Serial: "serial --unit=0 --speed=115200", + }, + }, + "terminal-input-only": { + input: &GRUB2Config{ + TerminalInput: []string{"serial", "console"}, + }, + expected: &Grub2DConfig{ + TerminalInput: []string{"serial", "console"}, + }, + }, + "terminal-output-only": { + input: &GRUB2Config{ + TerminalOutput: []string{"serial"}, + }, + expected: &Grub2DConfig{ + TerminalOutput: []string{"serial"}, + }, + }, + "all-console-fields": { + input: &GRUB2Config{ + TerminalInput: []string{"serial", "console"}, + TerminalOutput: []string{"serial", "console"}, + Serial: "serial --unit=0 --speed=115200", + // unrelated fields should be ignored + Default: "saved", + Timeout: 10, + }, + expected: &Grub2DConfig{ + TerminalInput: []string{"serial", "console"}, + TerminalOutput: []string{"serial", "console"}, + Serial: "serial --unit=0 --speed=115200", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result := NewGrub2DConfigFromGrub2Config(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestGrub2DStageOptionsJSON(t *testing.T) { + opts := &Grub2DStageOptions{ + Path: "tree:///boot/grub2/console.cfg", + Config: &Grub2DConfig{ + TerminalInput: []string{"serial", "console"}, + TerminalOutput: []string{"serial", "console"}, + Serial: "serial --unit=0 --speed=115200", + }, + } + + data, err := json.MarshalIndent(opts, "", " ") + require.NoError(t, err) + + expected := `{ + "path": "tree:///boot/grub2/console.cfg", + "config": { + "terminal_input": [ + "serial", + "console" + ], + "terminal_output": [ + "serial", + "console" + ], + "serial": "serial --unit=0 --speed=115200" + } +}` + assert.Equal(t, expected, string(data)) +} + +func TestGrub2DStageOptionsJSONOmitEmpty(t *testing.T) { + // only serial set, terminal fields should be omitted + opts := &Grub2DStageOptions{ + Path: "tree:///boot/grub2/console.cfg", + Config: &Grub2DConfig{ + Serial: "serial --unit=0 --speed=9600", + }, + } + + data, err := json.MarshalIndent(opts, "", " ") + require.NoError(t, err) + + expected := `{ + "path": "tree:///boot/grub2/console.cfg", + "config": { + "serial": "serial --unit=0 --speed=9600" + } +}` + assert.Equal(t, expected, string(data)) +}