From 8234db3b29eafc4da884b738fe49a223d15ce600 Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Wed, 30 Sep 2026 23:34:57 -0400 Subject: [PATCH 1/2] manifest: mount /var filesystems on top of the bootc deployment The post-install stages of bootc disk images (users, home directories, files, subscription and the final SELinux relabel) work on the ostree deployment. Its /var is a non-recursive bind mount of the stateroot var, so filesystems mounted at /var on the physical root stay hidden from these stages. For example, with a separate /var or /var/home logical volume, user home directories and SSH keys were written below the mountpoint on the root filesystem and were missing at boot. Mount the /var filesystems after the deployment mount and before the (recursive) bind mount to the tree, so customizations land on the filesystems that are mounted at boot. As setfiles does not descend into other mounts, also relabel each of them explicitly. Layouts without filesystems below /var produce the same manifests as before. Generated-by: AI --- pkg/manifest/raw_bootc.go | 32 +++++++++++++++-- pkg/manifest/raw_bootc_test.go | 34 +++++++++++++++++++ .../bootc_install_to_filesystem_stage.go | 6 ++++ .../bootc_install_to_filesystem_stage_test.go | 15 ++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/pkg/manifest/raw_bootc.go b/pkg/manifest/raw_bootc.go index a156f32dc7..509e18195c 100644 --- a/pkg/manifest/raw_bootc.go +++ b/pkg/manifest/raw_bootc.go @@ -204,8 +204,7 @@ 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://")) + mounts = deploymentMounts(mounts) postStages := []*osbuild.Stage{} @@ -385,7 +384,15 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { // then per the selinux policy if p.OSCustomizations.SELinux != "" { if len(postStages) > 0 { - for _, changedFile := range []string{"/etc", "/var"} { + // setfiles does not descend into other mounts, so relabel + // filesystems mounted below /var separately. + changedFiles := []string{"/etc", "/var"} + for _, m := range mounts { + if osbuild.IsVarMountpoint(m.Target) { + changedFiles = append(changedFiles, m.Target) + } + } + for _, changedFile := range changedFiles { opts := &osbuild.SELinuxStageOptions{ Target: "tree://" + changedFile, FileContexts: fmt.Sprintf("etc/selinux/%s/contexts/files/file_contexts", p.OSCustomizations.SELinux), @@ -403,6 +410,25 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { return pipeline, nil } +// deploymentMounts returns the mounts presenting the ostree deployment as the +// tree. The deployment's /var is a bind mount of the stateroot's var, which +// hides filesystems mounted on the physical root's /var. Mount those on top of +// the deployment instead, so that customizations written to /var (e.g. home +// directories) end up on the filesystems that are mounted at boot. +func deploymentMounts(diskMounts []osbuild.Mount) []osbuild.Mount { + var mounts, varMounts []osbuild.Mount + for _, m := range diskMounts { + if osbuild.IsVarMountpoint(m.Target) { + varMounts = append(varMounts, m) + } else { + mounts = append(mounts, m) + } + } + mounts = append(mounts, *osbuild.NewOSTreeDeploymentMountDefault("ostree.deployment", osbuild.OSTreeMountSourceMount)) + mounts = append(mounts, varMounts...) + return append(mounts, *osbuild.NewBindMount("bind-ostree-deployment-to-tree", "mount://", "tree://")) +} + // genMountpointSELinuxStages creates stages that label the root and /boot // mount point directories with proper SELinux contexts before bootc install. // diff --git a/pkg/manifest/raw_bootc_test.go b/pkg/manifest/raw_bootc_test.go index 6268af7d55..6dd6618954 100644 --- a/pkg/manifest/raw_bootc_test.go +++ b/pkg/manifest/raw_bootc_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -100,6 +101,39 @@ func TestRawBootcImageSerializeMountsValidated(t *testing.T) { assert.EqualError(t, err, `required mounts for bootupd stage [/boot/efi] missing`) } +func TestRawBootcImageSerializeVarMounts(t *testing.T) { + pipeline := makeFakeRawBootcPipeline() + pipeline.PartitionTable = testdisk.MakeFakePartitionTable("/", "/boot", "/boot/efi", "/var/opt", "/var/opt/app", "/tmp") + pipeline.OSCustomizations.Users = []users.User{{Name: "alice"}} + pipeline.OSCustomizations.SELinux = "targeted" + serialized, err := pipeline.Serialize() + require.NoError(t, err) + + // Customizations see /var filesystems on top of the deployment, + // rather than hidden below its stateroot /var. + stage := findStage("org.osbuild.users", serialized.Stages) + require.NotNil(t, stage) + var mounts []string + for _, mount := range stage.Mounts { + mounts = append(mounts, mount.Type+":"+mount.Target) + } + assert.Equal(t, []string{ + "org.osbuild.ext4:/", "org.osbuild.ext4:/boot", "org.osbuild.fat:/boot/efi", "org.osbuild.ext4:/tmp", + "org.osbuild.ostree.deployment:", + "org.osbuild.ext4:/var/opt", "org.osbuild.ext4:/var/opt/app", + "org.osbuild.bind:tree://", + }, mounts) + + // setfiles does not cross mounts: each /var filesystem is relabeled. + var relabeled []string + for _, stage := range findStages("org.osbuild.selinux", serialized.Stages) { + if opts := stage.Options.(*osbuild.SELinuxStageOptions); strings.HasPrefix(opts.Target, "tree://") { + relabeled = append(relabeled, opts.Target) + } + } + assert.Equal(t, []string{"tree:///etc", "tree:///var", "tree:///var/opt", "tree:///var/opt/app"}, relabeled) +} + func findMountIdx(mounts []osbuild.Mount, mntType string) int { for i, mnt := range mounts { if mnt.Type == mntType { diff --git a/pkg/osbuild/bootc_install_to_filesystem_stage.go b/pkg/osbuild/bootc_install_to_filesystem_stage.go index 0275b6bcf8..c60752b209 100644 --- a/pkg/osbuild/bootc_install_to_filesystem_stage.go +++ b/pkg/osbuild/bootc_install_to_filesystem_stage.go @@ -4,6 +4,7 @@ import ( "fmt" "slices" + "strings" "github.com/osbuild/image-builder/pkg/platform" ) @@ -60,3 +61,8 @@ func NewBootcInstallToFilesystemStage(options *BootcInstallToFilesystemOptions, Mounts: reqMounts, }, nil } + +// IsVarMountpoint returns true for /var and mountpoints below it. +func IsVarMountpoint(target string) bool { + return target == "/var" || strings.HasPrefix(target, "/var/") +} diff --git a/pkg/osbuild/bootc_install_to_filesystem_stage_test.go b/pkg/osbuild/bootc_install_to_filesystem_stage_test.go index ad52834fc3..8866a47603 100644 --- a/pkg/osbuild/bootc_install_to_filesystem_stage_test.go +++ b/pkg/osbuild/bootc_install_to_filesystem_stage_test.go @@ -71,6 +71,21 @@ func TestBootcInstallToFilesystemStageNewEssentialMountsOnly(t *testing.T) { assert.Equal(t, expectedStage, stage) } +func TestIsVarMountpoint(t *testing.T) { + for target, expected := range map[string]bool{ + "/var": true, + "/var/": true, + "/var/log": true, + "/var/lib/app": true, + "/": false, + "/variable": false, + "/opt/var": false, + "/boot/efi": false, + } { + assert.Equal(t, expected, osbuild.IsVarMountpoint(target), target) + } +} + func TestBootcInstallToFilesystemStageNewNoContainers(t *testing.T) { devices := makeOsbuildDevices("dev-for-/", "dev-for-/boot", "dev-for-/boot/efi") mounts := makeOsbuildMounts("/", "/boot", "/boot/efi") From 9776cd12d3cc57ec765a7c29c18106612b7789a1 Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Wed, 30 Sep 2026 23:35:19 -0400 Subject: [PATCH 2/2] bootc: pass /var mounts to bootc install when bootc supports them bootc can initialize filesystems mounted at /var or below in the target from the image's /var content, and advertises this as the `initialize-var-mounts` install feature in `bootc container inspect` (proposed separately in bootc). Without that, separate /var filesystems in a bootc disk image stay empty and hide the image's /var content at boot. Look up the feature in the container that runs the installation (the build container, if one is set) and only then mount /var filesystems for the install-to-filesystem stage. Otherwise the stage keeps mounting just /, /boot and /boot/efi: older bootc versions would leave the filesystems empty, and versions before 1.12 reject mountpoints in the target root. The `bootc container inspect` output is now parsed separately from running it, and run once per container. Related: https://github.com/bootc-dev/bootc/issues/1615 Generated-by: AI --- pkg/bootc/bootc.go | 4 + pkg/bootc/resolver.go | 81 ++++++++++++++----- pkg/bootc/resolver_test.go | 32 ++++++++ pkg/distro/defs/bootc.go | 26 +++--- pkg/distro/defs/bootc_imagetype.go | 1 + pkg/distro/defs/bootc_test.go | 24 ++++-- pkg/image/bootc_disk.go | 3 + pkg/manifest/raw_bootc.go | 4 +- pkg/manifest/raw_bootc_test.go | 81 ++++++++++++------- .../bootc_install_to_filesystem_stage.go | 13 +-- .../bootc_install_to_filesystem_stage_test.go | 58 ++++++++----- 11 files changed, 237 insertions(+), 90 deletions(-) diff --git a/pkg/bootc/bootc.go b/pkg/bootc/bootc.go index e992cd686e..a6f7a80070 100644 --- a/pkg/bootc/bootc.go +++ b/pkg/bootc/bootc.go @@ -28,6 +28,10 @@ type Info struct { // Is the container using a unified kernel? UnifiedKernel bool + // Does the container's bootc initialize filesystems mounted at /var + // (and below) during "bootc install to-filesystem"? + InstallVarMounts bool + // What bootloader should be passed? Bootloader *string } diff --git a/pkg/bootc/resolver.go b/pkg/bootc/resolver.go index c223543655..bdc3742192 100644 --- a/pkg/bootc/resolver.go +++ b/pkg/bootc/resolver.go @@ -235,11 +235,12 @@ func (c *Container) ResolveInfo(variant string) (*Info, error) { bootcInfo.DefaultRootFs = bootcInstallConfig.Filesystem.Root.Type bootcInfo.Bootloader = bootcInstallConfig.Bootloader - unifiedKernel, err := c.UnifiedKernel() + inspect, err := c.inspect() if err != nil { return nil, err } - bootcInfo.UnifiedKernel = unifiedKernel + bootcInfo.UnifiedKernel = inspect.Kernel.Unified + bootcInfo.InstallVarMounts = inspect.installVarMounts() size, err := getContainerSize(c.ref, c.storeOpts) if err != nil { @@ -254,10 +255,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 runs "bootc install", so its bootc determines + // which filesystems can be mounted for the installation. + installVarMounts, err := c.InstallVarMounts() + if err != nil { + return nil, err + } return &Info{ - Imgref: c.ref, - ImageID: c.id, - Arch: c.Arch(), + Imgref: c.ref, + ImageID: c.id, + Arch: c.Arch(), + InstallVarMounts: installVarMounts, }, nil } @@ -382,8 +390,35 @@ func (c *Container) InitrdModules(kver string) ([]string, error) { return strings.Split(strings.TrimRight(string(output), "\n"), "\n"), nil } -// UnifiedKernel finds out if the kernel inside the bootc container is unified -func (c *Container) UnifiedKernel() (bool, error) { +// InstallFeatureVarMounts is advertised by "bootc container inspect" when +// "bootc install to-filesystem" initializes filesystems mounted at /var (and +// below) from the image. Older versions leave such filesystems empty, so they +// hide the image's /var content, and versions before 1.12 reject them. +const InstallFeatureVarMounts = "initialize-var-mounts" + +// containerInspect is the subset of "bootc container inspect --json" used here. +type containerInspect struct { + Kernel struct { + Unified bool `json:"unified"` + } `json:"kernel"` + InstallFeatures []string `json:"install-features"` +} + +func (i *containerInspect) installVarMounts() bool { + return slices.Contains(i.InstallFeatures, InstallFeatureVarMounts) +} + +func parseContainerInspect(data []byte) (*containerInspect, error) { + var inspect containerInspect + if err := json.Unmarshal(data, &inspect); err != nil { + return nil, fmt.Errorf("failed to unmarshal bootc inspect : %w", err) + } + return &inspect, nil +} + +// inspect runs "bootc container inspect" in the container. The result is +// empty for bootc versions without that command. +func (c *Container) inspect() (*containerInspect, error) { args := []string{"exec"} args = append(args, c.storeOpts...) args = append(args, c.id, "bootc", "container", "inspect", "--json") @@ -393,25 +428,31 @@ func (c *Container) UnifiedKernel() (bool, error) { if err != nil { if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 2 { // NOTE: the 'bootc container inspect' was added in version 1.12.0, which was not in RHEL-10.1. - // Treat exit value '2' as a non-error, since it means the command is not available and return false. - return false, nil + // Treat exit value '2' as a non-error, since it means the command is not available. + return &containerInspect{}, nil } - return false, fmt.Errorf("failed to run bootc container inspect: %w, output:\n%s", err, output) + return nil, fmt.Errorf("failed to run bootc container inspect: %w, output:\n%s", err, output) } + return parseContainerInspect(output) +} - var bootcInspect struct { - Kargs []string `json:"kargs"` - Kernel struct { - Version string `json:"version"` - Unified bool `json:"unified"` - } `json:"kernel"` +// UnifiedKernel finds out if the kernel inside the bootc container is unified +func (c *Container) UnifiedKernel() (bool, error) { + inspect, err := c.inspect() + if err != nil { + return false, err } + return inspect.Kernel.Unified, nil +} - if err := json.Unmarshal(output, &bootcInspect); err != nil { - return false, fmt.Errorf("failed to unmarshal bootc inspect : %w", err) +// InstallVarMounts finds out if the bootc inside the container initializes +// filesystems mounted at /var (and below) during "bootc install to-filesystem". +func (c *Container) InstallVarMounts() (bool, error) { + inspect, err := c.inspect() + if err != nil { + return false, err } - - return bootcInspect.Kernel.Unified, nil + return inspect.installVarMounts(), nil } func findImageIdFor(cntId, ref string, extraOpts []string) (string, error) { diff --git a/pkg/bootc/resolver_test.go b/pkg/bootc/resolver_test.go index 20a5862ad5..e308c744b3 100644 --- a/pkg/bootc/resolver_test.go +++ b/pkg/bootc/resolver_test.go @@ -259,6 +259,38 @@ func TestUnifiedKernelHappy(t *testing.T) { } } +func TestInstallVarMounts(t *testing.T) { + for _, tc := range []struct { + Name string + In string + ExitCode int + Out bool + ErrMsg string + }{ + {Name: "supported", In: `{"install-features": ["initialize-var-mounts"]}`, Out: true}, + {Name: "other features", In: `{"install-features": ["something-else"]}`, Out: false}, + {Name: "no features", In: `{"kernel": {"unified": false}}`, Out: false}, + {Name: "unknown command", In: "", ExitCode: 2, Out: false}, + {Name: "error", In: "", ExitCode: 1, ErrMsg: "failed to run bootc container inspect"}, + {Name: "bad json", In: "{", ErrMsg: "failed to unmarshal bootc inspect"}, + } { + t.Run(tc.Name, func(t *testing.T) { + makeFakePodman(t, fmt.Sprintf(`#!/bin/sh + echo '%s' + exit %d + `, tc.In, tc.ExitCode)) + cnt := bootc.Container{} + varMounts, err := cnt.InstallVarMounts() + if tc.ErrMsg != "" { + assert.ErrorContains(t, err, tc.ErrMsg) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.Out, varMounts) + }) + } +} + func TestBootloaderHappy(t *testing.T) { for _, tc := range []struct { In string diff --git a/pkg/distro/defs/bootc.go b/pkg/distro/defs/bootc.go index c5324df18d..c07de42064 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 + // set from the build container, which runs "bootc install" + installVarMounts bool id distro.ID defaultFs string @@ -129,17 +131,18 @@ func NewBootcWithLoader(loader *Loader, name string, cinfo *bootc.Info) (*BootcD d := &BootcDistro{ // the ID is technically not allowed by the ID parser, as it doesn't // contain a version, but we will relax this requirement later - id: *id, - imgref: cinfo.Imgref, - imageID: cinfo.ImageID, - buildImgref: cinfo.Imgref, // default to using the same image for build for now - sourceInfo: osInfo, - buildSourceInfo: osInfo, - defaultFs: cinfo.DefaultRootFs, - releasever: osInfo.OSRelease.VersionID, - rootfsMinSize: cinfo.Size * containerSizeToDiskSizeMultiplier, - bootloader: cinfo.Bootloader, - unifiedKernel: cinfo.UnifiedKernel, + id: *id, + imgref: cinfo.Imgref, + imageID: cinfo.ImageID, + buildImgref: cinfo.Imgref, // default to using the same image for build for now + sourceInfo: osInfo, + buildSourceInfo: osInfo, + defaultFs: cinfo.DefaultRootFs, + releasever: osInfo.OSRelease.VersionID, + rootfsMinSize: cinfo.Size * containerSizeToDiskSizeMultiplier, + bootloader: cinfo.Bootloader, + unifiedKernel: cinfo.UnifiedKernel, + installVarMounts: cinfo.InstallVarMounts, } // load image types from bootc-generic-1 @@ -298,6 +301,7 @@ func (d *BootcDistro) SetBuildContainer(cinfo *bootc.Info) error { d.buildImgref = cinfo.Imgref d.buildImageID = cinfo.ImageID + d.installVarMounts = cinfo.InstallVarMounts d.buildSourceInfo = cinfo.OSInfo return nil diff --git a/pkg/distro/defs/bootc_imagetype.go b/pkg/distro/defs/bootc_imagetype.go index b4d0769c3a..b2f75ec72e 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.InstallVarMounts = bd.installVarMounts img.OSCustomizations.Subscription = options.Subscription img.OSCustomizations.Users = users.UsersFromBP(customizations.GetUsers()) diff --git a/pkg/distro/defs/bootc_test.go b/pkg/distro/defs/bootc_test.go index 7a90fc283f..e1c60d6dd1 100644 --- a/pkg/distro/defs/bootc_test.go +++ b/pkg/distro/defs/bootc_test.go @@ -459,11 +459,12 @@ func TestSetBuildContainer(t *testing.T) { // base bootc container info to initialise the distro before setting the // build container info baseBootcInfo := &bootc.Info{ - Imgref: "example.com/containers/distro-bootc:version12", - ImageID: "acf88e518194fac963a1b2e2e4110e38a4ce5fb3fceddd624fae8997d4566930", - Arch: "aarch64", - DefaultRootFs: "xfs", - Size: 100 * datasizes.MiB, + Imgref: "example.com/containers/distro-bootc:version12", + ImageID: "acf88e518194fac963a1b2e2e4110e38a4ce5fb3fceddd624fae8997d4566930", + Arch: "aarch64", + DefaultRootFs: "xfs", + Size: 100 * datasizes.MiB, + InstallVarMounts: true, OSInfo: &osinfo.Info{ OSRelease: osinfo.OSRelease{ ID: "whatever", @@ -477,6 +478,8 @@ func TestSetBuildContainer(t *testing.T) { expectedImgref string expectedImageID string expectedError string + // the build container runs bootc install, not the base container + expectedInstallVarMounts bool } testCases := map[string]testCase{ @@ -494,6 +497,16 @@ func TestSetBuildContainer(t *testing.T) { expectedImageID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", }, + "var-mounts": { + buildInfo: &bootc.Info{ + Imgref: "example.com/containers/distro-bootc:build43", + Arch: "arm64", + InstallVarMounts: true, + }, + expectedImgref: "example.com/containers/distro-bootc:build43", + expectedInstallVarMounts: true, + }, + "noimgref": { buildInfo: &bootc.Info{ ImageID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", @@ -541,6 +554,7 @@ func TestSetBuildContainer(t *testing.T) { require.Equal(tc.expectedImgref, bd.buildImgref) require.Equal(tc.expectedImageID, bd.buildImageID) + require.Equal(tc.expectedInstallVarMounts, bd.installVarMounts) }) } } diff --git a/pkg/image/bootc_disk.go b/pkg/image/bootc_disk.go index f2564111d6..ca29a8200c 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 installer's bootc initializes filesystems mounted at /var + InstallVarMounts bool Partitions []PartitionConfig @@ -114,6 +116,7 @@ func (img *BootcDiskImage) InstantiateManifestFromContainers(m *manifest.Manifes } rawImage.Bootloader = img.Bootloader rawImage.UnifiedKernel = img.UnifiedKernel + rawImage.InstallVarMounts = img.InstallVarMounts 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 509e18195c..6b09bc2a8b 100644 --- a/pkg/manifest/raw_bootc.go +++ b/pkg/manifest/raw_bootc.go @@ -38,6 +38,8 @@ type RawBootcImage struct { UnifiedKernel bool Bootloader *string + // The installer's bootc initializes filesystems mounted at /var + InstallVarMounts bool // customizations go here because there is no intermediate // tree, with `bootc install to-filesystem` we can only work @@ -186,7 +188,7 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { if err != nil { return osbuild.Pipeline{}, err } - st, err := osbuild.NewBootcInstallToFilesystemStage(opts, inputs, devices, mounts, p.platform) + st, err := osbuild.NewBootcInstallToFilesystemStage(opts, inputs, devices, mounts, p.platform, p.InstallVarMounts) if err != nil { return osbuild.Pipeline{}, err } diff --git a/pkg/manifest/raw_bootc_test.go b/pkg/manifest/raw_bootc_test.go index 6dd6618954..aebf969849 100644 --- a/pkg/manifest/raw_bootc_test.go +++ b/pkg/manifest/raw_bootc_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "testing" @@ -102,36 +103,60 @@ func TestRawBootcImageSerializeMountsValidated(t *testing.T) { } func TestRawBootcImageSerializeVarMounts(t *testing.T) { - pipeline := makeFakeRawBootcPipeline() - pipeline.PartitionTable = testdisk.MakeFakePartitionTable("/", "/boot", "/boot/efi", "/var/opt", "/var/opt/app", "/tmp") - pipeline.OSCustomizations.Users = []users.User{{Name: "alice"}} - pipeline.OSCustomizations.SELinux = "targeted" - serialized, err := pipeline.Serialize() - require.NoError(t, err) + bootMounts := []string{"/", "/boot", "/boot/efi"} + varMounts := []string{"/var/opt", "/var/opt/app"} + for _, installVarMounts := range []bool{false, true} { + t.Run(fmt.Sprintf("InstallVarMounts=%v", installVarMounts), func(t *testing.T) { + pipeline := makeFakeRawBootcPipeline() + pipeline.PartitionTable = testdisk.MakeFakePartitionTable("/", "/boot", "/boot/efi", "/var/opt", "/var/opt/app", "/tmp") + pipeline.InstallVarMounts = installVarMounts + pipeline.OSCustomizations.Users = []users.User{{Name: "alice"}} + pipeline.OSCustomizations.SELinux = "targeted" + serialized, err := pipeline.Serialize() + require.NoError(t, err) + + // Only a bootc that initializes /var filesystems gets them mounted. + expectedInstallMounts := bootMounts + if installVarMounts { + expectedInstallMounts = append(slices.Clone(bootMounts), varMounts...) + } + stage := findStage("org.osbuild.bootc.install-to-filesystem", serialized.Stages) + require.NotNil(t, stage) + assert.Equal(t, expectedInstallMounts, mountTargets(stage.Mounts)) + + // Customizations always see /var filesystems on top of the deployment, + // rather than hidden below its stateroot /var. + stage = findStage("org.osbuild.users", serialized.Stages) + require.NotNil(t, stage) + var mounts []string + for _, mount := range stage.Mounts { + mounts = append(mounts, mount.Type+":"+mount.Target) + } + assert.Equal(t, []string{ + "org.osbuild.ext4:/", "org.osbuild.ext4:/boot", "org.osbuild.fat:/boot/efi", "org.osbuild.ext4:/tmp", + "org.osbuild.ostree.deployment:", + "org.osbuild.ext4:/var/opt", "org.osbuild.ext4:/var/opt/app", + "org.osbuild.bind:tree://", + }, mounts) + + // setfiles does not cross mounts: each /var filesystem is relabeled. + var relabeled []string + for _, stage := range findStages("org.osbuild.selinux", serialized.Stages) { + if opts := stage.Options.(*osbuild.SELinuxStageOptions); strings.HasPrefix(opts.Target, "tree://") { + relabeled = append(relabeled, opts.Target) + } + } + assert.Equal(t, []string{"tree:///etc", "tree:///var", "tree:///var/opt", "tree:///var/opt/app"}, relabeled) + }) + } +} - // Customizations see /var filesystems on top of the deployment, - // rather than hidden below its stateroot /var. - stage := findStage("org.osbuild.users", serialized.Stages) - require.NotNil(t, stage) - var mounts []string - for _, mount := range stage.Mounts { - mounts = append(mounts, mount.Type+":"+mount.Target) - } - assert.Equal(t, []string{ - "org.osbuild.ext4:/", "org.osbuild.ext4:/boot", "org.osbuild.fat:/boot/efi", "org.osbuild.ext4:/tmp", - "org.osbuild.ostree.deployment:", - "org.osbuild.ext4:/var/opt", "org.osbuild.ext4:/var/opt/app", - "org.osbuild.bind:tree://", - }, mounts) - - // setfiles does not cross mounts: each /var filesystem is relabeled. - var relabeled []string - for _, stage := range findStages("org.osbuild.selinux", serialized.Stages) { - if opts := stage.Options.(*osbuild.SELinuxStageOptions); strings.HasPrefix(opts.Target, "tree://") { - relabeled = append(relabeled, opts.Target) - } +func mountTargets(mounts []osbuild.Mount) []string { + var targets []string + for _, mount := range mounts { + targets = append(targets, mount.Target) } - assert.Equal(t, []string{"tree:///etc", "tree:///var", "tree:///var/opt", "tree:///var/opt/app"}, relabeled) + return targets } func findMountIdx(mounts []osbuild.Mount, mntType string) int { diff --git a/pkg/osbuild/bootc_install_to_filesystem_stage.go b/pkg/osbuild/bootc_install_to_filesystem_stage.go index c60752b209..489ac1478c 100644 --- a/pkg/osbuild/bootc_install_to_filesystem_stage.go +++ b/pkg/osbuild/bootc_install_to_filesystem_stage.go @@ -31,9 +31,12 @@ func (BootcInstallToFilesystemOptions) isStageOptions() {} // It requires a mount setup so that bootupd can be run by bootc. I.e // "/", "/boot" and "/boot/efi" need to be set up so that // bootc/bootupd find and install all required bootloader bits. +// With varMounts, filesystems for /var and below are mounted as well, so that +// bootc initializes them from the image. Only pass it for a bootc that +// advertises this (see bootc.Info.InstallVarMounts). // // The mounts input should be generated with GenBootupdDevicesMounts. -func NewBootcInstallToFilesystemStage(options *BootcInstallToFilesystemOptions, inputs ContainerDeployInputs, devices map[string]Device, mounts []Mount, pltf platform.Platform) (*Stage, error) { +func NewBootcInstallToFilesystemStage(options *BootcInstallToFilesystemOptions, inputs ContainerDeployInputs, devices map[string]Device, mounts []Mount, pltf platform.Platform, varMounts bool) (*Stage, error) { if err := validateBootupdMounts(mounts, pltf); err != nil { return nil, err } @@ -42,13 +45,13 @@ func NewBootcInstallToFilesystemStage(options *BootcInstallToFilesystemOptions, return nil, fmt.Errorf("expected exactly one container input but got: %v (%v)", len(inputs.Images.References), inputs.Images.References) } - // Don't mount any custom mountpoints. - // Only mount the minimum required mounts for bootc: - // /, /boot, and /boot/efi, if they are already defined. + // Don't mount other custom mountpoints, bootc requires an otherwise empty + // target. Older bootc versions reject /var mountpoints, or leave those + // filesystems empty so that they hide the image's /var content at boot. requiredMountpoints := []string{"/", "/boot", "/boot/efi"} reqMounts := make([]Mount, 0, len(mounts)) for _, mount := range mounts { - if slices.Contains(requiredMountpoints, mount.Target) { + if slices.Contains(requiredMountpoints, mount.Target) || (varMounts && IsVarMountpoint(mount.Target)) { reqMounts = append(reqMounts, mount) } } diff --git a/pkg/osbuild/bootc_install_to_filesystem_stage_test.go b/pkg/osbuild/bootc_install_to_filesystem_stage_test.go index 8866a47603..62defedfba 100644 --- a/pkg/osbuild/bootc_install_to_filesystem_stage_test.go +++ b/pkg/osbuild/bootc_install_to_filesystem_stage_test.go @@ -2,6 +2,8 @@ package osbuild_test import ( "encoding/json" + "fmt" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -42,33 +44,49 @@ func TestBootcInstallToFilesystemStageNewHappy(t *testing.T) { Devices: devices, Mounts: mounts, } - stage, err := osbuild.NewBootcInstallToFilesystemStage(nil, inputs, devices, mounts, pf) + stage, err := osbuild.NewBootcInstallToFilesystemStage(nil, inputs, devices, mounts, pf, false) require.Nil(t, err) assert.Equal(t, stage, expectedStage) } -func TestBootcInstallToFilesystemStageNewEssentialMountsOnly(t *testing.T) { - devices := makeOsbuildDevices("dev-for-/", "dev-for-/boot/efi", "dev-for-/var/log") - mounts := makeOsbuildMounts("/", "/boot/efi", "/var/log") +func TestBootcInstallToFilesystemStageNewVarMounts(t *testing.T) { + devices := makeOsbuildDevices("dev-for-/", "dev-for-/boot/efi", "dev-for-/var", "dev-for-/var/log", "dev-for-/var/log/audit") + mounts := makeOsbuildMounts("/", "/boot/efi", "/var", "/var/log", "/var/log/audit", "/opt", "/tmp", "/variable") inputs := makeFakeContainerInputs() pf := &platform.Data{ Arch: arch.ARCH_X86_64, UEFIVendor: "test", } - expectedStage := &osbuild.Stage{ - Type: "org.osbuild.bootc.install-to-filesystem", - Options: (*osbuild.BootcInstallToFilesystemOptions)(nil), - Inputs: inputs, - Devices: devices, - Mounts: []osbuild.Mount{ - {Name: "mnt-for-/", Type: "org.osbuild.ext4", Source: "dev-for-/", Target: "/"}, - {Name: "mnt-for-/boot/efi", Type: "org.osbuild.vfat", Source: "dev-for-/boot/efi", Target: "/boot/efi"}, - }, + bootMounts := []osbuild.Mount{ + {Name: "mnt-for-/", Type: "org.osbuild.ext4", Source: "dev-for-/", Target: "/"}, + {Name: "mnt-for-/boot/efi", Type: "org.osbuild.vfat", Source: "dev-for-/boot/efi", Target: "/boot/efi"}, + } + varMounts := []osbuild.Mount{ + {Name: "mnt-for-/var", Type: "org.osbuild.ext4", Source: "dev-for-/var", Target: "/var"}, + {Name: "mnt-for-/var/log", Type: "org.osbuild.ext4", Source: "dev-for-/var/log", Target: "/var/log"}, + {Name: "mnt-for-/var/log/audit", Type: "org.osbuild.ext4", Source: "dev-for-/var/log/audit", Target: "/var/log/audit"}, + } + for _, tc := range []struct { + varMounts bool + expectedMounts []osbuild.Mount + }{ + {false, bootMounts}, + {true, append(slices.Clone(bootMounts), varMounts...)}, + } { + t.Run(fmt.Sprintf("varMounts=%v", tc.varMounts), func(t *testing.T) { + expectedStage := &osbuild.Stage{ + Type: "org.osbuild.bootc.install-to-filesystem", + Options: (*osbuild.BootcInstallToFilesystemOptions)(nil), + Inputs: inputs, + Devices: devices, + Mounts: tc.expectedMounts, + } + stage, err := osbuild.NewBootcInstallToFilesystemStage(nil, inputs, devices, mounts, pf, tc.varMounts) + require.Nil(t, err) + assert.Equal(t, expectedStage, stage) + }) } - stage, err := osbuild.NewBootcInstallToFilesystemStage(nil, inputs, devices, mounts, pf) - require.Nil(t, err) - assert.Equal(t, expectedStage, stage) } func TestIsVarMountpoint(t *testing.T) { @@ -95,7 +113,7 @@ func TestBootcInstallToFilesystemStageNewNoContainers(t *testing.T) { UEFIVendor: "test", } - _, err := osbuild.NewBootcInstallToFilesystemStage(nil, inputs, devices, mounts, pf) + _, err := osbuild.NewBootcInstallToFilesystemStage(nil, inputs, devices, mounts, pf, false) assert.EqualError(t, err, "expected exactly one container input but got: 0 (map[])") } @@ -115,7 +133,7 @@ func TestBootcInstallToFilesystemStageNewTwoContainers(t *testing.T) { UEFIVendor: "test", } - _, err := osbuild.NewBootcInstallToFilesystemStage(nil, inputs, devices, mounts, pf) + _, err := osbuild.NewBootcInstallToFilesystemStage(nil, inputs, devices, mounts, pf, false) assert.EqualError(t, err, "expected exactly one container input but got: 2 (map[1:{} 2:{}])") } @@ -128,7 +146,7 @@ func TestBootcInstallToFilesystemStageMissingMounts(t *testing.T) { UEFIVendor: "test", } - stage, err := osbuild.NewBootcInstallToFilesystemStage(nil, inputs, devices, mounts, pf) + stage, err := osbuild.NewBootcInstallToFilesystemStage(nil, inputs, devices, mounts, pf, false) // XXX: rename error assert.ErrorContains(t, err, "required mounts for bootupd stage [/boot/efi] missing") require.Nil(t, stage) @@ -146,7 +164,7 @@ func TestBootcInstallToFilesystemStageJsonHappy(t *testing.T) { opts := &osbuild.BootcInstallToFilesystemOptions{ TargetImgref: "quay.io/centos-bootc/centos-bootc-dev:stream9", } - stage, err := osbuild.NewBootcInstallToFilesystemStage(opts, inputs, devices, mounts, pf) + stage, err := osbuild.NewBootcInstallToFilesystemStage(opts, inputs, devices, mounts, pf, false) require.Nil(t, err) stageJson, err := json.MarshalIndent(stage, "", " ") require.Nil(t, err)