Skip to content

Commit b4cc44b

Browse files
committed
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 #2494 Assisted-by: Opencode.ai <Opus 4.6>
1 parent 9c54d6d commit b4cc44b

2 files changed

Lines changed: 292 additions & 17 deletions

File tree

pkg/manifest/raw_bootc.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,22 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) {
143143
pipeline.AddStage(stage)
144144
}
145145

146+
// Label the root and /boot mount points before bootc install so that
147+
// all files in the ostree repository and /boot hierarchy are properly
148+
// labeled. Without this, the mount point directories end up as
149+
// unlabeled_t and every file written by bootc inherits that label.
150+
// See https://github.com/coreos/fedora-coreos-tracker/issues/1771
151+
// and https://github.com/coreos/fedora-coreos-tracker/issues/1772
152+
if p.OSCustomizations.SELinux != "" {
153+
selinuxStages, err := p.genMountpointSELinuxStages()
154+
if err != nil {
155+
return osbuild.Pipeline{}, err
156+
}
157+
for _, stage := range selinuxStages {
158+
pipeline.AddStage(stage)
159+
}
160+
}
161+
146162
if len(p.containerSpecs) != 1 {
147163
return osbuild.Pipeline{}, fmt.Errorf("expected a single container input got %v", p.containerSpecs)
148164
}
@@ -333,6 +349,108 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) {
333349
return pipeline, nil
334350
}
335351

352+
// genMountpointSELinuxStages creates stages that label the root and /boot
353+
// mount point directories with proper SELinux contexts before bootc install.
354+
//
355+
// Without this, the mount point directories on the freshly created
356+
// filesystems are unlabeled (unlabeled_t) and bootc install inherits
357+
// that label for all files it writes.
358+
//
359+
// The approach mirrors what COSA does:
360+
// 1. Create /boot (and /boot/efi on UEFI) directories on the root filesystem
361+
// 2. Label the root mount (without /boot mounted) so the /boot directory
362+
// mountpoint itself gets the correct label
363+
// 3. Label /boot (with /boot mounted) so /boot/efi gets labeled correctly
364+
//
365+
// The file_contexts used for labeling come from the source pipeline (the
366+
// extracted container tree) via an input reference.
367+
func (p *RawBootcImage) genMountpointSELinuxStages() ([]*osbuild.Stage, error) {
368+
stages := make([]*osbuild.Stage, 0, 3)
369+
370+
devices, allMounts, err := osbuild.GenBootupdDevicesMounts(p.filename, p.PartitionTable, p.platform)
371+
if err != nil {
372+
return nil, fmt.Errorf("generating devices/mounts for mountpoint SELinux labeling: %w", err)
373+
}
374+
375+
// Partition mounts by target for selective mounting.
376+
// Note: rootAndBootMounts intentionally excludes the EFI mount - we only
377+
// need to mount /boot to create and label the /boot/efi directory, the
378+
// EFI partition itself is not mounted during pre-install labeling.
379+
var rootMounts []osbuild.Mount
380+
var rootAndBootMounts []osbuild.Mount
381+
hasBootPartition := false
382+
hasEFIPartition := false
383+
for _, mnt := range allMounts {
384+
switch mnt.Target {
385+
case "/":
386+
rootMounts = append(rootMounts, mnt)
387+
rootAndBootMounts = append(rootAndBootMounts, mnt)
388+
case "/boot":
389+
hasBootPartition = true
390+
rootAndBootMounts = append(rootAndBootMounts, mnt)
391+
case "/boot/efi":
392+
hasEFIPartition = true
393+
}
394+
}
395+
396+
// 1. Create /boot directory on root filesystem (and /boot/efi on boot)
397+
mkdirPaths := []osbuild.MkdirStagePath{
398+
{
399+
Path: "mount://-/boot",
400+
Mode: common.ToPtr(os.FileMode(0755)),
401+
},
402+
}
403+
if hasEFIPartition && hasBootPartition {
404+
mkdirPaths = append(mkdirPaths, osbuild.MkdirStagePath{
405+
Path: "mount://boot/efi",
406+
Mode: common.ToPtr(os.FileMode(0755)),
407+
})
408+
}
409+
410+
mkdirMounts := rootMounts
411+
if hasEFIPartition && hasBootPartition {
412+
mkdirMounts = rootAndBootMounts
413+
}
414+
mkdirStage := osbuild.NewMkdirStage(&osbuild.MkdirStageOptions{
415+
Paths: mkdirPaths,
416+
})
417+
mkdirStage.Devices = devices
418+
mkdirStage.Mounts = mkdirMounts
419+
stages = append(stages, mkdirStage)
420+
421+
// Tree input for file_contexts from the source pipeline
422+
fileContextsInputName := "tree"
423+
fileContextsInput := osbuild.NewPipelineTreeInputs(fileContextsInputName, p.SourcePipeline)
424+
fileContextsPath := fmt.Sprintf("input://%s/etc/selinux/%s/contexts/files/file_contexts",
425+
fileContextsInputName, p.OSCustomizations.SELinux)
426+
427+
// 2. Label root mount point (without /boot mounted so that the
428+
// /boot directory mountpoint gets labeled)
429+
rootSELinuxStage := osbuild.NewSELinuxStage(&osbuild.SELinuxStageOptions{
430+
FileContexts: fileContextsPath,
431+
Target: "mount://-/",
432+
})
433+
rootSELinuxStage.Devices = devices
434+
rootSELinuxStage.Mounts = rootMounts
435+
rootSELinuxStage.Inputs = fileContextsInput
436+
stages = append(stages, rootSELinuxStage)
437+
438+
// 3. Label /boot mount point (with /boot mounted so that /boot/efi
439+
// gets labeled)
440+
if hasBootPartition {
441+
bootSELinuxStage := osbuild.NewSELinuxStage(&osbuild.SELinuxStageOptions{
442+
FileContexts: fileContextsPath,
443+
Target: "mount://-/boot/",
444+
})
445+
bootSELinuxStage.Devices = devices
446+
bootSELinuxStage.Mounts = rootAndBootMounts
447+
bootSELinuxStage.Inputs = fileContextsInput
448+
stages = append(stages, bootSELinuxStage)
449+
}
450+
451+
return stages, nil
452+
}
453+
336454
// XXX: duplicated from os.go
337455
func (p *RawBootcImage) getInline() []string {
338456
inlineData := []string{}

pkg/manifest/raw_bootc_test.go

Lines changed: 174 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -186,14 +186,17 @@ func TestRawBootcImageSerializeMkdirOptions(t *testing.T) {
186186
pipeline, err := rawBootcPipeline.Serialize()
187187
assert.NoError(t, err)
188188

189-
mkdirStage := findStage("org.osbuild.mkdir", pipeline.Stages)
189+
// Use findPostInstallStages to avoid matching pre-install mkdir
190+
// stages (e.g. mountpoint SELinux labeling) if SELinux is ever
191+
// set in this test.
192+
postInstallMkdirStages := findPostInstallStages("org.osbuild.mkdir", pipeline.Stages)
190193
if len(tc.expectedMkdirPaths) > 0 {
191194
// ensure options got passed
192-
require.NotNil(t, mkdirStage)
193-
mkdirOptions := mkdirStage.Options.(*osbuild.MkdirStageOptions)
195+
require.Greater(t, len(postInstallMkdirStages), 0)
196+
mkdirOptions := postInstallMkdirStages[0].Options.(*osbuild.MkdirStageOptions)
194197
assert.Equal(t, tc.expectedMkdirPaths, mkdirOptions.Paths)
195198
} else {
196-
require.Nil(t, mkdirStage)
199+
assert.Equal(t, 0, len(postInstallMkdirStages))
197200
}
198201
}
199202
}
@@ -240,6 +243,29 @@ func assertBootcDeploymentAndBindMount(t *testing.T, stage *osbuild.Stage) {
240243
assert.True(t, bindMntIdx > deploymentMntIdx)
241244
}
242245

246+
// findPostInstallStages returns stages that come after the bootc install stage
247+
// and have the ostree deployment + bind mount (i.e. post-install customization stages).
248+
func findPostInstallStages(stageType string, stages []*osbuild.Stage) []*osbuild.Stage {
249+
// Find the bootc install stage index
250+
bootcIdx := -1
251+
for i, s := range stages {
252+
if s.Type == "org.osbuild.bootc.install-to-filesystem" {
253+
bootcIdx = i
254+
break
255+
}
256+
}
257+
if bootcIdx < 0 {
258+
return nil
259+
}
260+
var found []*osbuild.Stage
261+
for _, s := range stages[bootcIdx+1:] {
262+
if s.Type == stageType {
263+
found = append(found, s)
264+
}
265+
}
266+
return found
267+
}
268+
243269
func TestRawBootcImageSerializeCustomizationGenCorrectStages(t *testing.T) {
244270
rawBootcPipeline := makeFakeRawBootcPipeline()
245271

@@ -253,18 +279,21 @@ func TestRawBootcImageSerializeCustomizationGenCorrectStages(t *testing.T) {
253279
{nil, nil, "", nil},
254280
{[]users.User{{Name: "foo"}}, nil, "", []string{"org.osbuild.mkdir", "org.osbuild.users"}},
255281
{[]users.User{{Name: "foo"}}, nil, "targeted", []string{"org.osbuild.mkdir", "org.osbuild.users", "org.osbuild.selinux"}},
256-
{[]users.User{{Name: "foo"}}, []users.Group{{Name: "bar"}}, "targeted", []string{"org.osbuild.mkdir", "org.osbuild.users", "org.osbuild.users", "org.osbuild.selinux"}},
282+
{[]users.User{{Name: "foo"}}, []users.Group{{Name: "bar"}}, "targeted", []string{"org.osbuild.groups", "org.osbuild.mkdir", "org.osbuild.users", "org.osbuild.selinux"}},
257283
} {
258284
rawBootcPipeline.OSCustomizations.Users = tc.users
285+
rawBootcPipeline.OSCustomizations.Groups = tc.groups
259286
rawBootcPipeline.OSCustomizations.SELinux = tc.SELinux
260287

261288
pipeline, err := rawBootcPipeline.Serialize()
262289
assert.NoError(t, err)
263290

264291
for _, expectedStage := range tc.expectedStages {
265-
stage := findStage(expectedStage, pipeline.Stages)
266-
assert.NotNil(t, stage)
267-
assertBootcDeploymentAndBindMount(t, stage)
292+
stages := findPostInstallStages(expectedStage, pipeline.Stages)
293+
assert.Greater(t, len(stages), 0, "expected post-install stage %q not found", expectedStage)
294+
for _, stage := range stages {
295+
assertBootcDeploymentAndBindMount(t, stage)
296+
}
268297
}
269298
}
270299
}
@@ -325,16 +354,15 @@ func TestRawBootcImageSerializeCreateFilesDirs(t *testing.T) {
325354
pipeline, err := rawBootcPipeline.Serialize()
326355
assert.NoError(t, err)
327356

328-
// check dirs
329-
mkdirStage := findStage("org.osbuild.mkdir", pipeline.Stages)
357+
// check dirs - look for post-install mkdir stages (with deployment mounts)
358+
postInstallMkdirStages := findPostInstallStages("org.osbuild.mkdir", pipeline.Stages)
330359
if len(tc.dirs) > 0 {
331-
// ensure options got passed
332-
require.NotNil(t, mkdirStage)
333-
mkdirOptions := mkdirStage.Options.(*osbuild.MkdirStageOptions)
360+
require.Greater(t, len(postInstallMkdirStages), 0)
361+
mkdirOptions := postInstallMkdirStages[0].Options.(*osbuild.MkdirStageOptions)
334362
assert.Equal(t, "/path/to/dir", mkdirOptions.Paths[0].Path)
335-
assertBootcDeploymentAndBindMount(t, mkdirStage)
363+
assertBootcDeploymentAndBindMount(t, postInstallMkdirStages[0])
336364
} else {
337-
assert.Nil(t, mkdirStage)
365+
assert.Equal(t, 0, len(postInstallMkdirStages))
338366
}
339367

340368
// check files
@@ -349,9 +377,15 @@ func TestRawBootcImageSerializeCreateFilesDirs(t *testing.T) {
349377
assert.Nil(t, copyStage)
350378
}
351379

352-
selinuxStage := findStage("org.osbuild.selinux", pipeline.Stages)
380+
// Pre-install SELinux stages for mountpoint labeling
381+
preInstallSELinuxStages := findPreInstallStages("org.osbuild.selinux", pipeline.Stages)
382+
assert.Equal(t, 2, len(preInstallSELinuxStages), "expected 2 pre-install selinux stages (root + boot)")
353383

354-
assert.NotNil(t, selinuxStage)
384+
// Post-install SELinux stages for relabeling customizations
385+
if len(tc.dirs) > 0 || len(tc.files) > 0 {
386+
postInstallSELinuxStages := findPostInstallStages("org.osbuild.selinux", pipeline.Stages)
387+
assert.Greater(t, len(postInstallSELinuxStages), 0, "expected post-install selinux relabeling stages")
388+
}
355389

356390
// XXX: we should really check that the inline
357391
// source for files got generated but that is
@@ -388,6 +422,129 @@ func TestRawBootcPipelineNoMountsStages(t *testing.T) {
388422
checkStagesForNoMounts(t, common.Must(pipeline.Serialize()).Stages)
389423
}
390424

425+
// findPreInstallStages returns stages of the given type that appear before
426+
// the bootc install stage.
427+
func findPreInstallStages(stageType string, stages []*osbuild.Stage) []*osbuild.Stage {
428+
var found []*osbuild.Stage
429+
for _, s := range stages {
430+
if s.Type == "org.osbuild.bootc.install-to-filesystem" {
431+
break
432+
}
433+
if s.Type == stageType {
434+
found = append(found, s)
435+
}
436+
}
437+
return found
438+
}
439+
440+
func TestRawBootcImageSerializeMountpointSELinuxLabeling(t *testing.T) {
441+
rawBootcPipeline := makeFakeRawBootcPipeline()
442+
rawBootcPipeline.OSCustomizations.SELinux = "targeted"
443+
444+
pipeline, err := rawBootcPipeline.Serialize()
445+
require.NoError(t, err)
446+
447+
// Pre-install mkdir stage should create /boot and /boot/efi directories
448+
preInstallMkdirStages := findPreInstallStages("org.osbuild.mkdir", pipeline.Stages)
449+
require.Equal(t, 1, len(preInstallMkdirStages))
450+
mkdirOpts := preInstallMkdirStages[0].Options.(*osbuild.MkdirStageOptions)
451+
var mkdirPaths []string
452+
for _, p := range mkdirOpts.Paths {
453+
mkdirPaths = append(mkdirPaths, p.Path)
454+
}
455+
assert.Contains(t, mkdirPaths, "mount://-/boot")
456+
assert.Contains(t, mkdirPaths, "mount://boot/efi")
457+
// mkdir stage should have devices and mounts (root + boot)
458+
assert.NotEmpty(t, preInstallMkdirStages[0].Devices)
459+
assert.NotEmpty(t, preInstallMkdirStages[0].Mounts)
460+
461+
// Pre-install selinux stages: one for root, one for /boot
462+
preInstallSELinuxStages := findPreInstallStages("org.osbuild.selinux", pipeline.Stages)
463+
require.Equal(t, 2, len(preInstallSELinuxStages))
464+
465+
// First SELinux stage: labels root mount (without boot mounted)
466+
rootSELinuxOpts := preInstallSELinuxStages[0].Options.(*osbuild.SELinuxStageOptions)
467+
assert.Equal(t, "mount://-/", rootSELinuxOpts.Target)
468+
assert.Contains(t, rootSELinuxOpts.FileContexts, "input://tree/etc/selinux/targeted/contexts/files/file_contexts")
469+
// Should have inputs (tree input from source pipeline)
470+
assert.NotNil(t, preInstallSELinuxStages[0].Inputs)
471+
// Should only mount root
472+
rootMountTargets := make([]string, 0)
473+
for _, mnt := range preInstallSELinuxStages[0].Mounts {
474+
rootMountTargets = append(rootMountTargets, mnt.Target)
475+
}
476+
assert.Contains(t, rootMountTargets, "/")
477+
assert.NotContains(t, rootMountTargets, "/boot")
478+
479+
// Second SELinux stage: labels /boot (with boot mounted)
480+
bootSELinuxOpts := preInstallSELinuxStages[1].Options.(*osbuild.SELinuxStageOptions)
481+
assert.Equal(t, "mount://-/boot/", bootSELinuxOpts.Target)
482+
assert.Contains(t, bootSELinuxOpts.FileContexts, "input://tree/etc/selinux/targeted/contexts/files/file_contexts")
483+
assert.NotNil(t, preInstallSELinuxStages[1].Inputs)
484+
// Should mount both root and boot
485+
bootMountTargets := make([]string, 0)
486+
for _, mnt := range preInstallSELinuxStages[1].Mounts {
487+
bootMountTargets = append(bootMountTargets, mnt.Target)
488+
}
489+
assert.Contains(t, bootMountTargets, "/")
490+
assert.Contains(t, bootMountTargets, "/boot")
491+
}
492+
493+
func TestRawBootcImageSerializeMountpointSELinuxLabelingNoSELinux(t *testing.T) {
494+
rawBootcPipeline := makeFakeRawBootcPipeline()
495+
// No SELinux set - no pre-install labeling stages should be generated
496+
rawBootcPipeline.OSCustomizations.SELinux = ""
497+
498+
pipeline, err := rawBootcPipeline.Serialize()
499+
require.NoError(t, err)
500+
501+
preInstallSELinuxStages := findPreInstallStages("org.osbuild.selinux", pipeline.Stages)
502+
assert.Equal(t, 0, len(preInstallSELinuxStages))
503+
preInstallMkdirStages := findPreInstallStages("org.osbuild.mkdir", pipeline.Stages)
504+
assert.Equal(t, 0, len(preInstallMkdirStages))
505+
}
506+
507+
func TestRawBootcImageSerializeMountpointSELinuxLabelingNoBoot(t *testing.T) {
508+
// Test the edge case where there is no separate /boot partition
509+
// (only root + EFI). Only the root SELinux stage should be generated,
510+
// no /boot labeling stage.
511+
mani := manifest.New()
512+
r := &runner.Linux{}
513+
pf := &platform.Data{
514+
Arch: arch.ARCH_X86_64,
515+
UEFIVendor: "test",
516+
}
517+
build := manifest.NewBuildFromContainer(&mani, r, nil, nil)
518+
rawBootcPipeline := manifest.NewRawBootcImage(build, nil, pf)
519+
rawBootcPipeline.PartitionTable = testdisk.MakeFakePartitionTable("/", "/boot/efi")
520+
err := rawBootcPipeline.SerializeStart(manifest.Inputs{Containers: []container.Spec{{Source: "foo"}}})
521+
require.NoError(t, err)
522+
523+
rawBootcPipeline.OSCustomizations.SELinux = "targeted"
524+
525+
pipeline, err := rawBootcPipeline.Serialize()
526+
require.NoError(t, err)
527+
528+
// Pre-install mkdir stage should only create /boot (no /boot/efi since
529+
// there's no separate boot partition to mount it on)
530+
preInstallMkdirStages := findPreInstallStages("org.osbuild.mkdir", pipeline.Stages)
531+
require.Equal(t, 1, len(preInstallMkdirStages))
532+
mkdirOpts := preInstallMkdirStages[0].Options.(*osbuild.MkdirStageOptions)
533+
var mkdirPaths []string
534+
for _, p := range mkdirOpts.Paths {
535+
mkdirPaths = append(mkdirPaths, p.Path)
536+
}
537+
assert.Contains(t, mkdirPaths, "mount://-/boot")
538+
assert.NotContains(t, mkdirPaths, "mount://boot/efi")
539+
540+
// Only one pre-install selinux stage (root only, no /boot stage)
541+
preInstallSELinuxStages := findPreInstallStages("org.osbuild.selinux", pipeline.Stages)
542+
require.Equal(t, 1, len(preInstallSELinuxStages))
543+
544+
rootSELinuxOpts := preInstallSELinuxStages[0].Options.(*osbuild.SELinuxStageOptions)
545+
assert.Equal(t, "mount://-/", rootSELinuxOpts.Target)
546+
}
547+
391548
func TestRawBootcPXE(t *testing.T) {
392549
rawBootcPipeline := makeFakeRawBootcPipeline()
393550
rawBootcPipeline.KernelVersion = "5.14.0-611.4.1.el9_7.x86_64"

0 commit comments

Comments
 (0)