From 714adf31d03810aa11d9e5ce7d37a206f97b0d18 Mon Sep 17 00:00:00 2001 From: Lucas Garfield Date: Wed, 15 Jul 2026 09:10:00 -0500 Subject: [PATCH 1/2] manifest/raw_bootc: support subscription registration on first boot This only takes effect once callers populate OSCustomizations.Subscription; see the follow-up commit. Previously, the only source of inline data was Customizations.Files. Now we also need to create inline sources for files needed for subscription. For the sake of consistency with the OS pipeline in os.go, we add an inlineData field to RawBootcImage. The getInline() method just becomes a getter for the inlineData field. A new helper, genFileStagesAndRecordInlineData(), roughly mirrors (*OS).addStagesForAllFilesAndInlineData. Unlike the OS pipeline, however, there is no fileRefs() implementation here, so the helper returns an error for URI-backed files; previously these silently produced a manifest referencing a source that was never defined. In addition to files, the subscriptionService() function also enables a service. Instead of enabling it in the subscription code block, we add it to enabledServices later on. This is again similar to how we serialize the OS pipeline and will make supporting Customizations.Services easy to add later. For subscriptionServiceOptions, InsightsOnBoot creates a drop-in that reruns Insights collection on every healthy boot so that after an upgrade the console reflects the new commit hash (see a6ecc3255). UnitPath needs to be /etc/ because /usr/ is immutable. For the most part, the tests attempt to mirror the relevant tests for os.go. --- pkg/manifest/raw_bootc.go | 74 +++++++++++++-- pkg/manifest/raw_bootc_test.go | 162 +++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 7 deletions(-) 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 +} From 02f91aaeb496055e2b2872e7c1e68a3b684f6ac8 Mon Sep 17 00:00:00 2001 From: Lucas Garfield Date: Wed, 15 Jul 2026 09:10:11 -0500 Subject: [PATCH 2/2] distro/generic: pass subscription options to bootc disk images Copy the requested subscription options into the image customizations in manifestForDisk, so bootc disk images (qcow2, ami, ...) register on first boot via the machinery added in the previous commit. --- pkg/distro/generic/bootc_image_test.go | 22 ++++++++++++++++++++++ pkg/distro/generic/bootc_imagetype.go | 1 + 2 files changed, 23 insertions(+) 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()