Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion pkg/controller/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ import (
kscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/klog/v2"

"github.com/containers/image/v5/docker/reference"
"github.com/opencontainers/go-digest"
apicfgv1 "github.com/openshift/api/config/v1"
apicfgv1alpha1 "github.com/openshift/api/config/v1alpha1"
mcfgv1 "github.com/openshift/api/machineconfiguration/v1"
apioperatorsv1alpha1 "github.com/openshift/api/operator/v1alpha1"
buildconstants "github.com/openshift/machine-config-operator/pkg/controller/build/constants"
ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common"
containerruntimeconfig "github.com/openshift/machine-config-operator/pkg/controller/container-runtime-config"
kubeletconfig "github.com/openshift/machine-config-operator/pkg/controller/kubelet-config"
Expand Down Expand Up @@ -73,8 +76,9 @@ func (b *Bootstrap) Run(destDir string) error {
apioperatorsv1alpha1.Install(scheme)
apicfgv1.Install(scheme)
apicfgv1alpha1.Install(scheme)
corev1.AddToScheme(scheme)
codecFactory := serializer.NewCodecFactory(scheme)
decoder := codecFactory.UniversalDecoder(mcfgv1.GroupVersion, apioperatorsv1alpha1.GroupVersion, apicfgv1.GroupVersion, apicfgv1alpha1.GroupVersion)
decoder := codecFactory.UniversalDecoder(mcfgv1.GroupVersion, apioperatorsv1alpha1.GroupVersion, apicfgv1.GroupVersion, apicfgv1alpha1.GroupVersion, corev1.SchemeGroupVersion)

var (
cconfig *mcfgv1.ControllerConfig
Expand All @@ -83,6 +87,7 @@ func (b *Bootstrap) Run(destDir string) error {
kconfigs []*mcfgv1.KubeletConfig
pools []*mcfgv1.MachineConfigPool
configs []*mcfgv1.MachineConfig
machineOSConfigs []*mcfgv1.MachineOSConfig
crconfigs []*mcfgv1.ContainerRuntimeConfig
icspRules []*apioperatorsv1alpha1.ImageContentSourcePolicy
idmsRules []*apicfgv1.ImageDigestMirrorSet
Expand Down Expand Up @@ -124,6 +129,8 @@ func (b *Bootstrap) Run(destDir string) error {
pools = append(pools, obj)
case *mcfgv1.MachineConfig:
configs = append(configs, obj)
case *mcfgv1.MachineOSConfig:
machineOSConfigs = append(machineOSConfigs, obj)
case *mcfgv1.ControllerConfig:
cconfig = obj
case *mcfgv1.ContainerRuntimeConfig:
Expand Down Expand Up @@ -234,6 +241,17 @@ func (b *Bootstrap) Run(destDir string) error {
}
klog.Infof("Successfully generated MachineConfigs from kubelet configs.")

// Create component MachineConfigs for pre-built images for hybrid OCL
// This must happen BEFORE render.RunBootstrap() so they can be merged into rendered MCs
if len(machineOSConfigs) > 0 {
preBuiltImageMCs, err := createPreBuiltImageMachineConfigs(machineOSConfigs, pools)
if err != nil {
return fmt.Errorf("failed to create pre-built image MachineConfigs: %w", err)
}
configs = append(configs, preBuiltImageMCs...)
klog.Infof("Successfully created %d pre-built image component MachineConfigs for hybrid OCL.", len(preBuiltImageMCs))
}

fpools, gconfigs, err := render.RunBootstrap(pools, configs, cconfig)
if err != nil {
return err
Expand Down Expand Up @@ -376,3 +394,68 @@ func parseManifests(filename string, r io.Reader) ([]manifest, error) {
manifests = append(manifests, m)
}
}

// createPreBuiltImageMachineConfigs creates component MachineConfigs that set osImageURL for pools
// that have associated MachineOSConfigs with pre-built image annotations.
// These component MCs will be automatically merged into rendered MCs by the render controller.
// This function validates pre-built images at bootstrap time and will fail if the image format is invalid.
// MOSCs without the annotation are skipped (this can happen if bootstrap runs again after seeding).
func createPreBuiltImageMachineConfigs(machineOSConfigs []*mcfgv1.MachineOSConfig, pools []*mcfgv1.MachineConfigPool) ([]*mcfgv1.MachineConfig, error) {
var preBuiltImageMCs []*mcfgv1.MachineConfig

for _, mosc := range machineOSConfigs {
preBuiltImage, hasPreBuiltImage := mosc.Annotations[buildconstants.PreBuiltImageAnnotationKey]

// Skip if annotation is not present (could be a re-run after seeding completed)
if !hasPreBuiltImage || preBuiltImage == "" {
klog.V(4).Infof("Skipping MachineOSConfig %s - no pre-built image annotation (may have already been seeded)", mosc.Name)
continue
}

poolName := mosc.Spec.MachineConfigPool.Name

// Validate the pre-built image format and digest
if err := validatePreBuiltImage(preBuiltImage); err != nil {
return nil, fmt.Errorf("invalid pre-built image %q for MachineOSConfig %s (pool %s): %w",
preBuiltImage, mosc.Name, poolName, err)
}

// Create the component MachineConfig
mc := ctrlcommon.CreatePreBuiltImageMachineConfig(poolName, preBuiltImage, buildconstants.PreBuiltImageAnnotationKey)
preBuiltImageMCs = append(preBuiltImageMCs, mc)
klog.Infof("✓ Validated and created component MachineConfig %s with OSImageURL: %s for pool %s", mc.Name, preBuiltImage, poolName)
}

return preBuiltImageMCs, nil
}

// validatePreBuiltImage validates the pre-built image format using containers/image library
func validatePreBuiltImage(imageSpec string) error {
if imageSpec == "" {
return fmt.Errorf("pre-built image spec cannot be empty")
}

// Use the containers/image library to parse and validate the image reference
ref, err := reference.ParseNamed(imageSpec)
if err != nil {
return fmt.Errorf("pre-built image has invalid format: %w", err)
}

// Ensure the reference has a digest (is canonical)
canonical, ok := ref.(reference.Canonical)
if !ok {
return fmt.Errorf("pre-built image must use digested format (image@sha256:digest), got: %q", imageSpec)
}

// Validate the digest using the go-digest library
if err := canonical.Digest().Validate(); err != nil {
return fmt.Errorf("pre-built image has invalid digest: %w", err)
}

// Ensure it's specifically a SHA256 digest (which is what we expect for container images)
if canonical.Digest().Algorithm() != digest.SHA256 {
return fmt.Errorf("pre-built image must use SHA256 digest, got %s: %q", canonical.Digest().Algorithm(), imageSpec)
}

return nil
}
69 changes: 69 additions & 0 deletions pkg/controller/bootstrap/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,72 @@ func TestBootstrapRun(t *testing.T) {
})
}
}

func TestValidatePreBuiltImage(t *testing.T) {
tests := []struct {
name string
imageSpec string
expectedError bool
errorContains string
}{
{
name: "Valid image with proper digest format",
imageSpec: "registry.example.com/test@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
expectedError: false,
},
{
name: "Empty image spec should fail",
imageSpec: "",
expectedError: true,
errorContains: "cannot be empty",
},
{
name: "Image without digest should fail",
imageSpec: "registry.example.com/test:latest",
expectedError: true,
errorContains: "must use digested format",
},
{
name: "Image with invalid digest length should fail",
imageSpec: "registry.example.com/test@sha256:12345",
expectedError: true,
errorContains: "invalid reference format",
},
{
name: "Image with invalid digest characters should fail",
imageSpec: "registry.example.com/test@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdez",
expectedError: true,
errorContains: "invalid reference format",
},
{
name: "Image with uppercase digest should fail",
imageSpec: "registry.example.com/test@sha256:1234567890ABCDEF1234567890abcdef1234567890abcdef1234567890abcdef",
expectedError: true,
errorContains: "invalid checksum digest format",
},
{
name: "Image with MD5 digest should fail",
imageSpec: "registry.example.com/test@md5:1234567890abcdef1234567890abcdef",
expectedError: true,
errorContains: "unsupported digest algorithm",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validatePreBuiltImage(tt.imageSpec)

if tt.expectedError && err == nil {
t.Errorf("Expected error but got none")
}
if !tt.expectedError && err != nil {
t.Errorf("Unexpected error: %v", err)
}
if tt.expectedError && err != nil && tt.errorContains != "" {
if !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("Expected error to contain %q, but got: %v", tt.errorContains, err)
}
}
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
apiVersion: machineconfiguration.openshift.io/v1
kind: MachineOSConfig
metadata:
name: layered-worker
annotations:
machineconfiguration.openshift.io/pre-built-image: "quay.io/example/layered-rhcos:latest@sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"
spec:
machineConfigPool:
name: layered-worker
imageBuilder:
imageBuilderType: Job
baseImagePullSecret:
name: pull-secret
renderedImagePushSecret:
name: push-secret
renderedImagePushSpec: quay.io/example/layered-rhcos:latest
containerFile:
- containerfileArch: NoArch
content: |
FROM configs AS final
RUN rpm-ostree install httpd && \
ostree container commit
18 changes: 18 additions & 0 deletions pkg/controller/build/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ const (
TargetMachineConfigPoolLabelKey = "machineconfiguration.openshift.io/target-machine-config-pool"
)

// New labels for pre-built image tracking
const (
// PreBuiltImageLabelKey marks MachineOSBuild objects created from pre-built images
PreBuiltImageLabelKey = "machineconfiguration.openshift.io/pre-built-image"
)

// Annotations added to all ephemeral build objects BuildController creates.
const (
MachineOSBuildNameAnnotationKey = "machineconfiguration.openshift.io/machine-os-build"
Expand All @@ -36,6 +42,18 @@ const (
RebuildMachineOSConfigAnnotationKey string = "machineconfiguration.openshift.io/rebuild"
)

// New annotations for pre-built image support
const (
// PreBuiltImageAnnotationKey indicates a MachineOSConfig should be seeded with a pre-built image
PreBuiltImageAnnotationKey = "machineconfiguration.openshift.io/pre-built-image"
)

// Component MachineConfig naming for pre-built images
const (
// PreBuiltImageMachineConfigPrefix is the prefix for component MCs that set osImageURL from pre-built images
PreBuiltImageMachineConfigPrefix = "10-prebuiltimage-osimageurl-"
)

// Entitled build secret names
const (
// Name of the etc-pki-entitlement secret from the openshift-config-managed namespace.
Expand Down
41 changes: 41 additions & 0 deletions pkg/controller/build/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,47 @@ func hasRebuildAnnotation(mosc *mcfgv1.MachineOSConfig) bool {
return metav1.HasAnnotation(mosc.ObjectMeta, constants.RebuildMachineOSConfigAnnotationKey)
}

// hasPreBuiltImageAnnotation checks if a MachineOSConfig has the pre-built image annotation.
func hasPreBuiltImageAnnotation(mosc *mcfgv1.MachineOSConfig) bool {
_, exists := mosc.Annotations[constants.PreBuiltImageAnnotationKey]
return exists
}

// getPreBuiltImage returns the pre-built image from a MachineOSConfig's annotations.
// Returns the image string and a boolean indicating if it exists and is non-empty.
func getPreBuiltImage(mosc *mcfgv1.MachineOSConfig) (string, bool) {
image, exists := mosc.Annotations[constants.PreBuiltImageAnnotationKey]
return image, exists && image != ""
}

// shouldSeedWithPreBuiltImage determines if a MachineOSConfig should be seeded with a pre-built image.
// Returns true if:
// - The MOSC has a pre-built image annotation
// - The MOSC does NOT have a current build annotation (meaning seeding hasn't happened yet)
func shouldSeedWithPreBuiltImage(mosc *mcfgv1.MachineOSConfig) bool {
return hasPreBuiltImageAnnotation(mosc) &&
!hasCurrentBuildAnnotation(mosc)
}

// isPreBuiltImageAwaitingSeeding checks if a MOSC has pre-built image annotation but hasn't been seeded.
// This is useful for skipping normal build workflows when the seeding workflow should handle it.
// Seeding is considered complete once the currentBuild annotation is set.
func isPreBuiltImageAwaitingSeeding(mosc *mcfgv1.MachineOSConfig) bool {
return hasPreBuiltImageAnnotation(mosc) && !hasCurrentBuildAnnotation(mosc)
}

// needsPreBuiltImageAnnotationCleanup determines if a MOSC has completed seeding and
// the pre-built image annotation can be safely removed.
// Returns true if:
// - The MOSC has a current build annotation (seeding is complete)
// - The MOSC status has been populated with CurrentImagePullSpec
// - The MOSC still has the PreBuiltImageAnnotationKey (needs cleanup)
func needsPreBuiltImageAnnotationCleanup(mosc *mcfgv1.MachineOSConfig) bool {
return hasCurrentBuildAnnotation(mosc) &&
mosc.Status.CurrentImagePullSpec != "" &&
hasPreBuiltImageAnnotation(mosc)
}

// Looks at the error chain for the given error and determines if the error
// should be ignored or not based upon whether it is a not found error. If it
// should be ignored, this will log the error as well as the name and kind of
Expand Down
81 changes: 81 additions & 0 deletions pkg/controller/build/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

mcfgv1 "github.com/openshift/api/machineconfiguration/v1"
"github.com/openshift/machine-config-operator/pkg/apihelpers"
"github.com/openshift/machine-config-operator/pkg/controller/build/constants"
"github.com/openshift/machine-config-operator/pkg/controller/build/fixtures"
ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -198,3 +199,83 @@ func TestIsMachineOSBuildStatusUpdateNeeded(t *testing.T) {
}
}
}

func TestNeedsPreBuiltImageAnnotationCleanup(t *testing.T) {
t.Parallel()

tests := []struct {
name string
mosc *mcfgv1.MachineOSConfig
expectedCleanup bool
}{
{
name: "needs cleanup - all conditions met",
mosc: &mcfgv1.MachineOSConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Annotations: map[string]string{
constants.PreBuiltImageAnnotationKey: "registry.example.com/image@sha256:abc123",
constants.CurrentMachineOSBuildAnnotationKey: "test-build-1",
},
},
Status: mcfgv1.MachineOSConfigStatus{
CurrentImagePullSpec: "registry.example.com/image@sha256:abc123",
},
},
expectedCleanup: true,
},
{
name: "no cleanup - missing current build annotation",
mosc: &mcfgv1.MachineOSConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Annotations: map[string]string{
constants.PreBuiltImageAnnotationKey: "registry.example.com/image@sha256:abc123",
},
},
Status: mcfgv1.MachineOSConfigStatus{
CurrentImagePullSpec: "registry.example.com/image@sha256:abc123",
},
},
expectedCleanup: false,
},
{
name: "no cleanup - status not populated",
mosc: &mcfgv1.MachineOSConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Annotations: map[string]string{
constants.PreBuiltImageAnnotationKey: "registry.example.com/image@sha256:abc123",
constants.CurrentMachineOSBuildAnnotationKey: "test-build-1",
},
},
Status: mcfgv1.MachineOSConfigStatus{
CurrentImagePullSpec: "",
},
},
expectedCleanup: false,
},
{
name: "no cleanup - prebuilt image annotation already removed",
mosc: &mcfgv1.MachineOSConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Annotations: map[string]string{
constants.CurrentMachineOSBuildAnnotationKey: "test-build-1",
},
},
Status: mcfgv1.MachineOSConfigStatus{
CurrentImagePullSpec: "registry.example.com/image@sha256:abc123",
},
},
expectedCleanup: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := needsPreBuiltImageAnnotationCleanup(tt.mosc)
assert.Equal(t, tt.expectedCleanup, result, "needsPreBuiltImageAnnotationCleanup() result mismatch")
})
}
}
Loading