diff --git a/pkg/manifest/export_test.go b/pkg/manifest/export_test.go index 40778d88bc..8923d6ab61 100644 --- a/pkg/manifest/export_test.go +++ b/pkg/manifest/export_test.go @@ -50,7 +50,7 @@ func NewTestOSWithPlatform(pf *platform.Data) *OS { } func (p *OSTreeDeployment) AddStagesForAllFilesAndInlineData(pipeline *osbuild.Pipeline, files []*fsnode.File) { - p.addStagesForAllFilesAndInlineData(pipeline, files, "ostree/ref") + p.addStagesForAllFilesAndInlineData(pipeline, files) } func (p *Vagrant) GetMacAddress() string { diff --git a/pkg/manifest/ostree_deployment.go b/pkg/manifest/ostree_deployment.go index 85a4569b9f..2e36353db5 100644 --- a/pkg/manifest/ostree_deployment.go +++ b/pkg/manifest/ostree_deployment.go @@ -337,7 +337,11 @@ func (p *OSTreeDeployment) serialize() (osbuild.Pipeline, error) { return osbuild.Pipeline{}, fmt.Errorf("no content source defined for ostree deployment") } - configStage := osbuild.NewOSTreeConfigStage( + // Set up mounts for the pipeline. These are used by all stages in + // this pipeline except those added via AddStageDirect. + pipeline.SetMounts(*osbuild.NewOSTreeDeploymentMount("ostree-"+ref, p.osName, ref, 0)) + + pipeline.AddStage(osbuild.NewOSTreeConfigStage( &osbuild.OSTreeConfigStageOptions{ Repo: repoPath, Config: &osbuild.OSTreeConfig{ @@ -347,23 +351,16 @@ func (p *OSTreeDeployment) serialize() (osbuild.Pipeline, error) { }, }, }, - ) - configStage.MountOSTree(p.osName, ref, 0) - pipeline.AddStage(configStage) + )) fsCfgStages, err := filesystemConfigStages(p.PartitionTable, p.MountConfiguration) if err != nil { return osbuild.Pipeline{}, err } - for _, stage := range fsCfgStages { - stage.MountOSTree(p.osName, ref, 0) - pipeline.AddStage(stage) - } + pipeline.AddStages(fsCfgStages...) if len(p.Groups) > 0 { - grpStage := osbuild.GenGroupsStage(p.Groups) - grpStage.MountOSTree(p.osName, ref, 0) - pipeline.AddStage(grpStage) + pipeline.AddStage(osbuild.GenGroupsStage(p.Groups)) } if len(p.Users) > 0 { @@ -371,12 +368,13 @@ func (p *OSTreeDeployment) serialize() (osbuild.Pipeline, error) { if err != nil { return osbuild.Pipeline{}, fmt.Errorf("password encryption failed") } - usersStage.MountOSTree(p.osName, ref, 0) pipeline.AddStage(usersStage) } if p.IgnitionPlatform != "" { - pipeline.AddStage(osbuild.NewIgnitionStage(&osbuild.IgnitionStageOptions{ + // The ignition stage writes outside the deployment, so it + // bypasses the pipeline mounts. + pipeline.AddStageDirect(osbuild.NewIgnitionStage(&osbuild.IgnitionStageOptions{ // This is a workaround to make the systemd believe it's firstboot when ignition runs on real firstboot. // Right now, since we ship /etc/machine-id, systemd thinks it's not firstboot and ignition depends on it // to run on the real firstboot to enable services from presets. @@ -399,9 +397,7 @@ func (p *OSTreeDeployment) serialize() (osbuild.Pipeline, error) { if err != nil { return osbuild.Pipeline{}, err } - stageOption := osbuild.NewSystemdUnitCreateStage(mps) - stageOption.MountOSTree(p.osName, ref, 0) - pipeline.AddStage(stageOption) + pipeline.AddStage(osbuild.NewSystemdUnitCreateStage(mps)) p.EnabledServices = append(p.EnabledServices, serviceName) } @@ -412,9 +408,7 @@ func (p *OSTreeDeployment) serialize() (osbuild.Pipeline, error) { // in the code based on distro version, we enable / disable services also by // creating a preset file. if len(p.EnabledServices) != 0 || len(p.DisabledServices) != 0 { - presetsStage := osbuild.GenServicesPresetStage(p.EnabledServices, p.DisabledServices) - presetsStage.MountOSTree(p.osName, ref, 0) - pipeline.AddStage(presetsStage) + pipeline.AddStage(osbuild.GenServicesPresetStage(p.EnabledServices, p.DisabledServices)) } } @@ -428,35 +422,24 @@ func (p *OSTreeDeployment) serialize() (osbuild.Pipeline, error) { } if p.LockRootUser && !hasRoot { - rootLockStage := osbuild.NewUsersStage(osbuild.NewUsersStageOptionsForLockedRoot()) - rootLockStage.MountOSTree(p.osName, ref, 0) - pipeline.AddStage(rootLockStage) + pipeline.AddStage(osbuild.NewUsersStage(osbuild.NewUsersStageOptionsForLockedRoot())) } if p.Keyboard != "" { - options := &osbuild.KeymapStageOptions{ + pipeline.AddStage(osbuild.NewKeymapStage(&osbuild.KeymapStageOptions{ Keymap: p.Keyboard, - } - keymapStage := osbuild.NewKeymapStage(options) - keymapStage.MountOSTree(p.osName, ref, 0) - pipeline.AddStage(keymapStage) + })) } if p.Locale != "" { - options := &osbuild.LocaleStageOptions{ + pipeline.AddStage(osbuild.NewLocaleStage(&osbuild.LocaleStageOptions{ Language: p.Locale, - } - localeStage := osbuild.NewLocaleStage(options) - localeStage.MountOSTree(p.osName, ref, 0) - pipeline.AddStage(localeStage) + })) } if p.FIPS { - p.addStagesForAllFilesAndInlineData(&pipeline, osbuild.GenFIPSFiles(), ref) - for _, stage := range osbuild.GenFIPSStages() { - stage.MountOSTree(p.osName, ref, 0) - pipeline.AddStage(stage) - } + p.addStagesForAllFilesAndInlineData(&pipeline, osbuild.GenFIPSFiles()) + pipeline.AddStages(osbuild.GenFIPSStages()...) } if !p.UseBootupd { @@ -473,34 +456,28 @@ func (p *OSTreeDeployment) serialize() (osbuild.Pipeline, error) { Timeout: 1, TerminalOutput: []string{"console"}, } - bootloader := osbuild.NewGRUB2Stage(grubOptions) - bootloader.MountOSTree(p.osName, ref, 0) - pipeline.AddStage(bootloader) + pipeline.AddStage(osbuild.NewGRUB2Stage(grubOptions)) } // First create custom directories, because some of the files may depend on them if len(p.Directories) > 0 { - dirStages := osbuild.GenDirectoryNodesStages(p.Directories) - for _, stage := range dirStages { - stage.MountOSTree(p.osName, ref, 0) - } - pipeline.AddStages(dirStages...) + pipeline.AddStages(osbuild.GenDirectoryNodesStages(p.Directories)...) } if len(p.Files) > 0 { - p.addStagesForAllFilesAndInlineData(&pipeline, p.Files, ref) + p.addStagesForAllFilesAndInlineData(&pipeline, p.Files) } if len(p.EnabledServices) != 0 || len(p.DisabledServices) != 0 { - systemdStage := osbuild.NewSystemdStage(&osbuild.SystemdStageOptions{ + pipeline.AddStage(osbuild.NewSystemdStage(&osbuild.SystemdStageOptions{ EnabledServices: p.EnabledServices, DisabledServices: p.DisabledServices, - }) - systemdStage.MountOSTree(p.osName, ref, 0) - pipeline.AddStage(systemdStage) + })) } - pipeline.AddStage(osbuild.NewOSTreeSelinuxStage( + // The selinux stage references the deployment through its options, + // not through mounts, so it bypasses the pipeline mounts. + pipeline.AddStageDirect(osbuild.NewOSTreeSelinuxStage( &osbuild.OSTreeSelinuxStageOptions{ Deployment: osbuild.OSTreeDeployment{ OSName: p.osName, @@ -519,11 +496,8 @@ func (p *OSTreeDeployment) getInline() []string { // addStagesForAllFilesAndInlineData generates stages for creating files and adds them to // the pipeline. It also adds their data to the inlineData for the pipeline so // that the appropriate sources are created. -func (p *OSTreeDeployment) addStagesForAllFilesAndInlineData(pipeline *osbuild.Pipeline, files []*fsnode.File, ref string) { - for _, stage := range osbuild.GenFileNodesStages(files) { - stage.MountOSTree(p.osName, ref, 0) - pipeline.AddStage(stage) - } +func (p *OSTreeDeployment) addStagesForAllFilesAndInlineData(pipeline *osbuild.Pipeline, files []*fsnode.File) { + pipeline.AddStages(osbuild.GenFileNodesStages(files)...) for _, file := range files { p.inlineData = append(p.inlineData, string(file.Data())) diff --git a/pkg/manifest/raw_bootc.go b/pkg/manifest/raw_bootc.go index 91c896bb46..ff4a722abb 100644 --- a/pkg/manifest/raw_bootc.go +++ b/pkg/manifest/raw_bootc.go @@ -189,46 +189,39 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { mounts = append(mounts, *osbuild.NewOSTreeDeploymentMountDefault("ostree.deployment", osbuild.OSTreeMountSourceMount)) mounts = append(mounts, *osbuild.NewBindMount("bind-ostree-deployment-to-tree", "mount://", "tree://")) + pipeline.SetMounts(mounts...) + pipeline.SetDevices(devices) + 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) - } + postStages = append(postStages, fsCfgStages...) // customize the image if len(p.OSCustomizations.Groups) > 0 { - groupsStage := osbuild.GenGroupsStage(p.OSCustomizations.Groups) - groupsStage.Mounts = mounts - groupsStage.Devices = devices - postStages = append(postStages, groupsStage) + postStages = append(postStages, osbuild.GenGroupsStage(p.OSCustomizations.Groups)) } if len(p.OSCustomizations.Users) > 0 { // ensure home root dir (currently /var/home, /var/roothome) is // available - mkdirStage := osbuild.NewMkdirStage(&osbuild.MkdirStageOptions{ + postStages = append(postStages, osbuild.NewMkdirStage(&osbuild.MkdirStageOptions{ Paths: buildHomedirPaths(p.OSCustomizations.Users), - }) - mkdirStage.Mounts = mounts - mkdirStage.Devices = devices - postStages = append(postStages, mkdirStage) + })) // add the users usersStage, err := osbuild.GenUsersStage(p.OSCustomizations.Users, false) if err != nil { return osbuild.Pipeline{}, fmt.Errorf("user stage failed %w", err) } - usersStage.Mounts = mounts - usersStage.Devices = devices postStages = append(postStages, usersStage) } + pipeline.AddStages(postStages...) + if p.LiveBoot { // The dracut dmsquash-live module has a check for the root filesystem // This is a kludge to work around this: On Fedora and RHEL10 it expects /usr in the root @@ -254,31 +247,22 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { } noDeploymentMounts = append(noDeploymentMounts, m) } + // These stages use custom mounts (without the deployment + // mount) so they bypass the pipeline mounts. for _, stage := range stages { stage.Mounts = noDeploymentMounts stage.Devices = devices + pipeline.AddStageDirect(stage) } - postStages = append(postStages, stages...) } // First create custom directories, because some of the custom files may depend on them if len(p.OSCustomizations.Directories) > 0 { - - stages := osbuild.GenDirectoryNodesStages(p.OSCustomizations.Directories) - for _, stage := range stages { - stage.Mounts = mounts - stage.Devices = devices - } - postStages = append(postStages, stages...) + pipeline.AddStages(osbuild.GenDirectoryNodesStages(p.OSCustomizations.Directories)...) } if len(p.OSCustomizations.Files) > 0 { - stages := osbuild.GenFileNodesStages(p.OSCustomizations.Files) - for _, stage := range stages { - stage.Mounts = mounts - stage.Devices = devices - } - postStages = append(postStages, stages...) + pipeline.AddStages(osbuild.GenFileNodesStages(p.OSCustomizations.Files)...) } // The ignition stamp must be created after bootc install, otherwise bootc will error out @@ -299,32 +283,25 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { } ignitionStage = osbuild.NewIgnitionStage(&opts) } - var err error - // We cannot reuse the existing mounts because the generated ostree mounts are shadowing /boot and the file ends up - // in the wrong place. We reuse the bootupd mount generator as it's enough for this. We just need /boot. + // The ignition stage uses its own mounts and devices + // because the pipeline ostree mounts shadow /boot. ignitionStage.Devices, ignitionStage.Mounts, err = osbuild.GenBootupdDevicesMounts(p.filename, p.PartitionTable, p.platform) if err != nil { return osbuild.Pipeline{}, fmt.Errorf("gen devices stage failed %w", err) } - postStages = append(postStages, ignitionStage) + pipeline.AddStageDirect(ignitionStage) } - pipeline.AddStages(postStages...) - // In case we created any files in the deploy directory we need to relabel // then per the selinux policy if p.OSCustomizations.SELinux != "" { - if len(postStages) > 0 { + if len(postStages) > 0 || len(p.OSCustomizations.Directories) > 0 || len(p.OSCustomizations.Files) > 0 { for _, changedFile := range []string{"/etc", "/var"} { - opts := &osbuild.SELinuxStageOptions{ + pipeline.AddStage(osbuild.NewSELinuxStage(&osbuild.SELinuxStageOptions{ Target: "tree://" + changedFile, FileContexts: fmt.Sprintf("etc/selinux/%s/contexts/files/file_contexts", p.OSCustomizations.SELinux), ExcludePaths: []string{"/sysroot"}, - } - selinuxStage := osbuild.NewSELinuxStage(opts) - selinuxStage.Mounts = mounts - selinuxStage.Devices = devices - pipeline.AddStage(selinuxStage) + })) } } } diff --git a/pkg/osbuild/osbuild.go b/pkg/osbuild/osbuild.go index ac7aa7bb21..d3b5e66590 100644 --- a/pkg/osbuild/osbuild.go +++ b/pkg/osbuild/osbuild.go @@ -31,6 +31,9 @@ type Pipeline struct { // Sequence of stages that produce the filesystem tree, which is the // payload of the produced image. Stages []*Stage `json:"stages,omitempty"` + + mounts []Mount + devices map[string]Device } // SetBuild sets the pipeline and runner for generating the build environment @@ -39,10 +42,43 @@ func (p *Pipeline) SetBuild(build string) { p.Build = build } +// SetMounts sets pipeline-level mounts that are appended to every stage +// added via AddStage or AddStages. +func (p *Pipeline) SetMounts(mounts ...Mount) { + p.mounts = append(p.mounts, mounts...) +} + +// SetDevices sets pipeline-level devices that are merged into every stage +// added via AddStage or AddStages. +func (p *Pipeline) SetDevices(devices map[string]Device) { + p.devices = devices +} + // AddStage appends a stage to the list of stages of a pipeline. The stages // will be executed in the order they are appended. // If the argument is nil, it is not added. func (p *Pipeline) AddStage(stage *Stage) { + if stage != nil { + stage.Mounts = append(stage.Mounts, p.mounts...) + if len(p.devices) > 0 { + if stage.Devices == nil { + stage.Devices = make(map[string]Device, len(p.devices)) + } + for k, v := range p.devices { + if _, exists := stage.Devices[k]; exists { + panic(fmt.Sprintf("stage %q already defines device %q which conflicts with pipeline device", stage.Type, k)) + } + stage.Devices[k] = v + } + } + p.Stages = append(p.Stages, stage) + } +} + +// AddStageDirect appends a stage without merging pipeline-level mounts or +// devices. Use this for stages that intentionally bypass the pipeline +// mounts (e.g. stages that write outside the deployment). +func (p *Pipeline) AddStageDirect(stage *Stage) { if stage != nil { p.Stages = append(p.Stages, stage) } diff --git a/pkg/osbuild/osbuild_test.go b/pkg/osbuild/osbuild_test.go index 32a2ee1fb0..bdb936d6ba 100644 --- a/pkg/osbuild/osbuild_test.go +++ b/pkg/osbuild/osbuild_test.go @@ -27,6 +27,145 @@ func TestPipeline_AddStage(t *testing.T) { assert.Equal(t, 1, len(actualPipeline.Stages)) } +func TestPipeline_SetMounts(t *testing.T) { + pipeline := &Pipeline{} + pipeline.SetMounts( + *NewExt4Mount("root", "root-dev", "/"), + *NewXfsMount("data", "data-dev", "/data"), + ) + + pipeline.AddStage(&Stage{Type: "org.osbuild.rpm"}) + pipeline.AddStage(&Stage{Type: "org.osbuild.locale"}) + + assert.Len(t, pipeline.Stages, 2) + for _, stage := range pipeline.Stages { + assert.Len(t, stage.Mounts, 2) + assert.Equal(t, "root", stage.Mounts[0].Name) + assert.Equal(t, "data", stage.Mounts[1].Name) + } +} + +func TestPipeline_SetMountsAppendsToStageMounts(t *testing.T) { + pipeline := &Pipeline{} + pipeline.SetMounts(*NewExt4Mount("root", "root-dev", "/")) + + stage := &Stage{ + Type: "org.osbuild.rpm", + Mounts: []Mount{*NewXfsMount("existing", "existing-dev", "/existing")}, + } + pipeline.AddStage(stage) + + assert.Len(t, pipeline.Stages[0].Mounts, 2) + assert.Equal(t, "existing", pipeline.Stages[0].Mounts[0].Name) + assert.Equal(t, "root", pipeline.Stages[0].Mounts[1].Name) +} + +func TestPipeline_AddStageNilSkipsMounts(t *testing.T) { + pipeline := &Pipeline{} + pipeline.SetMounts(*NewExt4Mount("root", "root-dev", "/")) + pipeline.AddStage(nil) + + assert.Len(t, pipeline.Stages, 0) +} + +func TestPipeline_AddStagesWithMounts(t *testing.T) { + pipeline := &Pipeline{} + pipeline.SetMounts(*NewFATMount("efi", "efi-dev", "/boot/efi")) + + stages := []*Stage{ + {Type: "org.osbuild.rpm"}, + {Type: "org.osbuild.locale"}, + {Type: "org.osbuild.hostname"}, + } + pipeline.AddStages(stages...) + + assert.Len(t, pipeline.Stages, 3) + for _, stage := range pipeline.Stages { + assert.Len(t, stage.Mounts, 1) + assert.Equal(t, "efi", stage.Mounts[0].Name) + } +} + +func TestPipeline_AddStageDirect(t *testing.T) { + pipeline := &Pipeline{} + pipeline.SetMounts(*NewExt4Mount("root", "root-dev", "/")) + + pipeline.AddStageDirect(&Stage{Type: "org.osbuild.ignition"}) + + assert.Len(t, pipeline.Stages, 1) + assert.Len(t, pipeline.Stages[0].Mounts, 0) +} + +func TestPipeline_AddStageDirectNil(t *testing.T) { + pipeline := &Pipeline{} + pipeline.AddStageDirect(nil) + + assert.Len(t, pipeline.Stages, 0) +} + +func TestPipeline_NoMountsDoesNotAffectStages(t *testing.T) { + pipeline := &Pipeline{} + pipeline.AddStage(&Stage{Type: "org.osbuild.rpm"}) + + assert.Len(t, pipeline.Stages[0].Mounts, 0) +} + +func TestPipeline_SetDevices(t *testing.T) { + pipeline := &Pipeline{} + pipeline.SetDevices(map[string]Device{ + "root": {Type: "org.osbuild.loopback"}, + "boot": {Type: "org.osbuild.loopback"}, + }) + + pipeline.AddStage(&Stage{Type: "org.osbuild.rpm"}) + pipeline.AddStage(&Stage{Type: "org.osbuild.locale"}) + + assert.Len(t, pipeline.Stages, 2) + for _, stage := range pipeline.Stages { + assert.Len(t, stage.Devices, 2) + assert.Equal(t, "org.osbuild.loopback", stage.Devices["root"].Type) + assert.Equal(t, "org.osbuild.loopback", stage.Devices["boot"].Type) + } +} + +func TestPipeline_SetDevicesPanicsOnConflict(t *testing.T) { + pipeline := &Pipeline{} + pipeline.SetDevices(map[string]Device{ + "root": {Type: "org.osbuild.loopback"}, + }) + + stage := &Stage{ + Type: "org.osbuild.rpm", + Devices: map[string]Device{ + "root": {Type: "org.osbuild.luks2"}, + }, + } + assert.PanicsWithValue(t, + `stage "org.osbuild.rpm" already defines device "root" which conflicts with pipeline device`, + func() { pipeline.AddStage(stage) }, + ) +} + +func TestPipeline_NoDevicesDoesNotAffectStages(t *testing.T) { + pipeline := &Pipeline{} + pipeline.AddStage(&Stage{Type: "org.osbuild.rpm"}) + + assert.Nil(t, pipeline.Stages[0].Devices) +} + +func TestPipeline_MountsAndDevicesTogether(t *testing.T) { + pipeline := &Pipeline{} + pipeline.SetMounts(*NewExt4Mount("root", "root-dev", "/")) + pipeline.SetDevices(map[string]Device{ + "root-dev": {Type: "org.osbuild.loopback"}, + }) + + pipeline.AddStage(&Stage{Type: "org.osbuild.rpm"}) + + assert.Len(t, pipeline.Stages[0].Mounts, 1) + assert.Len(t, pipeline.Stages[0].Devices, 1) +} + var fakeOsbuildManifestWithIdentifiers = []byte(`{ "version": "2", "pipelines": [