From 1335b479e34fb3a9af81124a2e7c28c77f13cecd Mon Sep 17 00:00:00 2001 From: jbtrystram Date: Wed, 8 Jul 2026 17:14:47 +0200 Subject: [PATCH] manifest/raw_bootc: Label / and /boot before running bootc install When SELinux is enabled, insert a few stages before running `bootc install` that will make sure the `root` and `boot` mount points are labelled. This avoid files under `/sysroot` created by bootc to be `unlabeled_t` and this makes sure files under `/boot` created by bootupd inherit the correct `boot_t` label. See https://github.com/osbuild/image-builder/issues/2494 Assisted-by: Opencode.ai --- pkg/manifest/raw_bootc.go | 122 ++++++++++++++++++++ pkg/manifest/raw_bootc_test.go | 205 ++++++++++++++++++++++++++++++--- 2 files changed, 310 insertions(+), 17 deletions(-) diff --git a/pkg/manifest/raw_bootc.go b/pkg/manifest/raw_bootc.go index 9b252e09d4..1d43cda1cf 100644 --- a/pkg/manifest/raw_bootc.go +++ b/pkg/manifest/raw_bootc.go @@ -145,6 +145,22 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { pipeline.AddStage(stage) } + // Label the root and /boot mount points before bootc install so that + // all files in the ostree repository and /boot hierarchy are properly + // labeled. Without this, the mount point directories end up as + // unlabeled_t and every file written by bootc inherits that label. + // See https://github.com/coreos/fedora-coreos-tracker/issues/1771 + // and https://github.com/coreos/fedora-coreos-tracker/issues/1772 + if p.OSCustomizations.SELinux != "" { + selinuxStages, err := p.genMountpointSELinuxStages() + if err != nil { + return osbuild.Pipeline{}, err + } + for _, stage := range selinuxStages { + pipeline.AddStage(stage) + } + } + if len(p.containerSpecs) != 1 { return osbuild.Pipeline{}, fmt.Errorf("expected a single container input got %v", p.containerSpecs) } @@ -387,6 +403,112 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { return pipeline, nil } +// genMountpointSELinuxStages creates stages that label the root and /boot +// mount point directories with proper SELinux contexts before bootc install. +// +// Without this, the mount point directories on the freshly created +// filesystems are unlabeled (unlabeled_t) and bootc install inherits +// that label for all files it writes. +// +// The approach mirrors what COSA does: +// 1. Create /boot (and /boot/efi on UEFI) directories on the root filesystem +// 2. Label the root mount (without /boot mounted) so the /boot directory +// mountpoint itself gets the correct label +// 3. Label /boot (with /boot mounted) so /boot/efi gets labeled correctly +// +// The file_contexts used for labeling come from the source pipeline (the +// extracted container tree) via an input reference. +func (p *RawBootcImage) genMountpointSELinuxStages() ([]*osbuild.Stage, error) { + stages := make([]*osbuild.Stage, 0, 3) + + devices, allMounts, err := osbuild.GenBootupdDevicesMounts(p.filename, p.PartitionTable, p.platform) + if err != nil { + return nil, fmt.Errorf("generating devices/mounts for mountpoint SELinux labeling: %w", err) + } + + // Partition mounts by target for selective mounting. + // Note: rootAndBootMounts intentionally excludes the EFI mount - we only + // need to mount /boot to create and label the /boot/efi directory, the + // EFI partition itself is not mounted during pre-install labeling. + var rootMounts []osbuild.Mount + var rootAndBootMounts []osbuild.Mount + hasBootPartition := false + hasEFIPartition := false + for _, mnt := range allMounts { + switch mnt.Target { + case "/": + rootMounts = append(rootMounts, mnt) + rootAndBootMounts = append(rootAndBootMounts, mnt) + case "/boot": + hasBootPartition = true + rootAndBootMounts = append(rootAndBootMounts, mnt) + case "/boot/efi": + hasEFIPartition = true + } + } + + // 1a. Create /boot directory on root filesystem (only root mounted, + // so /boot is just a directory on the root fs, not a mount point) + mkdirBootStage := osbuild.NewMkdirStage(&osbuild.MkdirStageOptions{ + Paths: []osbuild.MkdirStagePath{ + { + Path: "mount://-/boot", + Mode: common.ToPtr(os.FileMode(0755)), + }, + }, + }) + mkdirBootStage.Devices = devices + mkdirBootStage.Mounts = rootMounts + stages = append(stages, mkdirBootStage) + + // 1b. Create /boot/efi directory on the boot filesystem (needs both + // root and boot mounted so the boot partition is accessible) + if hasEFIPartition && hasBootPartition { + mkdirEfiStage := osbuild.NewMkdirStage(&osbuild.MkdirStageOptions{ + Paths: []osbuild.MkdirStagePath{ + { + Path: "mount://boot/efi", + Mode: common.ToPtr(os.FileMode(0755)), + }, + }, + }) + mkdirEfiStage.Devices = devices + mkdirEfiStage.Mounts = rootAndBootMounts + stages = append(stages, mkdirEfiStage) + } + + // Tree input for file_contexts from the source pipeline + fileContextsInputName := "tree" + fileContextsInput := osbuild.NewPipelineTreeInputs(fileContextsInputName, p.SourcePipeline) + fileContextsPath := fmt.Sprintf("input://%s/etc/selinux/%s/contexts/files/file_contexts", + fileContextsInputName, p.OSCustomizations.SELinux) + + // 2. Label root mount point (without /boot mounted so that the + // /boot directory mountpoint gets labeled) + rootSELinuxStage := osbuild.NewSELinuxStage(&osbuild.SELinuxStageOptions{ + FileContexts: fileContextsPath, + Target: "mount://-/", + }) + rootSELinuxStage.Devices = devices + rootSELinuxStage.Mounts = rootMounts + rootSELinuxStage.Inputs = fileContextsInput + stages = append(stages, rootSELinuxStage) + + // 3. Label /boot mount point (with /boot mounted so that /boot/efi + // gets labeled) + if hasBootPartition { + bootSELinuxStage := osbuild.NewSELinuxStage(&osbuild.SELinuxStageOptions{ + FileContexts: fileContextsPath, + Target: "mount://-/boot/", + }) + bootSELinuxStage.Devices = devices + bootSELinuxStage.Mounts = rootAndBootMounts + bootSELinuxStage.Inputs = fileContextsInput + stages = append(stages, bootSELinuxStage) + } + + return stages, nil +} func (p *RawBootcImage) getInline() []string { return p.inlineData } diff --git a/pkg/manifest/raw_bootc_test.go b/pkg/manifest/raw_bootc_test.go index b2d00ef87a..6268af7d55 100644 --- a/pkg/manifest/raw_bootc_test.go +++ b/pkg/manifest/raw_bootc_test.go @@ -188,14 +188,17 @@ func TestRawBootcImageSerializeMkdirOptions(t *testing.T) { pipeline, err := rawBootcPipeline.Serialize() assert.NoError(t, err) - mkdirStage := findStage("org.osbuild.mkdir", pipeline.Stages) + // Use findPostInstallStages to avoid matching pre-install mkdir + // stages (e.g. mountpoint SELinux labeling) if SELinux is ever + // set in this test. + postInstallMkdirStages := findPostInstallStages("org.osbuild.mkdir", pipeline.Stages) if len(tc.expectedMkdirPaths) > 0 { // ensure options got passed - require.NotNil(t, mkdirStage) - mkdirOptions := mkdirStage.Options.(*osbuild.MkdirStageOptions) + require.Greater(t, len(postInstallMkdirStages), 0) + mkdirOptions := postInstallMkdirStages[0].Options.(*osbuild.MkdirStageOptions) assert.Equal(t, tc.expectedMkdirPaths, mkdirOptions.Paths) } else { - require.Nil(t, mkdirStage) + assert.Equal(t, 0, len(postInstallMkdirStages)) } } } @@ -242,6 +245,29 @@ func assertBootcDeploymentAndBindMount(t *testing.T, stage *osbuild.Stage) { assert.True(t, bindMntIdx > deploymentMntIdx) } +// findPostInstallStages returns stages that come after the bootc install stage +// and have the ostree deployment + bind mount (i.e. post-install customization stages). +func findPostInstallStages(stageType string, stages []*osbuild.Stage) []*osbuild.Stage { + // Find the bootc install stage index + bootcIdx := -1 + for i, s := range stages { + if s.Type == "org.osbuild.bootc.install-to-filesystem" { + bootcIdx = i + break + } + } + if bootcIdx < 0 { + return nil + } + var found []*osbuild.Stage + for _, s := range stages[bootcIdx+1:] { + if s.Type == stageType { + found = append(found, s) + } + } + return found +} + func TestRawBootcImageSerializeCustomizationGenCorrectStages(t *testing.T) { rawBootcPipeline := makeFakeRawBootcPipeline() @@ -255,18 +281,21 @@ func TestRawBootcImageSerializeCustomizationGenCorrectStages(t *testing.T) { {nil, nil, "", nil}, {[]users.User{{Name: "foo"}}, nil, "", []string{"org.osbuild.mkdir", "org.osbuild.users"}}, {[]users.User{{Name: "foo"}}, nil, "targeted", []string{"org.osbuild.mkdir", "org.osbuild.users", "org.osbuild.selinux"}}, - {[]users.User{{Name: "foo"}}, []users.Group{{Name: "bar"}}, "targeted", []string{"org.osbuild.mkdir", "org.osbuild.users", "org.osbuild.users", "org.osbuild.selinux"}}, + {[]users.User{{Name: "foo"}}, []users.Group{{Name: "bar"}}, "targeted", []string{"org.osbuild.groups", "org.osbuild.mkdir", "org.osbuild.users", "org.osbuild.selinux"}}, } { rawBootcPipeline.OSCustomizations.Users = tc.users + rawBootcPipeline.OSCustomizations.Groups = tc.groups rawBootcPipeline.OSCustomizations.SELinux = tc.SELinux pipeline, err := rawBootcPipeline.Serialize() assert.NoError(t, err) for _, expectedStage := range tc.expectedStages { - stage := findStage(expectedStage, pipeline.Stages) - assert.NotNil(t, stage) - assertBootcDeploymentAndBindMount(t, stage) + stages := findPostInstallStages(expectedStage, pipeline.Stages) + assert.Greater(t, len(stages), 0, "expected post-install stage %q not found", expectedStage) + for _, stage := range stages { + assertBootcDeploymentAndBindMount(t, stage) + } } } } @@ -327,16 +356,15 @@ func TestRawBootcImageSerializeCreateFilesDirs(t *testing.T) { pipeline, err := rawBootcPipeline.Serialize() assert.NoError(t, err) - // check dirs - mkdirStage := findStage("org.osbuild.mkdir", pipeline.Stages) + // check dirs - look for post-install mkdir stages (with deployment mounts) + postInstallMkdirStages := findPostInstallStages("org.osbuild.mkdir", pipeline.Stages) if len(tc.dirs) > 0 { - // ensure options got passed - require.NotNil(t, mkdirStage) - mkdirOptions := mkdirStage.Options.(*osbuild.MkdirStageOptions) + require.Greater(t, len(postInstallMkdirStages), 0) + mkdirOptions := postInstallMkdirStages[0].Options.(*osbuild.MkdirStageOptions) assert.Equal(t, "/path/to/dir", mkdirOptions.Paths[0].Path) - assertBootcDeploymentAndBindMount(t, mkdirStage) + assertBootcDeploymentAndBindMount(t, postInstallMkdirStages[0]) } else { - assert.Nil(t, mkdirStage) + assert.Equal(t, 0, len(postInstallMkdirStages)) } // check files @@ -351,9 +379,15 @@ func TestRawBootcImageSerializeCreateFilesDirs(t *testing.T) { assert.Nil(t, copyStage) } - selinuxStage := findStage("org.osbuild.selinux", pipeline.Stages) + // Pre-install SELinux stages for mountpoint labeling + preInstallSELinuxStages := findPreInstallStages("org.osbuild.selinux", pipeline.Stages) + assert.Equal(t, 2, len(preInstallSELinuxStages), "expected 2 pre-install selinux stages (root + boot)") - assert.NotNil(t, selinuxStage) + // Post-install SELinux stages for relabeling customizations + if len(tc.dirs) > 0 || len(tc.files) > 0 { + postInstallSELinuxStages := findPostInstallStages("org.osbuild.selinux", pipeline.Stages) + assert.Greater(t, len(postInstallSELinuxStages), 0, "expected post-install selinux relabeling stages") + } // XXX: we should really check that the inline // source for files got generated but that is @@ -457,6 +491,143 @@ func TestRawBootcImageSerializeGrub2DStage(t *testing.T) { } } +// findPreInstallStages returns stages of the given type that appear before +// the bootc install stage. +func findPreInstallStages(stageType string, stages []*osbuild.Stage) []*osbuild.Stage { + var found []*osbuild.Stage + for _, s := range stages { + if s.Type == "org.osbuild.bootc.install-to-filesystem" { + break + } + if s.Type == stageType { + found = append(found, s) + } + } + return found +} + +func TestRawBootcImageSerializeMountpointSELinuxLabeling(t *testing.T) { + rawBootcPipeline := makeFakeRawBootcPipeline() + rawBootcPipeline.OSCustomizations.SELinux = "targeted" + + pipeline, err := rawBootcPipeline.Serialize() + require.NoError(t, err) + + // Pre-install mkdir stages: one for /boot (root-only), one for /boot/efi (root+boot) + preInstallMkdirStages := findPreInstallStages("org.osbuild.mkdir", pipeline.Stages) + require.Equal(t, 2, len(preInstallMkdirStages)) + + // First mkdir stage creates /boot with only root mounted + bootMkdirOpts := preInstallMkdirStages[0].Options.(*osbuild.MkdirStageOptions) + require.Equal(t, 1, len(bootMkdirOpts.Paths)) + assert.Equal(t, "mount://-/boot", bootMkdirOpts.Paths[0].Path) + assert.NotEmpty(t, preInstallMkdirStages[0].Devices) + bootMkdirMountTargets := make([]string, 0) + for _, mnt := range preInstallMkdirStages[0].Mounts { + bootMkdirMountTargets = append(bootMkdirMountTargets, mnt.Target) + } + assert.Contains(t, bootMkdirMountTargets, "/") + assert.NotContains(t, bootMkdirMountTargets, "/boot") + + // Second mkdir stage creates /boot/efi with root+boot mounted + efiMkdirOpts := preInstallMkdirStages[1].Options.(*osbuild.MkdirStageOptions) + require.Equal(t, 1, len(efiMkdirOpts.Paths)) + assert.Equal(t, "mount://boot/efi", efiMkdirOpts.Paths[0].Path) + assert.NotEmpty(t, preInstallMkdirStages[1].Devices) + efiMkdirMountTargets := make([]string, 0) + for _, mnt := range preInstallMkdirStages[1].Mounts { + efiMkdirMountTargets = append(efiMkdirMountTargets, mnt.Target) + } + assert.Contains(t, efiMkdirMountTargets, "/") + assert.Contains(t, efiMkdirMountTargets, "/boot") + + // Pre-install selinux stages: one for root, one for /boot + preInstallSELinuxStages := findPreInstallStages("org.osbuild.selinux", pipeline.Stages) + require.Equal(t, 2, len(preInstallSELinuxStages)) + + // First SELinux stage: labels root mount (without boot mounted) + rootSELinuxOpts := preInstallSELinuxStages[0].Options.(*osbuild.SELinuxStageOptions) + assert.Equal(t, "mount://-/", rootSELinuxOpts.Target) + assert.Contains(t, rootSELinuxOpts.FileContexts, "input://tree/etc/selinux/targeted/contexts/files/file_contexts") + // Should have inputs (tree input from source pipeline) + assert.NotNil(t, preInstallSELinuxStages[0].Inputs) + // Should only mount root + rootMountTargets := make([]string, 0) + for _, mnt := range preInstallSELinuxStages[0].Mounts { + rootMountTargets = append(rootMountTargets, mnt.Target) + } + assert.Contains(t, rootMountTargets, "/") + assert.NotContains(t, rootMountTargets, "/boot") + + // Second SELinux stage: labels /boot (with boot mounted) + bootSELinuxOpts := preInstallSELinuxStages[1].Options.(*osbuild.SELinuxStageOptions) + assert.Equal(t, "mount://-/boot/", bootSELinuxOpts.Target) + assert.Contains(t, bootSELinuxOpts.FileContexts, "input://tree/etc/selinux/targeted/contexts/files/file_contexts") + assert.NotNil(t, preInstallSELinuxStages[1].Inputs) + // Should mount both root and boot + bootMountTargets := make([]string, 0) + for _, mnt := range preInstallSELinuxStages[1].Mounts { + bootMountTargets = append(bootMountTargets, mnt.Target) + } + assert.Contains(t, bootMountTargets, "/") + assert.Contains(t, bootMountTargets, "/boot") +} + +func TestRawBootcImageSerializeMountpointSELinuxLabelingNoSELinux(t *testing.T) { + rawBootcPipeline := makeFakeRawBootcPipeline() + // No SELinux set - no pre-install labeling stages should be generated + rawBootcPipeline.OSCustomizations.SELinux = "" + + pipeline, err := rawBootcPipeline.Serialize() + require.NoError(t, err) + + preInstallSELinuxStages := findPreInstallStages("org.osbuild.selinux", pipeline.Stages) + assert.Equal(t, 0, len(preInstallSELinuxStages)) + preInstallMkdirStages := findPreInstallStages("org.osbuild.mkdir", pipeline.Stages) + assert.Equal(t, 0, len(preInstallMkdirStages)) +} + +func TestRawBootcImageSerializeMountpointSELinuxLabelingNoBoot(t *testing.T) { + // Test the edge case where there is no separate /boot partition + // (only root + EFI). Only the root SELinux stage should be generated, + // no /boot labeling stage. + mani := manifest.New() + r := &runner.Linux{} + pf := &platform.Data{ + Arch: arch.ARCH_X86_64, + UEFIVendor: "test", + } + build := manifest.NewBuildFromContainer(&mani, r, nil, nil) + rawBootcPipeline := manifest.NewRawBootcImage(build, nil, pf) + rawBootcPipeline.PartitionTable = testdisk.MakeFakePartitionTable("/", "/boot/efi") + err := rawBootcPipeline.SerializeStart(manifest.Inputs{Containers: []container.Spec{{Source: "foo"}}}) + require.NoError(t, err) + + rawBootcPipeline.OSCustomizations.SELinux = "targeted" + + pipeline, err := rawBootcPipeline.Serialize() + require.NoError(t, err) + + // Pre-install mkdir stage should only create /boot (no /boot/efi since + // there's no separate boot partition to mount it on) + preInstallMkdirStages := findPreInstallStages("org.osbuild.mkdir", pipeline.Stages) + require.Equal(t, 1, len(preInstallMkdirStages)) + mkdirOpts := preInstallMkdirStages[0].Options.(*osbuild.MkdirStageOptions) + var mkdirPaths []string + for _, p := range mkdirOpts.Paths { + mkdirPaths = append(mkdirPaths, p.Path) + } + assert.Contains(t, mkdirPaths, "mount://-/boot") + assert.NotContains(t, mkdirPaths, "mount://boot/efi") + + // Only one pre-install selinux stage (root only, no /boot stage) + preInstallSELinuxStages := findPreInstallStages("org.osbuild.selinux", pipeline.Stages) + require.Equal(t, 1, len(preInstallSELinuxStages)) + + rootSELinuxOpts := preInstallSELinuxStages[0].Options.(*osbuild.SELinuxStageOptions) + assert.Equal(t, "mount://-/", rootSELinuxOpts.Target) +} + func TestRawBootcPXE(t *testing.T) { rawBootcPipeline := makeFakeRawBootcPipeline() rawBootcPipeline.KernelVersion = "5.14.0-611.4.1.el9_7.x86_64"