Skip to content

Commit ef8cc3c

Browse files
committed
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 a6ecc32). UnitPath needs to be /etc/ because /usr/ is immutable. For the most part, the tests attempt to mirror the relevant tests for os.go.
1 parent 32ac16d commit ef8cc3c

2 files changed

Lines changed: 228 additions & 7 deletions

File tree

pkg/manifest/raw_bootc.go

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ type RawBootcImage struct {
4949

5050
// DiskCustomizations can influence things in the base OS tree
5151
DiskCustomizations DiskCustomizations
52+
53+
inlineData []string
5254
}
5355

5456
func (p RawBootcImage) Filename() string {
@@ -191,6 +193,8 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) {
191193

192194
postStages := []*osbuild.Stage{}
193195

196+
var subscriptionEnabledServices []string
197+
194198
fsCfgStages, err := filesystemConfigStages(pt, p.DiskCustomizations.MountConfiguration)
195199
if err != nil {
196200
return osbuild.Pipeline{}, err
@@ -261,6 +265,36 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) {
261265
postStages = append(postStages, stages...)
262266
}
263267

268+
if p.OSCustomizations.Subscription != nil {
269+
subStage, subDirs, subFiles, subServices, err := subscriptionService(
270+
*p.OSCustomizations.Subscription,
271+
&subscriptionServiceOptions{
272+
// ostree based: unit in /etc (not /usr commit content),
273+
// insights on boot
274+
InsightsOnBoot: true,
275+
UnitPath: osbuild.EtcUnitPath,
276+
PermissiveRHC: common.ValueOrEmpty(p.OSCustomizations.PermissiveRHC),
277+
})
278+
if err != nil {
279+
return osbuild.Pipeline{}, err
280+
}
281+
282+
subFileStages, err := p.genFileStagesAndRecordInlineData(subFiles)
283+
if err != nil {
284+
return osbuild.Pipeline{}, err
285+
}
286+
stages := []*osbuild.Stage{subStage}
287+
stages = append(stages, osbuild.GenDirectoryNodesStages(subDirs)...)
288+
stages = append(stages, subFileStages...)
289+
for _, stage := range stages {
290+
stage.Mounts = mounts
291+
stage.Devices = devices
292+
}
293+
postStages = append(postStages, stages...)
294+
295+
subscriptionEnabledServices = subServices
296+
}
297+
264298
// First create custom directories, because some of the custom files may depend on them
265299
if len(p.OSCustomizations.Directories) > 0 {
266300

@@ -273,14 +307,31 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) {
273307
}
274308

275309
if len(p.OSCustomizations.Files) > 0 {
276-
stages := osbuild.GenFileNodesStages(p.OSCustomizations.Files)
310+
stages, err := p.genFileStagesAndRecordInlineData(p.OSCustomizations.Files)
311+
if err != nil {
312+
return osbuild.Pipeline{}, err
313+
}
277314
for _, stage := range stages {
278315
stage.Mounts = mounts
279316
stage.Devices = devices
280317
}
281318
postStages = append(postStages, stages...)
282319
}
283320

321+
// single point where enabled services are collected, as in the OS
322+
// pipeline; OSCustomizations.EnabledServices is not honoured yet
323+
var enabledServices []string
324+
enabledServices = append(enabledServices, subscriptionEnabledServices...)
325+
326+
if len(enabledServices) > 0 {
327+
systemdStage := osbuild.NewSystemdStage(&osbuild.SystemdStageOptions{
328+
EnabledServices: enabledServices,
329+
})
330+
systemdStage.Mounts = mounts
331+
systemdStage.Devices = devices
332+
postStages = append(postStages, systemdStage)
333+
}
334+
284335
// The ignition stamp must be created after bootc install, otherwise bootc will error out
285336
// because the boot partition is not empty.
286337
// That's why we have to pass `mount://boot/` and can't write to `tree://boot/`.
@@ -333,16 +384,24 @@ func (p *RawBootcImage) serialize() (osbuild.Pipeline, error) {
333384
return pipeline, nil
334385
}
335386

336-
// XXX: duplicated from os.go
337387
func (p *RawBootcImage) getInline() []string {
338-
inlineData := []string{}
388+
return p.inlineData
389+
}
339390

340-
// inline data for custom files
341-
for _, file := range p.OSCustomizations.Files {
342-
inlineData = append(inlineData, string(file.Data()))
391+
// genFileStagesAndRecordInlineData returns the stages that create the given
392+
// files and records their content as inline data for getInline().
393+
func (p *RawBootcImage) genFileStagesAndRecordInlineData(files []*fsnode.File) ([]*osbuild.Stage, error) {
394+
for _, file := range files {
395+
// files that come via an URI are not inline data, they
396+
// would need to be added to the manifest sources via a
397+
// fileRefs() implementation like the one in the OS pipeline
398+
if file.URI() != "" {
399+
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())
400+
}
401+
p.inlineData = append(p.inlineData, string(file.Data()))
343402
}
344403

345-
return inlineData
404+
return osbuild.GenFileNodesStages(files), nil
346405
}
347406

348407
// XXX: copied from raw.go

pkg/manifest/raw_bootc_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package manifest_test
33
import (
44
"fmt"
55
"os"
6+
"path/filepath"
67
"testing"
78

89
"github.com/stretchr/testify/assert"
@@ -13,6 +14,7 @@ import (
1314
"github.com/osbuild/image-builder/pkg/arch"
1415
"github.com/osbuild/image-builder/pkg/container"
1516
"github.com/osbuild/image-builder/pkg/customizations/fsnode"
17+
"github.com/osbuild/image-builder/pkg/customizations/subscription"
1618
"github.com/osbuild/image-builder/pkg/customizations/users"
1719
"github.com/osbuild/image-builder/pkg/manifest"
1820
"github.com/osbuild/image-builder/pkg/osbuild"
@@ -409,3 +411,163 @@ func TestRawBootcPXE(t *testing.T) {
409411
assert.Contains(t, mkdirPaths, "/usr")
410412
assert.Contains(t, mkdirPaths, "/proc")
411413
}
414+
415+
func TestRawBootcImageSerializeSubscriptionManagerCommands(t *testing.T) {
416+
rawBootcPipeline := makeFakeRawBootcPipeline()
417+
rawBootcPipeline.OSCustomizations.Subscription = &subscription.ImageOptions{
418+
Organization: "2040324",
419+
ActivationKey: "my-secret-key",
420+
ServerUrl: "subscription.rhsm.redhat.com",
421+
BaseUrl: "http://cdn.redhat.com/",
422+
}
423+
424+
pipeline, err := rawBootcPipeline.Serialize()
425+
require.NoError(t, err)
426+
CheckSystemdStageOptions(t, pipeline.Stages, []string{
427+
"/usr/sbin/subscription-manager config --server.hostname 'subscription.rhsm.redhat.com'",
428+
`/usr/sbin/subscription-manager register --org="${ORG_ID}" --activationkey="${ACTIVATION_KEY}" --baseurl 'http://cdn.redhat.com/'`,
429+
})
430+
431+
// registration unit in /etc, not /usr (ostree commit content)
432+
assert.Equal(t, osbuild.EtcUnitPath, registrationUnitPath(t, pipeline.Stages))
433+
}
434+
435+
func TestRawBootcImageSerializeSubscriptionManagerInsightsCommands(t *testing.T) {
436+
rawBootcPipeline := makeFakeRawBootcPipeline()
437+
rawBootcPipeline.OSCustomizations.Subscription = &subscription.ImageOptions{
438+
Organization: "2040324",
439+
ActivationKey: "my-secret-key",
440+
ServerUrl: "subscription.rhsm.redhat.com",
441+
BaseUrl: "http://cdn.redhat.com/",
442+
Insights: true,
443+
}
444+
445+
pipeline, err := rawBootcPipeline.Serialize()
446+
require.NoError(t, err)
447+
CheckSystemdStageOptions(t, pipeline.Stages, []string{
448+
"/usr/sbin/subscription-manager config --server.hostname 'subscription.rhsm.redhat.com'",
449+
`/usr/sbin/subscription-manager register --org="${ORG_ID}" --activationkey="${ACTIVATION_KEY}" --baseurl 'http://cdn.redhat.com/'`,
450+
"/usr/bin/insights-client --register",
451+
})
452+
453+
// InsightsOnBoot also materializes the insights-client drop-in
454+
mkdirPaths := collectMkdirPaths(pipeline.Stages)
455+
assert.Contains(t, mkdirPaths, "/etc/systemd/system/insights-client-boot.service.d")
456+
destinationPaths := collectCopyDestinationPaths(pipeline.Stages)
457+
assert.Contains(t, destinationPaths, "tree:///etc/systemd/system/insights-client-boot.service.d/override.conf")
458+
}
459+
460+
func TestRawBootcImageSerializeRhcInsightsCommands(t *testing.T) {
461+
rawBootcPipeline := makeFakeRawBootcPipeline()
462+
rawBootcPipeline.OSCustomizations.Subscription = &subscription.ImageOptions{
463+
Organization: "2040324",
464+
ActivationKey: "my-secret-key",
465+
ServerUrl: "subscription.rhsm.redhat.com",
466+
BaseUrl: "http://cdn.redhat.com/",
467+
Insights: false,
468+
Rhc: true,
469+
}
470+
rawBootcPipeline.OSCustomizations.PermissiveRHC = common.ToPtr(true)
471+
472+
pipeline, err := rawBootcPipeline.Serialize()
473+
require.NoError(t, err)
474+
CheckSystemdStageOptions(t, pipeline.Stages, []string{
475+
"/usr/sbin/subscription-manager config --server.hostname 'subscription.rhsm.redhat.com'",
476+
`/usr/bin/rhc connect --organization="${ORG_ID}" --activation-key="${ACTIVATION_KEY}"`,
477+
"/usr/sbin/semanage permissive --add rhcd_t",
478+
})
479+
}
480+
481+
func TestRawBootcImageSerializeSubscriptionEnablesService(t *testing.T) {
482+
rawBootcPipeline := makeFakeRawBootcPipeline()
483+
rawBootcPipeline.OSCustomizations.Subscription = &subscription.ImageOptions{
484+
Organization: "2040324",
485+
ActivationKey: "my-secret-key",
486+
}
487+
488+
pipeline, err := rawBootcPipeline.Serialize()
489+
require.NoError(t, err)
490+
491+
stage := findStage("org.osbuild.systemd", pipeline.Stages)
492+
require.NotNil(t, stage)
493+
opts := stage.Options.(*osbuild.SystemdStageOptions)
494+
assert.Equal(t, []string{"osbuild-subscription-register.service"}, opts.EnabledServices)
495+
}
496+
497+
// Mirrors TestAddInlineOS: the env file must be both a copy destination and an
498+
// inline source, and is written before the blueprint's own files.
499+
func TestRawBootcImageSerializeSubscriptionEnvFile(t *testing.T) {
500+
rawBootcPipeline := makeFakeRawBootcPipeline()
501+
502+
require := require.New(t)
503+
504+
rawBootcPipeline.OSCustomizations.Files = createTestFilesForPipeline()
505+
rawBootcPipeline.OSCustomizations.Subscription = &subscription.ImageOptions{
506+
Organization: "000",
507+
ActivationKey: "111",
508+
}
509+
510+
expectedPaths := []string{
511+
"tree:///etc/osbuild-subscription-register.env", // from the subscription options
512+
"tree:///etc/test/one", // directly from the OS customizations
513+
"tree:///etc/test/two",
514+
}
515+
516+
pipeline, err := rawBootcPipeline.Serialize()
517+
require.NoError(err)
518+
519+
destinationPaths := collectCopyDestinationPaths(pipeline.Stages)
520+
521+
// The order is significant. Do not use ElementsMatch() or similar.
522+
require.Equal(expectedPaths, destinationPaths)
523+
524+
expectedContents := []string{
525+
"ORG_ID=000\nACTIVATION_KEY=111",
526+
"test 1",
527+
"test 2",
528+
}
529+
530+
fileContents := manifest.GetInline(rawBootcPipeline)
531+
// These are used to define the 'sources' part of the manifest, so the
532+
// order doesn't matter
533+
require.ElementsMatch(expectedContents, fileContents)
534+
}
535+
536+
func TestRawBootcImageSerializeURIFilesError(t *testing.T) {
537+
rawBootcPipeline := makeFakeRawBootcPipeline()
538+
539+
localFile := filepath.Join(t.TempDir(), "local-file")
540+
require.NoError(t, os.WriteFile(localFile, []byte("some content"), 0644))
541+
uriFile := common.Must(fsnode.NewFileForURI("/etc/test/from-uri", nil, nil, nil, localFile))
542+
rawBootcPipeline.OSCustomizations.Files = []*fsnode.File{uriFile}
543+
544+
_, err := rawBootcPipeline.Serialize()
545+
assert.EqualError(t, err, fmt.Sprintf(
546+
"cannot create file %q from %q: files from an URI are not supported for bootc disk images yet",
547+
"/etc/test/from-uri", localFile))
548+
}
549+
550+
// registrationUnitPath returns the UnitPath of the registration unit, found by
551+
// filename because mount units share the systemd.unit.create stage type.
552+
func registrationUnitPath(t *testing.T, stages []*osbuild.Stage) osbuild.SystemdUnitPath {
553+
t.Helper()
554+
for _, s := range findStages("org.osbuild.systemd.unit.create", stages) {
555+
opts := s.Options.(*osbuild.SystemdUnitCreateStageOptions)
556+
if opts.Filename == "osbuild-subscription-register.service" {
557+
return opts.UnitPath
558+
}
559+
}
560+
require.Fail(t, "no osbuild-subscription-register.service unit.create stage found")
561+
return ""
562+
}
563+
564+
func collectMkdirPaths(stages []*osbuild.Stage) []string {
565+
mkdirPaths := make([]string, 0)
566+
for _, mkdirStage := range findStages("org.osbuild.mkdir", stages) {
567+
mkdirStageOptions := mkdirStage.Options.(*osbuild.MkdirStageOptions)
568+
for _, path := range mkdirStageOptions.Paths {
569+
mkdirPaths = append(mkdirPaths, path.Path)
570+
}
571+
}
572+
return mkdirPaths
573+
}

0 commit comments

Comments
 (0)