From e8996a2b6178d36b9c979e59260ff7dbb69e9b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Budai?= Date: Mon, 24 Nov 2025 13:30:53 +0100 Subject: [PATCH 1/2] stages/kickstart: add support for the bootc verb --- pkg/osbuild/kickstart_stage.go | 31 ++++++++ pkg/osbuild/kickstart_stage_test.go | 117 ++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/pkg/osbuild/kickstart_stage.go b/pkg/osbuild/kickstart_stage.go index caef867cdb..c537949ebd 100644 --- a/pkg/osbuild/kickstart_stage.go +++ b/pkg/osbuild/kickstart_stage.go @@ -22,6 +22,7 @@ type KickstartStageOptions struct { OSTreeCommit *OSTreeCommitOptions `json:"ostree,omitempty"` OSTreeContainer *OSTreeContainerOptions `json:"ostreecontainer,omitempty"` + Bootc *BootcOptions `json:"bootc,omitempty"` LiveIMG *LiveIMGOptions `json:"liveimg,omitempty"` @@ -67,6 +68,11 @@ type OSTreeContainerOptions struct { SignatureVerification bool `json:"signatureverification"` } +type BootcOptions struct { + SourceImgRef string `json:"source-imgref"` + TargetImgRef string `json:"target-imgref,omitempty"` +} + type RebootOptions struct { Eject bool `json:"eject,omitempty"` KExec bool `json:"kexec,omitempty"` @@ -308,6 +314,31 @@ func NewKickstartStageOptionsWithOSTreeContainer( return options, nil } +func NewKickstartStageOptionsWithBootc( + path string, + userCustomizations []users.User, + groupCustomizations []users.Group, + sourceImgRef string, + targetImgRef string) (*KickstartStageOptions, error) { + + options, err := NewKickstartStageOptions(path, userCustomizations, groupCustomizations) + + if err != nil { + return nil, err + } + + if sourceImgRef != "" { + bootcOptions := &BootcOptions{ + SourceImgRef: sourceImgRef, + TargetImgRef: targetImgRef, + } + + options.Bootc = bootcOptions + } + + return options, nil +} + func NewKickstartStageOptionsWithLiveIMG( path string, userCustomizations []users.User, diff --git a/pkg/osbuild/kickstart_stage_test.go b/pkg/osbuild/kickstart_stage_test.go index cee58dc9fd..88711234ae 100644 --- a/pkg/osbuild/kickstart_stage_test.go +++ b/pkg/osbuild/kickstart_stage_test.go @@ -559,6 +559,123 @@ func TestNewKicstartStageOptionsWithOSTreeContainer(t *testing.T) { } } +func TestNewKickstartStageOptionsWithBootc(t *testing.T) { + type testCase struct { + path string + userCustomizations []users.User + sourceImgRef string + targetImgRef string + + expOptions *osbuild.KickstartStageOptions + expErr string + } + + testCases := map[string]testCase{ + "empty": { + expErr: "org.osbuild.kickstart: kickstart path \"\" is invalid", + }, + "user": { + path: "/osbuild-test.ks", + userCustomizations: []users.User{ + { + Name: "fisher", + Shell: common.ToPtr("/bin/fish"), + }, + }, + expOptions: &osbuild.KickstartStageOptions{ + Path: "/osbuild-test.ks", + Users: map[string]osbuild.UsersStageOptionsUser{ + "fisher": { + Shell: common.ToPtr("/bin/fish"), + }, + }, + }, + }, + "bootc": { + path: "/osbuild-test.ks", + sourceImgRef: "docker://quay.io/fedora/fedora-bootc:latest", + + expOptions: &osbuild.KickstartStageOptions{ + Path: "/osbuild-test.ks", + Bootc: &osbuild.BootcOptions{ + SourceImgRef: "docker://quay.io/fedora/fedora-bootc:latest", + TargetImgRef: "", + }, + }, + }, + "bootc-with-target": { + path: "/osbuild-test.ks", + sourceImgRef: "oci:/run/install/repo/container", + targetImgRef: "docker://quay.io/fedora/fedora-bootc:latest", + + expOptions: &osbuild.KickstartStageOptions{ + Path: "/osbuild-test.ks", + Bootc: &osbuild.BootcOptions{ + SourceImgRef: "oci:/run/install/repo/container", + TargetImgRef: "docker://quay.io/fedora/fedora-bootc:latest", + }, + }, + }, + "user+bootc": { + path: "/osbuild-test.ks", + userCustomizations: []users.User{ + { + Name: "fisher", + Shell: common.ToPtr("/bin/fish"), + }, + }, + sourceImgRef: "registry.example.org/my-image:latest", + targetImgRef: "registry.example.org/my-image:stable", + + expOptions: &osbuild.KickstartStageOptions{ + Path: "/osbuild-test.ks", + Users: map[string]osbuild.UsersStageOptionsUser{ + "fisher": { + Shell: common.ToPtr("/bin/fish"), + }, + }, + Bootc: &osbuild.BootcOptions{ + SourceImgRef: "registry.example.org/my-image:latest", + TargetImgRef: "registry.example.org/my-image:stable", + }, + }, + }, + "internal-error": { + path: "/osbuild-test.ks", + // only need to check that an error from NewKickstartStageOptions + // is propagated + userCustomizations: []users.User{ + { + Name: "root", + Shell: common.ToPtr("/bin/tsh"), + }, + }, + expErr: "org.osbuild.kickstart: unsupported options for user \"root\": shell", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + ksOptions, err := osbuild.NewKickstartStageOptionsWithBootc( + tc.path, + tc.userCustomizations, + nil, + tc.sourceImgRef, + tc.targetImgRef, + ) + if tc.expErr != "" { + assert.EqualError(err, tc.expErr) + return + } + + assert.NoError(err) + assert.Equal(tc.expOptions, ksOptions) // new file path must be the original kickstart path + }) + } +} + func TestNewKicstartStageOptionsWithLiveIMG(t *testing.T) { type testCase struct { path string From 3e6ced65416d9b51b989870eb32f86ece0783688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Budai?= Date: Tue, 25 Nov 2025 12:38:00 +0100 Subject: [PATCH 2/2] many: support bootc verb in bootc-installer anaconda has recently got a new verb for bootc container images. This commit adds support for it. It's currently opt-in via yaml definitions before we decide how to enable it automatically for relevant distros. --- data/distrodefs/bootc-generic/imagetypes.yaml | 3 + pkg/distro/bootc/bootc.go | 5 ++ pkg/distro/installer_config.go | 4 ++ pkg/image/anaconda_container_installer.go | 5 ++ pkg/manifest/anaconda_installer_iso_tree.go | 70 ++++++++++++------- .../anaconda_installer_iso_tree_test.go | 70 +++++++++++++++++++ 6 files changed, 133 insertions(+), 24 deletions(-) diff --git a/data/distrodefs/bootc-generic/imagetypes.yaml b/data/distrodefs/bootc-generic/imagetypes.yaml index a85bdacda7..b5d2fc6488 100644 --- a/data/distrodefs/bootc-generic/imagetypes.yaml +++ b/data/distrodefs/bootc-generic/imagetypes.yaml @@ -163,6 +163,9 @@ image_types: filename: "installer.iso" boot_iso: true image_func: "bootc_legacy_iso" + # Uncomment the following lines to use the bootc verb instead of ostreecontainer + # installer_config: + # bootc_install_verb: bootc # XXX: ideally we would use name_aliases but the loader lib # does not not fully support this yet diff --git a/pkg/distro/bootc/bootc.go b/pkg/distro/bootc/bootc.go index d72320fe92..03a5f0f34a 100644 --- a/pkg/distro/bootc/bootc.go +++ b/pkg/distro/bootc/bootc.go @@ -556,6 +556,11 @@ func (t *BootcImageType) manifestForISO(bp *blueprint.Blueprint, options distro. // see https://github.com/osbuild/bootc-image-builder/issues/733 img.InstallerCustomizations.ISORootfsType = manifest.SquashfsRootfs + installerConfig := t.ImageTypeYAML.InstallerConfig(t.arch.distro.id, t.arch.Name()) + if installerConfig != nil && installerConfig.BootcInstallVerb != nil { + img.BootcInstallVerb = *installerConfig.BootcInstallVerb + } + installRootfsType, err := disk.NewFSType(t.arch.distro.defaultFs) if err != nil { return nil, nil, err diff --git a/pkg/distro/installer_config.go b/pkg/distro/installer_config.go index 6895bb3576..e3b148caec 100644 --- a/pkg/distro/installer_config.go +++ b/pkg/distro/installer_config.go @@ -32,6 +32,10 @@ type InstallerConfig struct { LoraxTemplatePackage *string `yaml:"lorax_template_package"` LoraxLogosPackage *string `yaml:"lorax_logos_package"` LoraxReleasePackage *string `yaml:"lorax_release_package"` + + // BootcInstallVerb controls which directive to use in kickstart files for bootc installer ISOs. + // Valid values are "ostreecontainer" (default) and "bootc" + BootcInstallVerb *string `yaml:"bootc_install_verb,omitempty"` } // InheritFrom inherits unset values from the provided parent configuration and diff --git a/pkg/image/anaconda_container_installer.go b/pkg/image/anaconda_container_installer.go index 97f4d51945..bb68c6aae3 100644 --- a/pkg/image/anaconda_container_installer.go +++ b/pkg/image/anaconda_container_installer.go @@ -46,6 +46,10 @@ type AnacondaContainerInstaller struct { InitramfsPath string // bootc installer cannot use /root as installer home InstallerHome string + + // BootcInstallVerb controls which directive to use in kickstart files. + // Valid values are "ostreecontainer" (default) and "bootc" + BootcInstallVerb string } func NewAnacondaContainerInstaller(platform platform.Platform, filename string, container container.SourceSpec, ref string) *AnacondaContainerInstaller { @@ -131,6 +135,7 @@ func (img *AnacondaContainerInstaller) InstantiateManifestFromContainer(m *manif isoTreePipeline.PartitionTable = efiBootPartitionTable(rng) isoTreePipeline.Release = img.InstallerCustomizations.Release isoTreePipeline.Kickstart = img.Kickstart + isoTreePipeline.BootcInstallVerb = img.BootcInstallVerb isoTreePipeline.RootfsCompression = img.RootfsCompression isoTreePipeline.RootfsType = img.InstallerCustomizations.ISORootfsType diff --git a/pkg/manifest/anaconda_installer_iso_tree.go b/pkg/manifest/anaconda_installer_iso_tree.go index d1964da5d5..fd5ae758a8 100644 --- a/pkg/manifest/anaconda_installer_iso_tree.go +++ b/pkg/manifest/anaconda_installer_iso_tree.go @@ -131,6 +131,10 @@ type AnacondaInstallerISOTree struct { SubscriptionPipeline *Subscription InstallRootfsType disk.FSType + + // BootcInstallVerb controls which directive to use in kickstart files. + // Valid values are "ostreecontainer" (default) and "bootc" + BootcInstallVerb string } func NewAnacondaInstallerISOTree(buildPipeline Build, anacondaPipeline *AnacondaInstaller, rootfsPipeline *ISORootfsImg, bootTreePipeline *EFIBootTree) *AnacondaInstallerISOTree { @@ -662,31 +666,49 @@ func (p *AnacondaInstallerISOTree) bootcInstallerKickstartStages() ([]*osbuild.S stages := make([]*osbuild.Stage, 0) - // do what we can in our kickstart stage - kickstartOptions, err := osbuild.NewKickstartStageOptionsWithOSTreeContainer( - p.Kickstart.Path, - p.Kickstart.Users, - p.Kickstart.Groups, - path.Join("/run/install/repo", p.PayloadPath), - "oci", - "", - "") - if err != nil { - return nil, fmt.Errorf("failed to create kickstart stage options: %w", err) - } + var kickstartOptions *osbuild.KickstartStageOptions + var err error - // Workaround for lack of --target-imgref in Anaconda, xref https://github.com/osbuild/images/issues/380 - kickstartOptions.Post = append(kickstartOptions.Post, osbuild.PostOptions{ - ErrorOnFail: true, - Commands: []string{ - fmt.Sprintf("bootc switch --mutate-in-place --transport registry %s", p.containerSpec.LocalName), - "# used during automatic image testing as finished marker", - "if [ -c /dev/ttyS0 ]; then", - " # continue on errors here, because we used to omit --erroronfail", - ` echo "Install finished" > /dev/ttyS0 || true`, - "fi", - }, - }) + switch p.BootcInstallVerb { + case "bootc": + sourceImgRef := fmt.Sprintf("oci:%s", path.Join("/run/install/repo", p.PayloadPath)) + kickstartOptions, err = osbuild.NewKickstartStageOptionsWithBootc( + p.Kickstart.Path, + p.Kickstart.Users, + p.Kickstart.Groups, + sourceImgRef, + p.containerSpec.LocalName) + if err != nil { + return nil, fmt.Errorf("failed to create kickstart stage options: %w", err) + } + case "ostreecontainer", "": + kickstartOptions, err = osbuild.NewKickstartStageOptionsWithOSTreeContainer( + p.Kickstart.Path, + p.Kickstart.Users, + p.Kickstart.Groups, + path.Join("/run/install/repo", p.PayloadPath), + "oci", + "", + "") + if err != nil { + return nil, fmt.Errorf("failed to create kickstart stage options: %w", err) + } + + // Workaround for lack of --target-imgref in Anaconda, xref https://github.com/osbuild/images/issues/380 + kickstartOptions.Post = append(kickstartOptions.Post, osbuild.PostOptions{ + ErrorOnFail: true, + Commands: []string{ + fmt.Sprintf("bootc switch --mutate-in-place --transport registry %s", p.containerSpec.LocalName), + "# used during automatic image testing as finished marker", + "if [ -c /dev/ttyS0 ]; then", + " # continue on errors here, because we used to omit --erroronfail", + ` echo "Install finished" > /dev/ttyS0 || true`, + "fi", + }, + }) + default: + return nil, fmt.Errorf("invalid bootcInstallVerb: %s", p.BootcInstallVerb) + } // kickstart.New() already validates the options but they may have been // modified since then, so validate them before we create the stages diff --git a/pkg/manifest/anaconda_installer_iso_tree_test.go b/pkg/manifest/anaconda_installer_iso_tree_test.go index 8d150e3fb0..642f2197a6 100644 --- a/pkg/manifest/anaconda_installer_iso_tree_test.go +++ b/pkg/manifest/anaconda_installer_iso_tree_test.go @@ -963,3 +963,73 @@ func TestAnacondaISOTreeSerializeInstallRootfsType(t *testing.T) { assert.Contains(t, inlineData[0], tc.expected) } } + +func containerBootcSwitchInPost(ksOptions *osbuild.KickstartStageOptions) bool { + for _, post := range ksOptions.Post { + for _, cmd := range post.Commands { + if strings.Contains(cmd, "bootc switch") { + return true + } + } + } + return false +} + +func TestAnacondaISOTreeBootcInstallVerb(t *testing.T) { + containerPayload := makeFakeContainerPayload() + + t.Run("bootc-install-verb-bootc", func(t *testing.T) { + pipeline := newTestAnacondaISOTree() + pipeline.Kickstart = &kickstart.Options{Path: testKsPath} + pipeline.BootcInstallVerb = "bootc" + pipeline.PayloadPath = "/container" + sp, err := manifest.SerializeWith(pipeline, manifest.Inputs{Containers: []container.Spec{containerPayload}}) + assert.NoError(t, err) + + ksOptions := getKickstartOptions(sp.Stages) + assert.NotNil(t, ksOptions.Bootc) + assert.Nil(t, ksOptions.OSTreeContainer) + assert.Equal(t, "oci:/run/install/repo/container", ksOptions.Bootc.SourceImgRef) + assert.Equal(t, containerPayload.LocalName, ksOptions.Bootc.TargetImgRef) + assert.False(t, containerBootcSwitchInPost(ksOptions), "bootc switch command should not be present when using bootc install verb") + }) + + t.Run("bootc-install-verb-ostreecontainer", func(t *testing.T) { + pipeline := newTestAnacondaISOTree() + pipeline.Kickstart = &kickstart.Options{Path: testKsPath} + pipeline.BootcInstallVerb = "ostreecontainer" + pipeline.PayloadPath = "/container" + sp, err := manifest.SerializeWith(pipeline, manifest.Inputs{Containers: []container.Spec{containerPayload}}) + assert.NoError(t, err) + + ksOptions := getKickstartOptions(sp.Stages) + assert.Nil(t, ksOptions.Bootc) + assert.NotNil(t, ksOptions.OSTreeContainer) + assert.Equal(t, "/run/install/repo/container", ksOptions.OSTreeContainer.URL) + assert.True(t, containerBootcSwitchInPost(ksOptions), "bootc switch command should be present when using ostreecontainer install verb") + }) + + t.Run("bootc-install-verb-empty-default", func(t *testing.T) { + // Empty string should default to ostreecontainer behavior + pipeline := newTestAnacondaISOTree() + pipeline.Kickstart = &kickstart.Options{Path: testKsPath} + pipeline.BootcInstallVerb = "" + pipeline.PayloadPath = "/container" + sp, err := manifest.SerializeWith(pipeline, manifest.Inputs{Containers: []container.Spec{containerPayload}}) + assert.NoError(t, err) + + ksOptions := getKickstartOptions(sp.Stages) + assert.Nil(t, ksOptions.Bootc) + assert.NotNil(t, ksOptions.OSTreeContainer) + assert.True(t, containerBootcSwitchInPost(ksOptions), "bootc switch command should be present when using empty install verb (default)") + }) + + t.Run("bootc-install-verb-invalid", func(t *testing.T) { + pipeline := newTestAnacondaISOTree() + pipeline.Kickstart = &kickstart.Options{Path: testKsPath} + pipeline.BootcInstallVerb = "invalid" + pipeline.PayloadPath = "/container" + _, err := manifest.SerializeWith(pipeline, manifest.Inputs{Containers: []container.Spec{containerPayload}}) + assert.EqualError(t, err, "cannot create ostree container stages: cannot generate bootc installer kickstart stages: invalid bootcInstallVerb: invalid") + }) +}