diff --git a/README.md b/README.md index c2bbf4c2e7..64a1a86108 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> [!IMPORTANT] +> Remove this line to confirm you've reviewed this PR before submitting. # image-builder CLI Build images from the command line in a convenient way. diff --git a/pkg/bootc/bootc.go b/pkg/bootc/bootc.go index e992cd686e..dd55069f6e 100644 --- a/pkg/bootc/bootc.go +++ b/pkg/bootc/bootc.go @@ -30,4 +30,9 @@ type Info struct { // What bootloader should be passed? Bootloader *string + + // Does the container's bootc have "bootc install mount"? Its + // deployment can then be mounted with the org.osbuild.bootc.deployment + // mount, whatever the storage backend, when it is the build root. + InstallMount bool } diff --git a/pkg/bootc/resolver.go b/pkg/bootc/resolver.go index c223543655..6d8b3bbc3d 100644 --- a/pkg/bootc/resolver.go +++ b/pkg/bootc/resolver.go @@ -241,6 +241,12 @@ func (c *Container) ResolveInfo(variant string) (*Info, error) { } bootcInfo.UnifiedKernel = unifiedKernel + installMount, err := c.HasInstallMount() + if err != nil { + return nil, err + } + bootcInfo.InstallMount = installMount + size, err := getContainerSize(c.ref, c.storeOpts) if err != nil { return nil, err @@ -254,10 +260,17 @@ func (c *Container) ResolveInfo(variant string) (*Info, error) { // used for build containers where we don't need all the information and trying // to get it might break things. func (c *Container) ResolveBuildInfo() (*Info, error) { + // The build container's bootc is the one that installs and mounts + // the deployment. Not all image types run bootc from the build + // container, so one without a working bootc is not an error here; + // the deployment is then mounted the way it was before "install + // mount". + installMount, _ := c.HasInstallMount() return &Info{ - Imgref: c.ref, - ImageID: c.id, - Arch: c.Arch(), + Imgref: c.ref, + ImageID: c.id, + Arch: c.Arch(), + InstallMount: installMount, }, nil } @@ -414,6 +427,32 @@ func (c *Container) UnifiedKernel() (bool, error) { return bootcInspect.Kernel.Unified, nil } +// clapUsageExitCode is the exit code of bootc (from its clap argument +// parser) for an unknown command. +const clapUsageExitCode = 2 + +// HasInstallMount finds out if the container's bootc has the "bootc install +// mount" command (bootc-dev/bootc#2483). The command is probed for rather +// than the bootc version, which doesn't tell for builds from git or +// backports. +func (c *Container) HasInstallMount() (bool, error) { + args := []string{"exec"} + args = append(args, c.storeOpts...) + args = append(args, c.id, "bootc", "install", "mount", "--help") + + /* #nosec G204 */ + if _, err := exec.Command("podman", args...).Output(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() == clapUsageExitCode { + return false, nil + } + return false, fmt.Errorf("failed to run bootc install mount --help: %w, stderr:\n%s", err, exitErr.Stderr) + } + return false, fmt.Errorf("failed to run bootc install mount --help: %w", err) + } + return true, nil +} + func findImageIdFor(cntId, ref string, extraOpts []string) (string, error) { args := []string{"inspect"} args = append(args, extraOpts...) diff --git a/pkg/bootc/resolver_test.go b/pkg/bootc/resolver_test.go index 20a5862ad5..703e64cb12 100644 --- a/pkg/bootc/resolver_test.go +++ b/pkg/bootc/resolver_test.go @@ -259,6 +259,34 @@ func TestUnifiedKernelHappy(t *testing.T) { } } +func TestHasInstallMount(t *testing.T) { + for _, tc := range []struct { + name string + exitCode int + expected bool + errMsg string + }{ + {"supported", 0, true, ""}, + {"unknown command", 2, false, ""}, + {"error", 1, false, "failed to run bootc install mount --help"}, + {"no bootc", 127, false, "failed to run bootc install mount --help"}, + } { + t.Run(tc.name, func(t *testing.T) { + makeFakePodman(t, fmt.Sprintf(`#!/bin/sh + exit %d + `, tc.exitCode)) + cnt := bootc.Container{} + installMount, err := cnt.HasInstallMount() + if tc.errMsg != "" { + assert.ErrorContains(t, err, tc.errMsg) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.expected, installMount) + }) + } +} + func TestBootloaderHappy(t *testing.T) { for _, tc := range []struct { In string diff --git a/pkg/distro/bootc/bootctest/exe/bootc.go b/pkg/distro/bootc/bootctest/exe/bootc.go index 437075a294..b0262c8c70 100644 --- a/pkg/distro/bootc/bootctest/exe/bootc.go +++ b/pkg/distro/bootc/bootctest/exe/bootc.go @@ -17,6 +17,11 @@ func fakeBootc() error { fmt.Println(`{"kernel": {"unified": false}}`) return nil } + if len(os.Args) >= 3 && os.Args[1] == "install" && os.Args[2] == "mount" { + // like a bootc without "install mount": clap's usage error + fmt.Fprintln(os.Stderr, "error: unrecognized subcommand 'mount'") + os.Exit(2) + } return fmt.Errorf("unexpected bootc arguments %v", os.Args) } diff --git a/pkg/distro/defs/bootc.go b/pkg/distro/defs/bootc.go index c5324df18d..ea7b38f400 100644 --- a/pkg/distro/defs/bootc.go +++ b/pkg/distro/defs/bootc.go @@ -26,6 +26,8 @@ type BootcDistro struct { buildSourceInfo *osinfo.Info unifiedKernel bool bootloader *string + // the build container's bootc has "bootc install mount" + installMount bool id distro.ID defaultFs string @@ -140,6 +142,7 @@ func NewBootcWithLoader(loader *Loader, name string, cinfo *bootc.Info) (*BootcD rootfsMinSize: cinfo.Size * containerSizeToDiskSizeMultiplier, bootloader: cinfo.Bootloader, unifiedKernel: cinfo.UnifiedKernel, + installMount: cinfo.InstallMount, } // load image types from bootc-generic-1 @@ -299,6 +302,7 @@ func (d *BootcDistro) SetBuildContainer(cinfo *bootc.Info) error { d.buildImgref = cinfo.Imgref d.buildImageID = cinfo.ImageID d.buildSourceInfo = cinfo.OSInfo + d.installMount = cinfo.InstallMount return nil } diff --git a/pkg/distro/defs/bootc_image_test.go b/pkg/distro/defs/bootc_image_test.go index 954d1b9344..28327c44f4 100644 --- a/pkg/distro/defs/bootc_image_test.go +++ b/pkg/distro/defs/bootc_image_test.go @@ -2,6 +2,8 @@ package defs import ( "math/rand" + "slices" + "strings" "testing" "github.com/osbuild/blueprint/pkg/blueprint" @@ -856,3 +858,116 @@ func TestManifestSubscriptionCustomization(t *testing.T) { }) } } + +func TestManifestUnifiedKernelCustomizationsWarn(t *testing.T) { + const warnPrefix = `blueprint validation failed for image type "qcow2": the bootc container has a unified kernel (UKI), which does not support: ` + rootPart := blueprint.PartitionCustomization{ + Type: "plain", + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{Mountpoint: "/", FSType: "ext4"}, + } + dataPart := blueprint.PartitionCustomization{ + Type: "plain", + MinSize: datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{Mountpoint: "/var/data", FSType: "ext4"}, + } + + for name, tc := range map[string]struct { + customizations *blueprint.Customizations + options distro.ImageOptions + expected string + // what is left when the build container's bootc has "install + // mount", which mounts the deployment for /etc and /var + expectedInstallMount string + }{ + "empty": {}, + "disk-root-only": { + customizations: &blueprint.Customizations{ + Disk: &blueprint.DiskCustomization{Partitions: []blueprint.PartitionCustomization{rootPart}}, + }, + }, + "user-and-kargs": { + customizations: &blueprint.Customizations{ + User: []blueprint.UserCustomization{{Name: "alice"}}, + Kernel: &blueprint.KernelCustomization{Append: "debug"}, + }, + expected: "customizations.user, customizations.kernel.append (" + ukiNeedsInstallMount + ")", + expectedInstallMount: "customizations.kernel.append", + }, + "group": { + customizations: &blueprint.Customizations{Group: []blueprint.GroupCustomization{{Name: "wheel2"}}}, + expected: "customizations.group (" + ukiNeedsInstallMount + ")", + }, + "files-and-dirs": { + customizations: &blueprint.Customizations{ + Directories: []blueprint.DirectoryCustomization{{Path: "/etc/foo"}}, + Files: []blueprint.FileCustomization{{Path: "/etc/foo/bar", Data: "baz"}}, + }, + expected: "customizations.directories, customizations.files (" + ukiNeedsInstallMount + ")", + }, + "ignition": { + customizations: &blueprint.Customizations{ + Ignition: &blueprint.IgnitionCustomization{FirstBoot: &blueprint.FirstBootIgnitionCustomization{ProvisioningURL: "https://example.com/config.ign"}}, + }, + expected: "customizations.ignition", + expectedInstallMount: "customizations.ignition", + }, + "bootloader": { + customizations: getBootloaderConfig().Customizations, + expected: "customizations.bootloader", + expectedInstallMount: "customizations.bootloader", + }, + "subscription": { + options: distro.ImageOptions{ + Subscription: &subscription.ImageOptions{Organization: "2040324", ActivationKey: "my-secret-key"}, + }, + expected: "subscription (" + ukiNeedsInstallMount + ")", + }, + "disk-extra-mountpoint": { + customizations: &blueprint.Customizations{ + Disk: &blueprint.DiskCustomization{Partitions: []blueprint.PartitionCustomization{rootPart, dataPart}}, + }, + expected: "customizations.disk mountpoints without an fstab (/var/data)", + expectedInstallMount: "customizations.disk mountpoints without an fstab (/var/data)", + }, + "filesystem-extra-mountpoint": { + customizations: &blueprint.Customizations{ + Filesystem: []blueprint.FilesystemCustomization{{Mountpoint: "/", MinSize: datasizes.GiB}, {Mountpoint: "/var/data", MinSize: datasizes.GiB}}, + }, + expected: "customizations.filesystem mountpoints without an fstab (/var/data)", + expectedInstallMount: "customizations.filesystem mountpoints without an fstab (/var/data)", + }, + } { + t.Run(name, func(t *testing.T) { + imgType := NewTestBootcImageType(t, "qcow2") + bp := &blueprint.Blueprint{Customizations: tc.customizations} + + // only look at the UKI warning, e.g. customizations.filesystem + // is not a supported option for bootc disks in the first place + ukiWarnings := func() []string { + _, warnings, err := imgType.Manifest(bp, tc.options, nil, common.ToPtr(int64(0))) + require.NoError(t, err) + return slices.DeleteFunc(warnings, func(w string) bool { + return !strings.HasPrefix(w, warnPrefix) + }) + } + + // without a UKI all of these are applied + assert.Empty(t, ukiWarnings()) + + bd := imgType.arch.distro.(*BootcDistro) + bd.unifiedKernel = true + for _, installMount := range []bool{false, true} { + bd.installMount = installMount + expected := tc.expected + if installMount { + expected = tc.expectedInstallMount + } + if expected == "" { + assert.Empty(t, ukiWarnings(), "install mount: %v", installMount) + } else { + assert.Equal(t, []string{warnPrefix + expected}, ukiWarnings(), "install mount: %v", installMount) + } + } + }) + } +} diff --git a/pkg/distro/defs/bootc_imagetype.go b/pkg/distro/defs/bootc_imagetype.go index b4d0769c3a..9efecb7676 100644 --- a/pkg/distro/defs/bootc_imagetype.go +++ b/pkg/distro/defs/bootc_imagetype.go @@ -261,6 +261,7 @@ func (t *bootcImageType) manifestForDisk(bp *blueprint.Blueprint, options distro img.Bootloader = bd.bootloader img.UnifiedKernel = bd.unifiedKernel + img.InstallMount = bd.installMount img.OSCustomizations.Subscription = options.Subscription img.OSCustomizations.Users = users.UsersFromBP(customizations.GetUsers()) @@ -315,6 +316,14 @@ func (t *bootcImageType) manifestForDisk(bp *blueprint.Blueprint, options distro } img.PartitionTable = pt + var warnings []string + if bd.unifiedKernel { + warnings, err = t.checkUnifiedKernelCustomizations(customizations, options, bd.installMount) + if err != nil { + return nil, nil, err + } + } + // Check Directory/File Customizations are valid dc := customizations.GetDirectories() fc := customizations.GetFiles() @@ -368,7 +377,7 @@ func (t *bootcImageType) manifestForDisk(bp *blueprint.Blueprint, options distro return nil, nil, err } - return &mf, nil, nil + return &mf, warnings, nil } func (t *bootcImageType) initAnacondaInstallerBaseFromSourceInfo(img *image.AnacondaInstallerBase, sourceInfo *osinfo.Info, customizations *blueprint.Customizations) error { @@ -836,6 +845,116 @@ func (t *bootcImageType) manifestForPXETar(bp *blueprint.Blueprint, options dist } +// unifiedKernelMountpoints are the mountpoints that work without an fstab on +// images with a unified kernel: systemd-gpt-auto-generator discovers the root +// and XBOOTLDR partitions and the ESP from their partition types (the ESP is +// always at /boot/efi in our partition tables, bootc finds it on its own). +var unifiedKernelMountpoints = []string{"/", "/boot", "/boot/efi"} + +func unsupportedUnifiedKernelMountpoints(mountpoints []string) []string { + return slices.DeleteFunc(mountpoints, func(mnt string) bool { + return slices.Contains(unifiedKernelMountpoints, mnt) + }) +} + +// diskCustomizationMountpoints returns all mountpoints of a disk +// customization, including logical volumes and btrfs subvolumes. +func diskCustomizationMountpoints(dc *blueprint.DiskCustomization) []string { + if dc == nil { + return nil + } + var mountpoints []string + for _, part := range dc.Partitions { + mountpoints = append(mountpoints, part.Mountpoint) + for _, lv := range part.LogicalVolumes { + mountpoints = append(mountpoints, lv.Mountpoint) + } + for _, subvol := range part.Subvolumes { + mountpoints = append(mountpoints, subvol.Mountpoint) + } + } + // swap and plain partitions without a filesystem have no mountpoint + return slices.DeleteFunc(mountpoints, func(mnt string) bool { return mnt == "" }) +} + +// ukiNeedsInstallMount explains how to get the /etc and /var customizations +// of an image with a unified kernel applied. +const ukiNeedsInstallMount = `customizing /etc and /var needs "bootc install mount" in the build container's bootc` + +// checkUnifiedKernelCustomizations returns a warning for the customizations +// that a disk image with a unified kernel (UKI) cannot apply. Its kernel +// command line is embedded in the signed UKI, and without "bootc install +// mount" in the build container nothing is written into the deployment after +// "bootc install", so these would otherwise be dropped silently. Like other +// blueprint validation failures this is a warning, which image-builder turns +// into an error unless --ignore-warnings is given. +func (t *bootcImageType) checkUnifiedKernelCustomizations(customizations *blueprint.Customizations, options distro.ImageOptions, installMount bool) ([]string, error) { + var unsupported []string + // with "bootc install mount", these go into the deployment's /etc and /var + if !installMount { + if len(customizations.GetUsers()) > 0 { + unsupported = append(unsupported, "customizations.user") + } + groups, err := customizations.GetGroups() + if err != nil { + return nil, err + } + if len(groups) > 0 { + unsupported = append(unsupported, "customizations.group") + } + if len(customizations.GetDirectories()) > 0 { + unsupported = append(unsupported, "customizations.directories") + } + if len(customizations.GetFiles()) > 0 { + unsupported = append(unsupported, "customizations.files") + } + if options.Subscription != nil { + unsupported = append(unsupported, "subscription") + } + } + needInstallMount := len(unsupported) > 0 + if kernel := customizations.GetKernel(); kernel != nil && kernel.Append != "" { + unsupported = append(unsupported, "customizations.kernel.append") + } + ign, err := customizations.GetIgnition() + if err != nil { + return nil, err + } + if ign != nil { + unsupported = append(unsupported, "customizations.ignition") + } + if customizations.GetBootloader() != nil { + unsupported = append(unsupported, "customizations.bootloader") + } + + // Filesystem and disk customizations are fine as long as their + // mountpoints are found without an fstab. The container's own disk.yaml + // is up to its author and not checked here. + var fsMountpoints []string + for _, fs := range customizations.GetFilesystems() { + fsMountpoints = append(fsMountpoints, fs.Mountpoint) + } + if mnts := unsupportedUnifiedKernelMountpoints(fsMountpoints); len(mnts) > 0 { + unsupported = append(unsupported, fmt.Sprintf("customizations.filesystem mountpoints without an fstab (%s)", strings.Join(mnts, ", "))) + } + diskCust, err := customizations.GetPartitioning() + if err != nil { + return nil, err + } + if mnts := unsupportedUnifiedKernelMountpoints(diskCustomizationMountpoints(diskCust)); len(mnts) > 0 { + unsupported = append(unsupported, fmt.Sprintf("customizations.disk mountpoints without an fstab (%s)", strings.Join(mnts, ", "))) + } + + if len(unsupported) == 0 { + return nil, nil + } + warning := fmt.Sprintf("blueprint validation failed for image type %q: the bootc container has a unified kernel (UKI), which does not support: %s", t.Name(), strings.Join(unsupported, ", ")) + if needInstallMount { + warning += fmt.Sprintf(" (%s)", ukiNeedsInstallMount) + } + return []string{warning}, nil +} + func PlatformFor(archStr, uefiVendor string) *platform.Data { archi := common.Must(arch.FromString(archStr)) platform := &platform.Data{ diff --git a/pkg/distro/defs/bootc_test.go b/pkg/distro/defs/bootc_test.go index 7a90fc283f..db7ededc0f 100644 --- a/pkg/distro/defs/bootc_test.go +++ b/pkg/distro/defs/bootc_test.go @@ -464,6 +464,8 @@ func TestSetBuildContainer(t *testing.T) { Arch: "aarch64", DefaultRootFs: "xfs", Size: 100 * datasizes.MiB, + // the build container's bootc is the one that counts + InstallMount: true, OSInfo: &osinfo.Info{ OSRelease: osinfo.OSRelease{ ID: "whatever", @@ -477,6 +479,8 @@ func TestSetBuildContainer(t *testing.T) { expectedImgref string expectedImageID string expectedError string + + expectedInstallMount bool } testCases := map[string]testCase{ @@ -486,12 +490,14 @@ func TestSetBuildContainer(t *testing.T) { "ok": { buildInfo: &bootc.Info{ - Imgref: "example.com/containers/distro-bootc:build42", - ImageID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - Arch: "arm64", + Imgref: "example.com/containers/distro-bootc:build42", + ImageID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + Arch: "arm64", + InstallMount: true, }, - expectedImgref: "example.com/containers/distro-bootc:build42", - expectedImageID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + expectedImgref: "example.com/containers/distro-bootc:build42", + expectedImageID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + expectedInstallMount: true, }, "noimgref": { @@ -541,6 +547,7 @@ func TestSetBuildContainer(t *testing.T) { require.Equal(tc.expectedImgref, bd.buildImgref) require.Equal(tc.expectedImageID, bd.buildImageID) + require.Equal(tc.expectedInstallMount, bd.installMount) }) } } diff --git a/pkg/image/bootc_disk.go b/pkg/image/bootc_disk.go index f2564111d6..5501ef4238 100644 --- a/pkg/image/bootc_disk.go +++ b/pkg/image/bootc_disk.go @@ -23,6 +23,8 @@ type BootcDiskImage struct { Bootloader *string UnifiedKernel bool + // The build container's bootc has "bootc install mount" + InstallMount bool Partitions []PartitionConfig @@ -114,6 +116,7 @@ func (img *BootcDiskImage) InstantiateManifestFromContainers(m *manifest.Manifes } rawImage.Bootloader = img.Bootloader rawImage.UnifiedKernel = img.UnifiedKernel + rawImage.InstallMount = img.InstallMount rawImage.PartitionTable = img.PartitionTable rawImage.OSCustomizations = img.OSCustomizations rawImage.DiskCustomizations = img.DiskCustomizations diff --git a/pkg/manifest/raw_bootc.go b/pkg/manifest/raw_bootc.go index a156f32dc7..5954e6bb12 100644 --- a/pkg/manifest/raw_bootc.go +++ b/pkg/manifest/raw_bootc.go @@ -39,6 +39,12 @@ type RawBootcImage struct { UnifiedKernel bool Bootloader *string + // The build root's bootc has "bootc install mount": the customization + // stages then mount the deployment with it, whatever the storage + // backend. Otherwise they mount it as an ostree deployment, and with a + // unified kernel (which means composefs) they are skipped. + InstallMount bool + // customizations go here because there is no intermediate // tree, with `bootc install to-filesystem` we can only work // with the image itself @@ -196,7 +202,7 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { pipeline.AddStage(stage) } - if !p.UnifiedKernel { + if p.InstallMount || !p.UnifiedKernel { // all our customizations work directly on the mounted deployment // root from the image so generate the devices/mounts for all devices, mounts, err = osbuild.GenBootupdDevicesMounts(p.filename, p.PartitionTable, p.platform) @@ -204,19 +210,32 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { return osbuild.Pipeline{}, fmt.Errorf("gen devices stage failed %w", err) } - mounts = append(mounts, *osbuild.NewOSTreeDeploymentMountDefault("ostree.deployment", osbuild.OSTreeMountSourceMount)) - mounts = append(mounts, *osbuild.NewBindMount("bind-ostree-deployment-to-tree", "mount://", "tree://")) + var deploymentMount osbuild.Mount + if p.InstallMount { + deploymentMount = *osbuild.NewBootcDeploymentMount("bootc.deployment", osbuild.OSTreeMountSourceMount) + mounts = append(mounts, deploymentMount) + mounts = append(mounts, *osbuild.NewBindMount("bind-bootc-deployment-to-tree", "mount://", "tree://")) + } else { + deploymentMount = *osbuild.NewOSTreeDeploymentMountDefault("ostree.deployment", osbuild.OSTreeMountSourceMount) + mounts = append(mounts, deploymentMount) + mounts = append(mounts, *osbuild.NewBindMount("bind-ostree-deployment-to-tree", "mount://", "tree://")) + } postStages := []*osbuild.Stage{} - fsCfgStages, err := filesystemConfigStages(pt, p.DiskCustomizations.MountConfiguration) - if err != nil { - return osbuild.Pipeline{}, err - } - for _, stage := range fsCfgStages { - stage.Mounts = mounts - stage.Devices = devices - postStages = append(postStages, stage) + // With a unified kernel, systemd-gpt-auto-generator finds the + // root and /boot from their partition types, and other mountpoints + // are refused when making the manifest. + if !p.UnifiedKernel { + fsCfgStages, err := filesystemConfigStages(pt, p.DiskCustomizations.MountConfiguration) + if err != nil { + return osbuild.Pipeline{}, err + } + for _, stage := range fsCfgStages { + stage.Mounts = mounts + stage.Devices = devices + postStages = append(postStages, stage) + } } // customize the image @@ -267,7 +286,7 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { // need to be in the root of the ostree filesystem, not in the deployment var noDeploymentMounts []osbuild.Mount for _, m := range mounts { - if m.Type == "org.osbuild.ostree.deployment" { + if m.Type == deploymentMount.Type { continue } noDeploymentMounts = append(noDeploymentMounts, m) @@ -337,7 +356,10 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { // The ignition stamp must be created after bootc install, otherwise bootc will error out // because the boot partition is not empty. // That's why we have to pass `mount://boot/` and can't write to `tree://boot/`. - if p.OSCustomizations.Ignition != nil { + // Ignition and the grub2 console configuration are not supported + // with a unified kernel: bootc installs it into the ESP and /boot + // is laid out differently. + if p.OSCustomizations.Ignition != nil && !p.UnifiedKernel { var ignitionStage *osbuild.Stage if len(p.OSCustomizations.Ignition.ProvisioningURL) > 0 { urls := strings.Fields(p.OSCustomizations.Ignition.ProvisioningURL) @@ -366,7 +388,7 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { // 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 { + if grub2dCfg := osbuild.NewGrub2DConfigFromGrub2Config(p.OSCustomizations.Grub2Config); grub2dCfg != nil && !p.UnifiedKernel { grub2dOpts := &osbuild.Grub2DStageOptions{ Config: grub2dCfg, Path: "tree:///boot/grub2/console.cfg", diff --git a/pkg/manifest/raw_bootc_test.go b/pkg/manifest/raw_bootc_test.go index 6268af7d55..7ae67885e9 100644 --- a/pkg/manifest/raw_bootc_test.go +++ b/pkg/manifest/raw_bootc_test.go @@ -2,8 +2,10 @@ package manifest_test import ( "fmt" + "maps" "os" "path/filepath" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -643,6 +645,91 @@ func TestRawBootcPXE(t *testing.T) { assert.Contains(t, mkdirPaths, "/proc") } +func TestRawBootcImageSerializeDeploymentMount(t *testing.T) { + const ( + ostreeMount = "org.osbuild.ostree.deployment" + bootcMount = "org.osbuild.bootc.deployment" + ) + for _, tc := range []struct { + name string + unifiedKernel bool + installMount bool + // the deployment mount of the customization stages, none if + // they are skipped + expectedMount string + }{ + {"ostree-mount", false, false, ostreeMount}, + {"install-mount", false, true, bootcMount}, + {"uki", true, false, ""}, + {"uki-install-mount", true, true, bootcMount}, + } { + t.Run(tc.name, func(t *testing.T) { + serialize := func(customize bool) *osbuild.Pipeline { + rawBootcPipeline := makeFakeRawBootcPipeline() + rawBootcPipeline.UnifiedKernel = tc.unifiedKernel + rawBootcPipeline.InstallMount = tc.installMount + rawBootcPipeline.OSCustomizations.SELinux = "targeted" + // set by image definitions rather than the user + rawBootcPipeline.OSCustomizations.Grub2Config = &osbuild.GRUB2Config{ + Serial: "serial --unit=0 --speed=115200", + } + rawBootcPipeline.DiskCustomizations.MountConfiguration = osbuild.MOUNT_CONFIGURATION_FSTAB + if customize { + rawBootcPipeline.OSCustomizations.Users = []users.User{{Name: "foo"}} + } + pipeline, err := rawBootcPipeline.Serialize() + require.NoError(t, err) + return &pipeline + } + deploymentMounts := func(pipeline *osbuild.Pipeline) map[string]int { + found := map[string]int{} + for _, stage := range pipeline.Stages { + for _, mntType := range []string{ostreeMount, bootcMount} { + if findMountIdx(stage.Mounts, mntType) >= 0 { + found[mntType]++ + } + } + } + return found + } + + pipeline := serialize(true) + usersStages := findPostInstallStages("org.osbuild.users", pipeline.Stages) + if tc.expectedMount == "" { + assert.Empty(t, usersStages) + assert.Empty(t, deploymentMounts(pipeline)) + } else { + require.Len(t, usersStages, 1) + mounts := usersStages[0].Mounts + deploymentIdx := findMountIdx(mounts, tc.expectedMount) + require.GreaterOrEqual(t, deploymentIdx, 0) + // the bind to the tree must come after the deployment + assert.Greater(t, findMountIdx(mounts, "org.osbuild.bind"), deploymentIdx) + // all stages use the same deployment mount + assert.Equal(t, []string{tc.expectedMount}, slices.Collect(maps.Keys(deploymentMounts(pipeline)))) + } + + // With a unified kernel, there is no fstab and no grub2 + // console configuration, and nothing is done after the + // install without customizations: the manifest doesn't + // change with "bootc install mount" then. + for _, stageType := range []string{"org.osbuild.fstab", "org.osbuild.grub2.d"} { + stages := findPostInstallStages(stageType, pipeline.Stages) + if tc.unifiedKernel { + assert.Empty(t, stages, stageType) + } else { + assert.Len(t, stages, 1, stageType) + } + } + if tc.unifiedKernel { + pipeline := serialize(false) + assert.Empty(t, deploymentMounts(pipeline)) + assert.Empty(t, findPostInstallStages("org.osbuild.selinux", pipeline.Stages)) + } + }) + } +} + func TestRawBootcImageSerializeSubscriptionManagerCommands(t *testing.T) { rawBootcPipeline := makeFakeRawBootcPipeline() rawBootcPipeline.OSCustomizations.Subscription = &subscription.ImageOptions{ diff --git a/pkg/manifestgen/manifestgen.go b/pkg/manifestgen/manifestgen.go index 02795ecbf8..d674c9e4e7 100644 --- a/pkg/manifestgen/manifestgen.go +++ b/pkg/manifestgen/manifestgen.go @@ -186,11 +186,13 @@ func (mg *Generator) Generate(bp *blueprint.Blueprint, imgType distro.ImageType, return nil, err } if len(warnings) > 0 { - warn := strings.Join(warnings, "\n") if mg.warningsOutput != nil { - fmt.Fprint(mg.warningsOutput, warn) + for _, warn := range warnings { + // e.g. the FIPS warning comes with its own newline + fmt.Fprintf(mg.warningsOutput, "WARNING: %s\n", strings.TrimRight(warn, "\n")) + } } else { - return nil, fmt.Errorf("Warnings during manifest creation:\n%v", warn) + return nil, fmt.Errorf("Warnings during manifest creation:\n%v", strings.Join(warnings, "\n")) } } pkgSetChains, err := preManifest.GetPackageSetChains() diff --git a/pkg/manifestgen/manifestgen_test.go b/pkg/manifestgen/manifestgen_test.go index acd29b3975..eaf8c1b62a 100644 --- a/pkg/manifestgen/manifestgen_test.go +++ b/pkg/manifestgen/manifestgen_test.go @@ -363,6 +363,36 @@ func TestManifestGeneratorDepsolveOutput(t *testing.T) { assert.Equal(t, []byte("fake depsolve output"), depsolveWarningsOutput.Bytes()) } +func TestManifestGeneratorWarningsOutput(t *testing.T) { + repos, err := testrepos.New() + require.NoError(t, err) + filter, err := imagefilter.New(distrofactory.NewDefault(), repos) + require.NoError(t, err) + res, err := filter.Filter("distro:centos-9", "type:qcow2", "arch:x86_64") + require.NoError(t, err) + require.Equal(t, 1, len(res)) + + // an installer customization is not supported for qcow2 images + bp := blueprint.Blueprint{ + Customizations: &blueprint.Customizations{ + Installer: &blueprint.InstallerCustomization{Unattended: true}, + }, + } + + // without a warnings output, warnings are errors + mg, err := manifestgen.New(repos, &manifestgen.Options{Depsolve: fakeDepsolve}) + require.NoError(t, err) + _, err = mg.Generate(&bp, res[0].ImgType, nil) + assert.ErrorContains(t, err, "Warnings during manifest creation:\nblueprint validation failed") + + var warningsOutput bytes.Buffer + mg, err = manifestgen.New(repos, &manifestgen.Options{Depsolve: fakeDepsolve, WarningsOutput: &warningsOutput}) + require.NoError(t, err) + _, err = mg.Generate(&bp, res[0].ImgType, nil) + require.NoError(t, err) + assert.Regexp(t, `^WARNING: blueprint validation failed for image type "qcow2": .*installer.*\n$`, warningsOutput.String()) +} + func TestManifestGeneratorOverrideRepos(t *testing.T) { repos, err := testrepos.New() assert.NoError(t, err) diff --git a/pkg/osbuild/bootc_deployment_mount.go b/pkg/osbuild/bootc_deployment_mount.go new file mode 100644 index 0000000000..ed531d9010 --- /dev/null +++ b/pkg/osbuild/bootc_deployment_mount.go @@ -0,0 +1,23 @@ +package osbuild + +// BootcDeploymentMountOptions are the options of the +// org.osbuild.bootc.deployment mount, which mounts the deployment of a +// bootc installation of either backend (ostree or composefs) with +// "bootc install mount" from the build root. +type BootcDeploymentMountOptions struct { + // Where the physical root of the installation is, like for the + // ostree deployment mount + Source OSTreeMountSource `json:"source,omitempty"` +} + +func (BootcDeploymentMountOptions) isMountOptions() {} + +func NewBootcDeploymentMount(name string, source OSTreeMountSource) *Mount { + return &Mount{ + Type: "org.osbuild.bootc.deployment", + Name: name, + Options: &BootcDeploymentMountOptions{ + Source: source, + }, + } +} diff --git a/pkg/osbuild/bootc_deployment_mount_test.go b/pkg/osbuild/bootc_deployment_mount_test.go new file mode 100644 index 0000000000..7b4edb3085 --- /dev/null +++ b/pkg/osbuild/bootc_deployment_mount_test.go @@ -0,0 +1,25 @@ +package osbuild_test + +import ( + "encoding/json" + "testing" + + "github.com/osbuild/image-builder/pkg/osbuild" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBootcDeploymentMountSerialized(t *testing.T) { + mnt := osbuild.NewBootcDeploymentMount("some-name", osbuild.OSTreeMountSourceMount) + json, err := json.MarshalIndent(mnt, "", " ") + require.Nil(t, err) + assert.Equal(t, ` +{ + "name": "some-name", + "type": "org.osbuild.bootc.deployment", + "options": { + "source": "mount" + } +}`[1:], string(json)) +}