diff --git a/pkg/distro/generic/bootc_image_test.go b/pkg/distro/generic/bootc_image_test.go index 4e87f0d4f8..b36ec38421 100644 --- a/pkg/distro/generic/bootc_image_test.go +++ b/pkg/distro/generic/bootc_image_test.go @@ -6,6 +6,7 @@ import ( "github.com/osbuild/blueprint/pkg/blueprint" "github.com/osbuild/image-builder/internal/common" + "github.com/osbuild/image-builder/pkg/customizations/subscription" "github.com/osbuild/image-builder/pkg/datasizes" "github.com/osbuild/image-builder/pkg/disk" "github.com/osbuild/image-builder/pkg/disk/partition" @@ -670,3 +671,24 @@ func TestGenPartitionTableFromOSInfo(t *testing.T) { assert.NoError(t, err) assert.Contains(t, string(manifestJson), "01010101-01011-01011-01011-01010101") } + +// Each bootc manifestFor* variant hand-copies customizations, so pin that the +// disk variant does not forget to pass Subscription through. +func TestManifestSubscriptionCustomization(t *testing.T) { + imgType := NewTestBootcImageType(t, "qcow2") + imgOpts := distro.ImageOptions{ + Subscription: &subscription.ImageOptions{ + Organization: "2040324", + ActivationKey: "my-secret-key", + }, + } + + mf, _, err := imgType.Manifest(&blueprint.Blueprint{}, imgOpts, nil, common.ToPtr(int64(0))) + assert.NoError(t, err) + manifestJson, err := mf.Serialize(nil, diskContainers, nil, nil, nil) + assert.NoError(t, err) + + // assert on names, not stage types: mount units share them + assert.Contains(t, string(manifestJson), "osbuild-subscription-register.service") + assert.Contains(t, string(manifestJson), "/etc/osbuild-subscription-register.env") +} diff --git a/pkg/distro/generic/bootc_imagetype.go b/pkg/distro/generic/bootc_imagetype.go index 29d5fed489..e1b8965230 100644 --- a/pkg/distro/generic/bootc_imagetype.go +++ b/pkg/distro/generic/bootc_imagetype.go @@ -226,6 +226,7 @@ func (t *bootcImageType) manifestForDisk(bp *blueprint.Blueprint, options distro img.Bootloader = bd.bootloader img.UnifiedKernel = bd.unifiedKernel + img.OSCustomizations.Subscription = options.Subscription img.OSCustomizations.Users = users.UsersFromBP(customizations.GetUsers()) groups, err := customizations.GetGroups() diff --git a/pkg/manifest/raw_bootc.go b/pkg/manifest/raw_bootc.go index 91c896bb46..a3d1cb5896 100644 --- a/pkg/manifest/raw_bootc.go +++ b/pkg/manifest/raw_bootc.go @@ -49,6 +49,8 @@ type RawBootcImage struct { // DiskCustomizations can influence things in the base OS tree DiskCustomizations DiskCustomizations + + inlineData []string } func (p RawBootcImage) Filename() string { @@ -191,6 +193,8 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { postStages := []*osbuild.Stage{} + var subscriptionEnabledServices []string + fsCfgStages, err := filesystemConfigStages(pt, p.DiskCustomizations.MountConfiguration) if err != nil { return osbuild.Pipeline{}, err @@ -261,6 +265,37 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { postStages = append(postStages, stages...) } + if p.OSCustomizations.Subscription != nil { + subStage, subDirs, subFiles, subServices, err := subscriptionService( + *p.OSCustomizations.Subscription, + &subscriptionServiceOptions{ + // ostree based: unit in /etc (not /usr commit content), + // insights on boot + InsightsOnBoot: true, + UnitPath: osbuild.EtcUnitPath, + // no-op for now: manifestForDisk never sets OSCustomizations.PermissiveRHC + PermissiveRHC: common.ValueOrEmpty(p.OSCustomizations.PermissiveRHC), + }) + if err != nil { + return osbuild.Pipeline{}, err + } + + subFileStages, err := p.genFileStagesAndRecordInlineData(subFiles) + if err != nil { + return osbuild.Pipeline{}, err + } + stages := []*osbuild.Stage{subStage} + stages = append(stages, osbuild.GenDirectoryNodesStages(subDirs)...) + stages = append(stages, subFileStages...) + for _, stage := range stages { + stage.Mounts = mounts + stage.Devices = devices + } + postStages = append(postStages, stages...) + + subscriptionEnabledServices = subServices + } + // First create custom directories, because some of the custom files may depend on them if len(p.OSCustomizations.Directories) > 0 { @@ -273,7 +308,10 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { } if len(p.OSCustomizations.Files) > 0 { - stages := osbuild.GenFileNodesStages(p.OSCustomizations.Files) + stages, err := p.genFileStagesAndRecordInlineData(p.OSCustomizations.Files) + if err != nil { + return osbuild.Pipeline{}, err + } for _, stage := range stages { stage.Mounts = mounts stage.Devices = devices @@ -281,6 +319,20 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { postStages = append(postStages, stages...) } + // single point where enabled services are collected, as in the OS + // pipeline; OSCustomizations.EnabledServices is not honoured yet + var enabledServices []string + enabledServices = append(enabledServices, subscriptionEnabledServices...) + + if len(enabledServices) > 0 { + systemdStage := osbuild.NewSystemdStage(&osbuild.SystemdStageOptions{ + EnabledServices: enabledServices, + }) + systemdStage.Mounts = mounts + systemdStage.Devices = devices + postStages = append(postStages, systemdStage) + } + // 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/`. @@ -333,16 +385,24 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) { return pipeline, nil } -// XXX: duplicated from os.go func (p *RawBootcImage) getInline() []string { - inlineData := []string{} + return p.inlineData +} - // inline data for custom files - for _, file := range p.OSCustomizations.Files { - inlineData = append(inlineData, string(file.Data())) +// genFileStagesAndRecordInlineData returns the stages that create the given +// files and records their content as inline data for getInline(). +func (p *RawBootcImage) genFileStagesAndRecordInlineData(files []*fsnode.File) ([]*osbuild.Stage, error) { + for _, file := range files { + // files that come via an URI are not inline data, they + // would need to be added to the manifest sources via a + // fileRefs() implementation like the one in the OS pipeline + if file.URI() != "" { + return nil, fmt.Errorf("cannot create file %q from %q: files from an URI are not supported for bootc disk images yet", file.Path(), file.URI()) + } + p.inlineData = append(p.inlineData, string(file.Data())) } - return inlineData + return osbuild.GenFileNodesStages(files), nil } // XXX: copied from raw.go diff --git a/pkg/manifest/raw_bootc_test.go b/pkg/manifest/raw_bootc_test.go index 0964f3fab0..c08858bcd7 100644 --- a/pkg/manifest/raw_bootc_test.go +++ b/pkg/manifest/raw_bootc_test.go @@ -3,6 +3,7 @@ package manifest_test import ( "fmt" "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -13,6 +14,7 @@ import ( "github.com/osbuild/image-builder/pkg/arch" "github.com/osbuild/image-builder/pkg/container" "github.com/osbuild/image-builder/pkg/customizations/fsnode" + "github.com/osbuild/image-builder/pkg/customizations/subscription" "github.com/osbuild/image-builder/pkg/customizations/users" "github.com/osbuild/image-builder/pkg/manifest" "github.com/osbuild/image-builder/pkg/osbuild" @@ -409,3 +411,163 @@ func TestRawBootcPXE(t *testing.T) { assert.Contains(t, mkdirPaths, "/usr") assert.Contains(t, mkdirPaths, "/proc") } + +func TestRawBootcImageSerializeSubscriptionManagerCommands(t *testing.T) { + rawBootcPipeline := makeFakeRawBootcPipeline() + rawBootcPipeline.OSCustomizations.Subscription = &subscription.ImageOptions{ + Organization: "2040324", + ActivationKey: "my-secret-key", + ServerUrl: "subscription.rhsm.redhat.com", + BaseUrl: "http://cdn.redhat.com/", + } + + pipeline, err := rawBootcPipeline.Serialize() + require.NoError(t, err) + CheckSystemdStageOptions(t, pipeline.Stages, []string{ + "/usr/sbin/subscription-manager config --server.hostname 'subscription.rhsm.redhat.com'", + `/usr/sbin/subscription-manager register --org="${ORG_ID}" --activationkey="${ACTIVATION_KEY}" --baseurl 'http://cdn.redhat.com/'`, + }) + + // registration unit in /etc, not /usr (ostree commit content) + assert.Equal(t, osbuild.EtcUnitPath, registrationUnitPath(t, pipeline.Stages)) +} + +func TestRawBootcImageSerializeSubscriptionManagerInsightsCommands(t *testing.T) { + rawBootcPipeline := makeFakeRawBootcPipeline() + rawBootcPipeline.OSCustomizations.Subscription = &subscription.ImageOptions{ + Organization: "2040324", + ActivationKey: "my-secret-key", + ServerUrl: "subscription.rhsm.redhat.com", + BaseUrl: "http://cdn.redhat.com/", + Insights: true, + } + + pipeline, err := rawBootcPipeline.Serialize() + require.NoError(t, err) + CheckSystemdStageOptions(t, pipeline.Stages, []string{ + "/usr/sbin/subscription-manager config --server.hostname 'subscription.rhsm.redhat.com'", + `/usr/sbin/subscription-manager register --org="${ORG_ID}" --activationkey="${ACTIVATION_KEY}" --baseurl 'http://cdn.redhat.com/'`, + "/usr/bin/insights-client --register", + }) + + // InsightsOnBoot also materializes the insights-client drop-in + mkdirPaths := collectMkdirPaths(pipeline.Stages) + assert.Contains(t, mkdirPaths, "/etc/systemd/system/insights-client-boot.service.d") + destinationPaths := collectCopyDestinationPaths(pipeline.Stages) + assert.Contains(t, destinationPaths, "tree:///etc/systemd/system/insights-client-boot.service.d/override.conf") +} + +func TestRawBootcImageSerializeRhcInsightsCommands(t *testing.T) { + rawBootcPipeline := makeFakeRawBootcPipeline() + rawBootcPipeline.OSCustomizations.Subscription = &subscription.ImageOptions{ + Organization: "2040324", + ActivationKey: "my-secret-key", + ServerUrl: "subscription.rhsm.redhat.com", + BaseUrl: "http://cdn.redhat.com/", + Insights: false, + Rhc: true, + } + rawBootcPipeline.OSCustomizations.PermissiveRHC = common.ToPtr(true) + + pipeline, err := rawBootcPipeline.Serialize() + require.NoError(t, err) + CheckSystemdStageOptions(t, pipeline.Stages, []string{ + "/usr/sbin/subscription-manager config --server.hostname 'subscription.rhsm.redhat.com'", + `/usr/bin/rhc connect --organization="${ORG_ID}" --activation-key="${ACTIVATION_KEY}"`, + "/usr/sbin/semanage permissive --add rhcd_t", + }) +} + +func TestRawBootcImageSerializeSubscriptionEnablesService(t *testing.T) { + rawBootcPipeline := makeFakeRawBootcPipeline() + rawBootcPipeline.OSCustomizations.Subscription = &subscription.ImageOptions{ + Organization: "2040324", + ActivationKey: "my-secret-key", + } + + pipeline, err := rawBootcPipeline.Serialize() + require.NoError(t, err) + + stage := findStage("org.osbuild.systemd", pipeline.Stages) + require.NotNil(t, stage) + opts := stage.Options.(*osbuild.SystemdStageOptions) + assert.Equal(t, []string{"osbuild-subscription-register.service"}, opts.EnabledServices) +} + +// Mirrors TestAddInlineOS: the env file must be both a copy destination and an +// inline source, and is written before the blueprint's own files. +func TestRawBootcImageSerializeSubscriptionEnvFile(t *testing.T) { + rawBootcPipeline := makeFakeRawBootcPipeline() + + require := require.New(t) + + rawBootcPipeline.OSCustomizations.Files = createTestFilesForPipeline() + rawBootcPipeline.OSCustomizations.Subscription = &subscription.ImageOptions{ + Organization: "000", + ActivationKey: "111", + } + + expectedPaths := []string{ + "tree:///etc/osbuild-subscription-register.env", // from the subscription options + "tree:///etc/test/one", // directly from the OS customizations + "tree:///etc/test/two", + } + + pipeline, err := rawBootcPipeline.Serialize() + require.NoError(err) + + destinationPaths := collectCopyDestinationPaths(pipeline.Stages) + + // The order is significant. Do not use ElementsMatch() or similar. + require.Equal(expectedPaths, destinationPaths) + + expectedContents := []string{ + "ORG_ID=000\nACTIVATION_KEY=111", + "test 1", + "test 2", + } + + fileContents := manifest.GetInline(rawBootcPipeline) + // These are used to define the 'sources' part of the manifest, so the + // order doesn't matter + require.ElementsMatch(expectedContents, fileContents) +} + +func TestRawBootcImageSerializeURIFilesError(t *testing.T) { + rawBootcPipeline := makeFakeRawBootcPipeline() + + localFile := filepath.Join(t.TempDir(), "local-file") + require.NoError(t, os.WriteFile(localFile, []byte("some content"), 0644)) + uriFile := common.Must(fsnode.NewFileForURI("/etc/test/from-uri", nil, nil, nil, localFile)) + rawBootcPipeline.OSCustomizations.Files = []*fsnode.File{uriFile} + + _, err := rawBootcPipeline.Serialize() + assert.EqualError(t, err, fmt.Sprintf( + "cannot create file %q from %q: files from an URI are not supported for bootc disk images yet", + "/etc/test/from-uri", localFile)) +} + +// registrationUnitPath returns the UnitPath of the registration unit, found by +// filename because mount units share the systemd.unit.create stage type. +func registrationUnitPath(t *testing.T, stages []*osbuild.Stage) osbuild.SystemdUnitPath { + t.Helper() + for _, s := range findStages("org.osbuild.systemd.unit.create", stages) { + opts := s.Options.(*osbuild.SystemdUnitCreateStageOptions) + if opts.Filename == "osbuild-subscription-register.service" { + return opts.UnitPath + } + } + require.Fail(t, "no osbuild-subscription-register.service unit.create stage found") + return "" +} + +func collectMkdirPaths(stages []*osbuild.Stage) []string { + mkdirPaths := make([]string, 0) + for _, mkdirStage := range findStages("org.osbuild.mkdir", stages) { + mkdirStageOptions := mkdirStage.Options.(*osbuild.MkdirStageOptions) + for _, path := range mkdirStageOptions.Paths { + mkdirPaths = append(mkdirPaths, path.Path) + } + } + return mkdirPaths +}