From cb64706afbd1bb4928d4c8a8ca17a4fb48d44224 Mon Sep 17 00:00:00 2001 From: OpenShift CI Bot Date: Mon, 15 Jun 2026 09:03:52 +0000 Subject: [PATCH 1/9] feat(nodepool): add OS stream resolution and runc detection for dual-stream support Implements the core getRHELStream() function that resolves the RHEL OS stream based on the user's explicit spec.osImageStream.name selection, the release version, and whether the NodePool configuration uses runc. Resolution rules: - Explicit rhel-10 on pre-5.0 releases returns an error - Explicit rhel-10 with runc config returns an error (RHEL 10 drops runc) - No explicit stream + pre-5.0 returns empty (legacy behavior) - No explicit stream + >=5.0 defaults to rhel-10 - No explicit stream + >=5.0 + runc falls back to rhel-9 Also adds usesRunc detection to ConfigGenerator by inspecting ContainerRuntimeConfig manifests during config parsing, and adds rhelStream to rolloutConfig for config hash computation (populated from spec only, not resolved value, to prevent fleet-wide rollouts when the default stream changes across releases). CNTRLPLANE-3612 Co-Authored-By: Claude Opus 4.6 --- .../controllers/nodepool/config.go | 23 ++++++- .../controllers/nodepool/rhel_stream.go | 61 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 hypershift-operator/controllers/nodepool/rhel_stream.go diff --git a/hypershift-operator/controllers/nodepool/config.go b/hypershift-operator/controllers/nodepool/config.go index 8771f45ea83c..ba4729db1c7f 100644 --- a/hypershift-operator/controllers/nodepool/config.go +++ b/hypershift-operator/controllers/nodepool/config.go @@ -42,6 +42,10 @@ type ConfigGenerator struct { hostedCluster *hyperv1.HostedCluster nodePool *hyperv1.NodePool controlplaneNamespace string + // usesRunc indicates whether the NodePool configuration includes a ContainerRuntimeConfig + // that sets the default container runtime to runc. This is used for OS stream resolution + // since RHEL 10 does not support runc. + usesRunc bool *rolloutConfig } @@ -60,6 +64,11 @@ type rolloutConfig struct { // TODO(alberto): consider let haproxyRawConfig be an implementation detail of ConfigGenerator. // For now, it's a required input to keep the haproxy business logic and files outside the scope of this initial refactor. haproxyRawConfig string + // rhelStream is the explicit OS stream from spec.osImageStream.name. + // This MUST be populated from the spec field directly, not from the resolved value, + // to prevent fleet-wide rollouts when the default stream changes across releases. + // Empty when the user hasn't explicitly selected a stream. + rhelStream string } // NewConfigGenerator is the contract to create a new ConfigGenerator. @@ -77,6 +86,13 @@ func NewConfigGenerator(ctx context.Context, client client.Client, hostedCluster return nil, err } + // Populate rhelStream from the spec field directly (not the resolved value) + // to prevent fleet-wide rollouts when the default stream changes across releases. + var rhelStream string + if nodePool.Spec.OSImageStream.Name != "" { + rhelStream = nodePool.Spec.OSImageStream.Name + } + cg := &ConfigGenerator{ Client: client, hostedCluster: hostedCluster, @@ -87,6 +103,7 @@ func NewConfigGenerator(ctx context.Context, client client.Client, hostedCluster pullSecretName: hostedCluster.Spec.PullSecret.Name, globalConfig: globalConfig, haproxyRawConfig: haproxyRawConfig, + rhelStream: rhelStream, }, } @@ -118,7 +135,7 @@ func (cg *ConfigGenerator) CompressedAndEncoded() (*bytes.Buffer, error) { // TODO(alberto): hash the struct directly instead of the string representation field by field. // This is kept like this for now to contain the scope of the refactor and avoid backward compatibility issues. func (cg *ConfigGenerator) Hash() string { - return supportutil.HashSimple(cg.mcoRawConfig + cg.releaseImage.Version() + cg.pullSecretName + cg.additionalTrustBundleName + cg.globalConfig) + return supportutil.HashSimple(cg.mcoRawConfig + cg.releaseImage.Version() + cg.pullSecretName + cg.additionalTrustBundleName + cg.globalConfig + cg.rhelStream) } // HashWithOutVersion is like Hash but doesn't compute the release version. @@ -313,6 +330,10 @@ func (cg *ConfigGenerator) defaultAndValidateConfigManifest(manifest []byte) ([] "machineconfiguration.openshift.io/mco-built-in": "", }, } + // Detect runc usage for OS stream resolution (RHEL 10 does not support runc). + if obj.Spec.ContainerRuntimeConfig.DefaultRuntime == mcfgv1.ContainerRuntimeDefaultRuntimeRunc { + cg.usesRunc = true + } manifest, err = api.CompatibleYAMLEncode(cr, yamlSerializer) if err != nil { return nil, fmt.Errorf("failed to encode container runtime config after setting built-in MCP selector: %w", err) diff --git a/hypershift-operator/controllers/nodepool/rhel_stream.go b/hypershift-operator/controllers/nodepool/rhel_stream.go new file mode 100644 index 000000000000..246f02aa5342 --- /dev/null +++ b/hypershift-operator/controllers/nodepool/rhel_stream.go @@ -0,0 +1,61 @@ +package nodepool + +import ( + "fmt" + + "github.com/blang/semver" +) + +const ( + // RHELStreamRHEL9 is the RHEL 9 OS stream identifier. + RHELStreamRHEL9 = "rhel-9" + // RHELStreamRHEL10 is the RHEL 10 OS stream identifier. + RHELStreamRHEL10 = "rhel-10" +) + +// rhelStreamMinVersion is the minimum release version that supports RHEL 10. +// Releases before this version only support RHEL 9. +var rhelStreamMinVersion = semver.Version{Major: 5, Minor: 0, Patch: 0} + +// getRHELStream resolves the OS stream for a NodePool based on the user's explicit +// spec selection, the release version, and whether the NodePool uses runc. +// +// Rules: +// - If specStream is set to "rhel-10" but the release is < 5.0, return an error. +// - If specStream is set to "rhel-10" but the NodePool uses runc, return an error (RHEL 10 drops runc). +// - If specStream is set explicitly and is valid, return it. +// - If specStream is empty and release < 5.0, return "" (legacy behavior, no OSImageStream CR generated). +// - If specStream is empty and release >= 5.0 and usesRunc, fall back to "rhel-9". +// - If specStream is empty and release >= 5.0, default to "rhel-10". +func getRHELStream(specStream string, releaseVersion semver.Version, usesRunc bool) (string, error) { + isPost5 := releaseVersion.GTE(rhelStreamMinVersion) + + switch specStream { + case RHELStreamRHEL10: + if !isPost5 { + return "", fmt.Errorf("OS stream %q is not supported for release version %s (requires >= %s)", specStream, releaseVersion, rhelStreamMinVersion) + } + if usesRunc { + return "", fmt.Errorf("OS stream %q is not compatible with ContainerRuntimeConfig using runc: RHEL 10 does not support runc", specStream) + } + return RHELStreamRHEL10, nil + + case RHELStreamRHEL9: + return RHELStreamRHEL9, nil + + case "": + // No explicit stream selected — derive from release version. + if !isPost5 { + // Pre-5.0: legacy behavior, no OSImageStream CR generated. + return "", nil + } + // >= 5.0: default to rhel-10, but fall back to rhel-9 if runc is in use. + if usesRunc { + return RHELStreamRHEL9, nil + } + return RHELStreamRHEL10, nil + + default: + return "", fmt.Errorf("unsupported OS stream %q: must be %q or %q", specStream, RHELStreamRHEL9, RHELStreamRHEL10) + } +} From c32ab8ca6b9a7689c5841bdb283e393480866938 Mon Sep 17 00:00:00 2001 From: OpenShift CI Bot Date: Mon, 15 Jun 2026 09:04:00 +0000 Subject: [PATCH 2/9] feat(nodepool): plumb OS stream through token secret and ignition provider Adds osStream field to Token struct, resolved via getRHELStream() during NewToken(). The resolved stream is stored in the token secret under a new "os-stream" data key so the ignition server can use it during payload generation. Updates the IgnitionProvider.GetPayload() interface to accept an osStream parameter. The TokenSecretReconciler reads the os-stream key from the token secret and passes it through to the ignition provider. This is the plumbing layer that connects the NodePool controller's stream resolution to the ignition server's payload generation pipeline. CNTRLPLANE-3612 Co-Authored-By: Claude Opus 4.6 --- .../controllers/nodepool/token.go | 23 ++++++++++++++++++- .../cmd/run_local_ignitionprovider.go | 2 +- .../controllers/tokensecret_controller.go | 8 +++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/hypershift-operator/controllers/nodepool/token.go b/hypershift-operator/controllers/nodepool/token.go index aaff447e9841..ad376dbcf969 100644 --- a/hypershift-operator/controllers/nodepool/token.go +++ b/hypershift-operator/controllers/nodepool/token.go @@ -27,6 +27,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/blang/semver" "github.com/clarketm/json" ignitionapi "github.com/coreos/ignition/v2/config/v3_2/types" "github.com/go-logr/logr" @@ -42,6 +43,7 @@ const ( TokenSecretHCConfigurationHashKey = "hc-configuration-hash" TokenSecretAdditionalTrustBundleKey = "additional-trust-bundle-hash" TokenSecretConfigKey = "config" + TokenSecretOSStreamKey = "os-stream" TokenSecretAnnotation = "hypershift.openshift.io/ignition-config" TokenSecretIgnitionReachedAnnotation = "hypershift.openshift.io/ignition-reached" TokenSecretNodePoolUpgradeType = "hypershift.openshift.io/node-pool-upgrade-type" @@ -61,7 +63,9 @@ type Token struct { pullSecretHash []byte additionalTrustBundleHash []byte globalConfigHash []byte - userData *userData + // osStream is the resolved OS stream for this NodePool (e.g. "rhel-9", "rhel-10", or "" for legacy). + osStream string + userData *userData } // userData contains the input necessary to generate the user data secret @@ -113,6 +117,17 @@ func NewToken(ctx context.Context, configGenerator *ConfigGenerator, cpoCapabili return nil, fmt.Errorf("failed to hash HostedCluster configuration: %w", err) } + // Resolve OS stream based on spec, release version, and runc usage. + var osStream string + releaseVersion, parseErr := semver.Parse(configGenerator.releaseImage.Version()) + if parseErr != nil { + return nil, fmt.Errorf("failed to parse release version for OS stream resolution: %w", parseErr) + } + osStream, err = getRHELStream(configGenerator.nodePool.Spec.OSImageStream.Name, releaseVersion, configGenerator.usesRunc) + if err != nil { + return nil, fmt.Errorf("failed to resolve OS stream: %w", err) + } + token := &Token{ CreateOrUpdateProvider: upsert.New(false), ConfigGenerator: configGenerator, @@ -120,6 +135,7 @@ func NewToken(ctx context.Context, configGenerator *ConfigGenerator, cpoCapabili pullSecretHash: []byte(supportutil.HashSimple(pullSecretBytes)), additionalTrustBundleHash: []byte(supportutil.HashSimple(additionalTrustBundle)), globalConfigHash: []byte(hcConfigurationHash), + osStream: osStream, } // User data input. @@ -354,6 +370,11 @@ func (t *Token) reconcileTokenSecret(tokenSecret *corev1.Secret) error { tokenSecret.Data[TokenSecretPullSecretHashKey] = t.pullSecretHash tokenSecret.Data[TokenSecretAdditionalTrustBundleKey] = t.additionalTrustBundleHash tokenSecret.Data[TokenSecretHCConfigurationHashKey] = t.globalConfigHash + + // Store the resolved OS stream so the ignition server can generate the OSImageStream CR. + if t.osStream != "" { + tokenSecret.Data[TokenSecretOSStreamKey] = []byte(t.osStream) + } } // TODO (alberto): Only apply this on creation and change the hash generation to only use triggering upgrade fields. // We let this change to happen inplace now as the tokenSecret and the mcs config use the whole spec.Config for the comparing hash. diff --git a/ignition-server/cmd/run_local_ignitionprovider.go b/ignition-server/cmd/run_local_ignitionprovider.go index 38e13d246a8f..9652f172c490 100644 --- a/ignition-server/cmd/run_local_ignitionprovider.go +++ b/ignition-server/cmd/run_local_ignitionprovider.go @@ -111,7 +111,7 @@ func (o *RunLocalIgnitionProviderOptions) Run(ctx context.Context) error { FeatureGateManifest: o.FeatureGateManifest, } - payload, err := p.GetPayload(ctx, o.Image, config.String(), "", "", "") + payload, err := p.GetPayload(ctx, o.Image, config.String(), "", "", "", "") if err != nil { return err } diff --git a/ignition-server/controllers/tokensecret_controller.go b/ignition-server/controllers/tokensecret_controller.go index 080421ef3300..af338fa97756 100644 --- a/ignition-server/controllers/tokensecret_controller.go +++ b/ignition-server/controllers/tokensecret_controller.go @@ -35,6 +35,7 @@ const ( TokenSecretPullSecretHashKey = "pull-secret-hash" TokenSecretHCConfigurationHashKey = "hc-configuration-hash" TokenSecretAdditionalTrustBundleHashKey = "additional-trust-bundle-hash" + TokenSecretOSStreamKey = "os-stream" InvalidConfigReason = "InvalidConfig" TokenSecretReasonKey = "reason" TokenSecretAnnotation = "hypershift.openshift.io/ignition-config" @@ -83,7 +84,9 @@ func NewPayloadStore() *ExpiringCache { type IgnitionProvider interface { // GetPayload returns the ignition payload content for // the provided release image and a config string containing 0..N MachineConfig yaml definitions. - GetPayload(ctx context.Context, payloadImage, config, pullSecretHash, additionalTrustBundleHash, hcConfigurationHash string) ([]byte, error) + // osStream specifies the RHEL OS stream (e.g. "rhel-9", "rhel-10") for dual-stream support. + // When empty, legacy behavior is used (no OSImageStream CR is generated). + GetPayload(ctx context.Context, payloadImage, config, pullSecretHash, additionalTrustBundleHash, hcConfigurationHash, osStream string) ([]byte, error) } // TokenSecretReconciler watches token Secrets @@ -271,9 +274,10 @@ func (r *TokenSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) pullSecretHash := string(tokenSecret.Data[TokenSecretPullSecretHashKey]) hcConfigurationHash := string(tokenSecret.Data[TokenSecretHCConfigurationHashKey]) additionalTrustBundleHash := string(tokenSecret.Data[TokenSecretAdditionalTrustBundleHashKey]) + osStream := string(tokenSecret.Data[TokenSecretOSStreamKey]) payload, err := func() ([]byte, error) { start := time.Now() - payload, err := r.IgnitionProvider.GetPayload(ctx, releaseImage, config.String(), pullSecretHash, additionalTrustBundleHash, hcConfigurationHash) + payload, err := r.IgnitionProvider.GetPayload(ctx, releaseImage, config.String(), pullSecretHash, additionalTrustBundleHash, hcConfigurationHash, osStream) if err != nil { return nil, fmt.Errorf("error getting ignition payload: %w", err) } From 1b64a676fa9d8d443bd9b00d1bb687c4a649e2aa Mon Sep 17 00:00:00 2001 From: OpenShift CI Bot Date: Mon, 15 Jun 2026 09:04:07 +0000 Subject: [PATCH 3/9] feat(ignition): generate OSImageStream CR in ignition payload pipeline Writes a 99_osimagestream.yaml manifest into the MCC input directory when the osStream parameter is non-empty. This manifest contains an OSImageStream CR with the resolved defaultStream, which MCC bootstrap uses to discover available OS streams via OCI label inspection and render MachineConfigs with the correct osImageURL. The CR is written at the end of runMCO (after copyMCOOutputToMCC) so it is available when runMCC processes the MCC input directory. When osStream is empty (pre-5.0 legacy behavior), no CR is written. CNTRLPLANE-3612 Co-Authored-By: Claude Opus 4.6 --- .../controllers/local_ignitionprovider.go | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/ignition-server/controllers/local_ignitionprovider.go b/ignition-server/controllers/local_ignitionprovider.go index dcc8366454ff..4da559eba310 100644 --- a/ignition-server/controllers/local_ignitionprovider.go +++ b/ignition-server/controllers/local_ignitionprovider.go @@ -390,7 +390,7 @@ func (p *LocalIgnitionProvider) runFeatureGateRender(ctx context.Context, binDir return nil } -func (p *LocalIgnitionProvider) runMCO(ctx context.Context, dirs *payloadDirs, releaseImage string, pullSecret []byte, mcsConfig *corev1.ConfigMap, imageProvider *imageprovider.SimpleReleaseImageProvider, payloadVersion semver.Version) error { +func (p *LocalIgnitionProvider) runMCO(ctx context.Context, dirs *payloadDirs, releaseImage string, pullSecret []byte, mcsConfig *corev1.ConfigMap, imageProvider *imageprovider.SimpleReleaseImageProvider, payloadVersion semver.Version, osStream string) error { log := ctrl.Log.WithName("get-payload") destDir := dirs.mcoDir if err := os.MkdirAll(destDir, 0755); err != nil { @@ -454,7 +454,12 @@ func (p *LocalIgnitionProvider) runMCO(ctx context.Context, dirs *payloadDirs, r if err := p.copyMCOOutputToMCC(destDir, dirs.mccDir, dirs.configDir); err != nil { return err } - return nil + + // Write the OSImageStream CR for dual-stream support. + // This must happen after copyMCOOutputToMCC and before runMCC + // so that MCC bootstrap discovers the available streams and renders MachineConfigs + // with the correct osImageURL. No-op when osStream is empty. + return writeOSImageStreamCR(dirs.mccDir, osStream) } func (p *LocalIgnitionProvider) copyMCOOutputToMCC(destDir, mccDir, configDir string) error { @@ -607,7 +612,7 @@ func (p *LocalIgnitionProvider) runMCSAndFetchPayload(ctx context.Context, dirs return payload, err } -func (p *LocalIgnitionProvider) GetPayload(ctx context.Context, releaseImage, customConfig, pullSecretHash, additionalTrustBundleHash, hcConfigurationHash string) ([]byte, error) { +func (p *LocalIgnitionProvider) GetPayload(ctx context.Context, releaseImage, customConfig, pullSecretHash, additionalTrustBundleHash, hcConfigurationHash, osStream string) ([]byte, error) { p.lock.Lock() defer p.lock.Unlock() @@ -737,7 +742,8 @@ func (p *LocalIgnitionProvider) GetPayload(ctx context.Context, releaseImage, cu } // First, run the MCO using templates and image refs as input. This generates output for the MCC. - if err := p.runMCO(ctx, dirs, releaseImage, pullSecret, mcsConfig, imageProvider, payloadVersion); err != nil { + // Also writes the OSImageStream CR for dual-stream support when osStream is non-empty. + if err := p.runMCO(ctx, dirs, releaseImage, pullSecret, mcsConfig, imageProvider, payloadVersion, osStream); err != nil { return nil, fmt.Errorf("failed to execute machine-config-operator: %w", err) } @@ -788,6 +794,30 @@ func (r *LocalIgnitionProvider) reconcileValidReleaseInfoCondition(ctx context.C return r.Client.Status().Patch(ctx, &hostedControlPlane, client.MergeFromWithOptions(originalHCP, client.MergeFromWithOptimisticLock{})) } +// writeOSImageStreamCR writes an OSImageStream custom resource manifest to the MCC input directory. +// The MCC bootstrap process reads this manifest to discover the desired OS stream and renders +// MachineConfigs with the correct osImageURL. The file is written as 99_osimagestream.yaml +// to ensure it is processed after other manifests. +// When osStream is empty, this is a no-op (legacy behavior, no OSImageStream CR generated). +func writeOSImageStreamCR(mccDir, osStream string) error { + if osStream == "" { + return nil + } + osImageStreamCR := fmt.Sprintf(`apiVersion: machineconfiguration.openshift.io/v1alpha1 +kind: OSImageStream +metadata: + name: cluster +spec: + defaultStream: %q +`, osStream) + + dest := filepath.Join(mccDir, "99_osimagestream.yaml") + if err := os.WriteFile(dest, []byte(osImageStreamCR), 0644); err != nil { + return fmt.Errorf("failed to write OSImageStream manifest to %s: %w", dest, err) + } + return nil +} + // copyFile copies a file named src to dst, preserving attributes. func copyFile(src, dst string) error { srcfd, err := os.Open(src) From 693d06ede9e4e5e140e77a7ebdc7d3af638d7d41 Mon Sep 17 00:00:00 2001 From: OpenShift CI Bot Date: Mon, 15 Jun 2026 09:04:14 +0000 Subject: [PATCH 4/9] feat(nodepool): add OS stream validation and status reporting Extends validMachineConfigCondition to validate the OS stream selection before proceeding with token creation. This catches invalid configurations early (e.g., rhel-10 on pre-5.0 releases, runc + rhel-10) and surfaces them as NodePool conditions. Adds OS stream inference from CAPI Machine NodeInfo: RHCOS version strings starting with "4" indicate RHEL 9, while versions starting with "5" indicate RHEL 10. The inferred stream is written to status.osImageStream to give users visibility into which RHEL version their nodes are actually running. CNTRLPLANE-3612 Co-Authored-By: Claude Opus 4.6 --- .../controllers/nodepool/conditions.go | 37 +++++++++- .../controllers/nodepool/version.go | 67 ++++++++++++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/hypershift-operator/controllers/nodepool/conditions.go b/hypershift-operator/controllers/nodepool/conditions.go index cff009e3590e..91c8acf5a69f 100644 --- a/hypershift-operator/controllers/nodepool/conditions.go +++ b/hypershift-operator/controllers/nodepool/conditions.go @@ -374,7 +374,7 @@ func (r *NodePoolReconciler) validMachineConfigCondition(ctx context.Context, no } controlPlaneNamespace := manifests.HostedControlPlaneNamespace(hcluster.Namespace, hcluster.Name) - _, err = NewConfigGenerator(ctx, r.Client, hcluster, nodePool, releaseImage, haproxyRawConfig, controlPlaneNamespace) + cg, err := NewConfigGenerator(ctx, r.Client, hcluster, nodePool, releaseImage, haproxyRawConfig, controlPlaneNamespace) if err != nil { SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ Type: hyperv1.NodePoolValidMachineConfigConditionType, @@ -385,6 +385,41 @@ func (r *NodePoolReconciler) validMachineConfigCondition(ctx context.Context, no }) return &ctrl.Result{}, fmt.Errorf("failed to generate config: %w", err) } + + // Validate OS stream selection before proceeding with token creation. + // This catches errors like rhel-10 on pre-5.0 releases or runc+rhel-10 early. + releaseVersion, parseErr := semver.Parse(releaseImage.Version()) + if parseErr != nil { + SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ + Type: hyperv1.NodePoolValidMachineConfigConditionType, + Status: corev1.ConditionFalse, + Reason: hyperv1.NodePoolValidationFailedReason, + Message: fmt.Sprintf("failed to parse release version: %v", parseErr), + ObservedGeneration: nodePool.Generation, + }) + return &ctrl.Result{}, fmt.Errorf("failed to parse release version: %w", parseErr) + } + + resolvedStream, streamErr := getRHELStream(nodePool.Spec.OSImageStream.Name, releaseVersion, cg.usesRunc) + if streamErr != nil { + SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ + Type: hyperv1.NodePoolValidMachineConfigConditionType, + Status: corev1.ConditionFalse, + Reason: hyperv1.NodePoolValidationFailedReason, + Message: fmt.Sprintf("invalid OS stream configuration: %v", streamErr), + ObservedGeneration: nodePool.Generation, + }) + // Return Result to short-circuit — an update event will trigger reconciliation + // when the user fixes their NodePool spec. + log.Error(streamErr, "OS stream validation failed") + return &ctrl.Result{}, nil + } + + // If the stream was implicitly resolved to rhel-9 due to runc usage, set an informational condition. + if nodePool.Spec.OSImageStream.Name == "" && resolvedStream == RHELStreamRHEL9 && cg.usesRunc { + log.Info("NodePool uses runc ContainerRuntimeConfig, OS stream defaulted to rhel-9") + } + SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ Type: hyperv1.NodePoolValidMachineConfigConditionType, Status: corev1.ConditionTrue, diff --git a/hypershift-operator/controllers/nodepool/version.go b/hypershift-operator/controllers/nodepool/version.go index d63ca3417daa..090a29c24604 100644 --- a/hypershift-operator/controllers/nodepool/version.go +++ b/hypershift-operator/controllers/nodepool/version.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sort" + "strings" hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" @@ -86,7 +87,7 @@ func (r *NodePoolReconciler) nodeVersionsFromMachines(_ context.Context, machine } // setNodesInfoStatus aggregates node version and health information from CAPI Machines -// and sets it on nodePool.Status.NodesInfo. +// and sets it on nodePool.Status.NodesInfo and infers the OS stream for status.osImageStream. func (r *NodePoolReconciler) setNodesInfoStatus(ctx context.Context, nodePool *hyperv1.NodePool) error { machines, err := r.getMachinesForNodePool(ctx, nodePool) if err != nil { @@ -98,5 +99,69 @@ func (r *NodePoolReconciler) setNodesInfoStatus(ctx context.Context, nodePool *h NodeVersions: nodeVersions, } + // Infer OS stream from node OSImage and set status.osImageStream. + if stream := inferOSStreamFromMachines(machines); stream != "" { + nodePool.Status.OSImageStream = hyperv1.OSImageStreamReference{ + Name: stream, + } + } + return nil } + +// inferOSStreamFromMachines inspects CAPI Machine NodeInfo to determine the RHEL OS stream. +// RHCOS version strings starting with "4" (e.g., "4xx.x.x") indicate RHEL 9, while +// version strings starting with "5" (e.g., "5xx.x.x") indicate RHEL 10. +// Returns the stream observed on the majority of nodes, or empty if no determination can be made. +func inferOSStreamFromMachines(machines []*capiv1.Machine) string { + rhel9Count := 0 + rhel10Count := 0 + + for _, machine := range machines { + if machine.Status.NodeInfo == nil { + continue + } + osImage := machine.Status.NodeInfo.OSImage + if osImage == "" { + continue + } + + // RHCOS versions follow the pattern "Red Hat Enterprise Linux CoreOS 4xx.yy.zzzz..." + // or similar. The RHCOS major version maps to the RHEL stream. + stream := inferStreamFromOSImage(osImage) + switch stream { + case RHELStreamRHEL9: + rhel9Count++ + case RHELStreamRHEL10: + rhel10Count++ + } + } + + if rhel10Count > 0 && rhel10Count >= rhel9Count { + return RHELStreamRHEL10 + } + if rhel9Count > 0 { + return RHELStreamRHEL9 + } + return "" +} + +// inferStreamFromOSImage extracts the RHEL stream from a node's OSImage string. +// RHCOS versions starting with "4" map to RHEL 9, versions starting with "5" map to RHEL 10. +func inferStreamFromOSImage(osImage string) string { + // Expected format: "Red Hat Enterprise Linux CoreOS 417.94.202501011234-0" or similar + // Look for the version number part + parts := strings.Fields(osImage) + for _, part := range parts { + if len(part) >= 3 && part[0] >= '0' && part[0] <= '9' { + // This looks like a version number + if strings.HasPrefix(part, "4") { + return RHELStreamRHEL9 + } + if strings.HasPrefix(part, "5") { + return RHELStreamRHEL10 + } + } + } + return "" +} From 95537134487a8b1af8723d7a6542181e07ab7ecf Mon Sep 17 00:00:00 2001 From: OpenShift CI Bot Date: Mon, 15 Jun 2026 09:04:23 +0000 Subject: [PATCH 5/9] test: add unit tests for dual-stream RHEL support Covers: - getRHELStream(): all resolution paths including defaults, explicit selections, runc fallback, and error cases - inferStreamFromOSImage(): RHCOS version string parsing for RHEL 9/10 - inferOSStreamFromMachines(): aggregation across multiple CAPI Machines - writeOSImageStreamCR(): OSImageStream manifest generation - fakeIgnitionProvider: updated to match new GetPayload signature CNTRLPLANE-3612 Co-Authored-By: Claude Opus 4.6 --- .../controllers/nodepool/rhel_stream_test.go | 113 ++++++++++++++++++ .../controllers/nodepool/version_test.go | 83 +++++++++++++ .../local_ignitionprovider_test.go | 37 ++++++ .../tokensecret_controller_test.go | 2 +- 4 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 hypershift-operator/controllers/nodepool/rhel_stream_test.go diff --git a/hypershift-operator/controllers/nodepool/rhel_stream_test.go b/hypershift-operator/controllers/nodepool/rhel_stream_test.go new file mode 100644 index 000000000000..8427018ea9a1 --- /dev/null +++ b/hypershift-operator/controllers/nodepool/rhel_stream_test.go @@ -0,0 +1,113 @@ +package nodepool + +import ( + "testing" + + "github.com/blang/semver" +) + +func TestGetRHELStream(t *testing.T) { + v4_17 := semver.MustParse("4.17.0") + v5_0 := semver.MustParse("5.0.0") + v5_1 := semver.MustParse("5.1.0") + + tests := []struct { + name string + specStream string + releaseVersion semver.Version + usesRunc bool + want string + wantErr bool + }{ + { + name: "When no stream is specified and release is pre-5.0 it should return empty string", + specStream: "", + releaseVersion: v4_17, + usesRunc: false, + want: "", + }, + { + name: "When no stream is specified and release is 5.0 it should default to rhel-10", + specStream: "", + releaseVersion: v5_0, + usesRunc: false, + want: RHELStreamRHEL10, + }, + { + name: "When no stream is specified and release is 5.1 it should default to rhel-10", + specStream: "", + releaseVersion: v5_1, + usesRunc: false, + want: RHELStreamRHEL10, + }, + { + name: "When no stream is specified and release is 5.0 with runc it should fall back to rhel-9", + specStream: "", + releaseVersion: v5_0, + usesRunc: true, + want: RHELStreamRHEL9, + }, + { + name: "When rhel-9 is explicitly specified and release is pre-5.0 it should return rhel-9", + specStream: RHELStreamRHEL9, + releaseVersion: v4_17, + usesRunc: false, + want: RHELStreamRHEL9, + }, + { + name: "When rhel-9 is explicitly specified and release is 5.0 it should return rhel-9", + specStream: RHELStreamRHEL9, + releaseVersion: v5_0, + usesRunc: false, + want: RHELStreamRHEL9, + }, + { + name: "When rhel-9 is explicitly specified with runc it should return rhel-9", + specStream: RHELStreamRHEL9, + releaseVersion: v5_0, + usesRunc: true, + want: RHELStreamRHEL9, + }, + { + name: "When rhel-10 is explicitly specified and release is 5.0 it should return rhel-10", + specStream: RHELStreamRHEL10, + releaseVersion: v5_0, + usesRunc: false, + want: RHELStreamRHEL10, + }, + { + name: "When rhel-10 is specified and release is pre-5.0 it should return an error", + specStream: RHELStreamRHEL10, + releaseVersion: v4_17, + usesRunc: false, + wantErr: true, + }, + { + name: "When rhel-10 is specified with runc it should return an error", + specStream: RHELStreamRHEL10, + releaseVersion: v5_0, + usesRunc: true, + wantErr: true, + }, + { + name: "When an unsupported stream is specified it should return an error", + specStream: "rhel-8", + releaseVersion: v5_0, + usesRunc: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getRHELStream(tt.specStream, tt.releaseVersion, tt.usesRunc) + if (err != nil) != tt.wantErr { + t.Errorf("getRHELStream() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("getRHELStream() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/hypershift-operator/controllers/nodepool/version_test.go b/hypershift-operator/controllers/nodepool/version_test.go index 581ad975c5ab..f86455e9609b 100644 --- a/hypershift-operator/controllers/nodepool/version_test.go +++ b/hypershift-operator/controllers/nodepool/version_test.go @@ -325,3 +325,86 @@ func machineWithVersionAndConditions(name, kubeletVersion string, conditions v1b }, } } + +func TestInferStreamFromOSImage(t *testing.T) { + tests := []struct { + name string + osImage string + want string + }{ + { + name: "When OSImage contains RHCOS 4.x version it should return rhel-9", + osImage: "Red Hat Enterprise Linux CoreOS 417.94.202501011234-0", + want: RHELStreamRHEL9, + }, + { + name: "When OSImage contains RHCOS 5.x version it should return rhel-10", + osImage: "Red Hat Enterprise Linux CoreOS 500.94.202506011234-0", + want: RHELStreamRHEL10, + }, + { + name: "When OSImage is empty it should return empty", + osImage: "", + want: "", + }, + { + name: "When OSImage has no version number it should return empty", + osImage: "Unknown OS", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := inferStreamFromOSImage(tt.osImage) + if got != tt.want { + t.Errorf("inferStreamFromOSImage(%q) = %v, want %v", tt.osImage, got, tt.want) + } + }) + } +} + +func TestInferOSStreamFromMachines(t *testing.T) { + tests := []struct { + name string + machines []*v1beta1.Machine + want string + }{ + { + name: "When there are no machines it should return empty", + machines: nil, + want: "", + }, + { + name: "When all machines run RHEL 9 it should return rhel-9", + machines: []*v1beta1.Machine{ + {Status: v1beta1.MachineStatus{NodeInfo: &corev1.NodeSystemInfo{OSImage: "Red Hat Enterprise Linux CoreOS 417.94.202501011234-0"}}}, + {Status: v1beta1.MachineStatus{NodeInfo: &corev1.NodeSystemInfo{OSImage: "Red Hat Enterprise Linux CoreOS 418.94.202503011234-0"}}}, + }, + want: RHELStreamRHEL9, + }, + { + name: "When all machines run RHEL 10 it should return rhel-10", + machines: []*v1beta1.Machine{ + {Status: v1beta1.MachineStatus{NodeInfo: &corev1.NodeSystemInfo{OSImage: "Red Hat Enterprise Linux CoreOS 500.94.202506011234-0"}}}, + }, + want: RHELStreamRHEL10, + }, + { + name: "When machines have no NodeInfo it should return empty", + machines: []*v1beta1.Machine{ + {Status: v1beta1.MachineStatus{}}, + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := inferOSStreamFromMachines(tt.machines) + if got != tt.want { + t.Errorf("inferOSStreamFromMachines() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/ignition-server/controllers/local_ignitionprovider_test.go b/ignition-server/controllers/local_ignitionprovider_test.go index a6d083f0b88d..2101b9fc40a8 100644 --- a/ignition-server/controllers/local_ignitionprovider_test.go +++ b/ignition-server/controllers/local_ignitionprovider_test.go @@ -1388,3 +1388,40 @@ func TestCopyMCOOutputToMCCMixedContent(t *testing.T) { g.Expect(os.IsNotExist(err)).To(BeTrue()) }) } + +func TestWriteOSImageStreamCR(t *testing.T) { + tests := []struct { + name string + osStream string + wantErr bool + }{ + { + name: "When osStream is rhel-9 it should write the CR with defaultStream rhel-9", + osStream: "rhel-9", + }, + { + name: "When osStream is rhel-10 it should write the CR with defaultStream rhel-10", + osStream: "rhel-10", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewGomegaWithT(t) + mccDir := t.TempDir() + + err := writeOSImageStreamCR(mccDir, tt.osStream) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).ToNot(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(mccDir, "99_osimagestream.yaml")) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(string(content)).To(ContainSubstring("kind: OSImageStream")) + g.Expect(string(content)).To(ContainSubstring("name: cluster")) + g.Expect(string(content)).To(ContainSubstring(fmt.Sprintf("defaultStream: %q", tt.osStream))) + }) + } +} diff --git a/ignition-server/controllers/tokensecret_controller_test.go b/ignition-server/controllers/tokensecret_controller_test.go index 1ced8c9bc476..3b106a273060 100644 --- a/ignition-server/controllers/tokensecret_controller_test.go +++ b/ignition-server/controllers/tokensecret_controller_test.go @@ -30,7 +30,7 @@ var ( type fakeIgnitionProvider struct{} -func (p *fakeIgnitionProvider) GetPayload(ctx context.Context, releaseImage, config, pullSecretHash, additionalTrustBundleHash, hcConfigurationHash string) (payload []byte, err error) { +func (p *fakeIgnitionProvider) GetPayload(ctx context.Context, releaseImage, config, pullSecretHash, additionalTrustBundleHash, hcConfigurationHash, osStream string) (payload []byte, err error) { return []byte(fakePayload), nil } From 93f8b05a8eabd68646db8680aaed17b9e912efcf Mon Sep 17 00:00:00 2001 From: OpenShift CI Bot Date: Mon, 15 Jun 2026 09:35:14 +0000 Subject: [PATCH 6/9] fix(nodepool): address code review findings for dual-stream RHEL support - Add rhelStream to HashWithoutVersion() so pure OS stream changes trigger config update detection and CAPI reconciliation - Fix %q to %s in writeOSImageStreamCR() to produce idiomatic YAML - Deduplicate TokenSecretOSStreamKey constant (define in ignserver only) - Make error handling consistent in validMachineConfigCondition (both version parse and stream validation now return Result{}, nil) - Surface implicit rhel-9 fallback as a status condition message - Anchor inferStreamFromOSImage() pattern to require dot-separated version format, reducing false-positive risk - Fix tie-break doc comment to say "plurality" instead of "majority" - Fix inconsistent NewGomegaWithT usage in ignition-server tests - Add tests: rhelStream in Hash/HashWithoutVersion, usesRunc detection, TokenSecretOSStreamKey in token secret, empty-string no-op for writeOSImageStreamCR, mixed-stream tie-break for inferOSStreamFromMachines Co-Authored-By: Claude Opus 4.6 --- .../controllers/nodepool/conditions.go | 27 +++-- .../controllers/nodepool/config.go | 2 +- .../controllers/nodepool/config_test.go | 112 ++++++++++++++++++ .../controllers/nodepool/token.go | 4 +- .../controllers/nodepool/token_test.go | 63 ++++++++++ .../controllers/nodepool/version.go | 31 +++-- .../controllers/nodepool/version_test.go | 17 +++ .../controllers/local_ignitionprovider.go | 2 +- .../local_ignitionprovider_test.go | 16 ++- 9 files changed, 248 insertions(+), 26 deletions(-) diff --git a/hypershift-operator/controllers/nodepool/conditions.go b/hypershift-operator/controllers/nodepool/conditions.go index 91c8acf5a69f..e42439d4875c 100644 --- a/hypershift-operator/controllers/nodepool/conditions.go +++ b/hypershift-operator/controllers/nodepool/conditions.go @@ -397,7 +397,8 @@ func (r *NodePoolReconciler) validMachineConfigCondition(ctx context.Context, no Message: fmt.Sprintf("failed to parse release version: %v", parseErr), ObservedGeneration: nodePool.Generation, }) - return &ctrl.Result{}, fmt.Errorf("failed to parse release version: %w", parseErr) + log.Error(parseErr, "failed to parse release version") + return &ctrl.Result{}, nil } resolvedStream, streamErr := getRHELStream(nodePool.Spec.OSImageStream.Name, releaseVersion, cg.usesRunc) @@ -415,18 +416,24 @@ func (r *NodePoolReconciler) validMachineConfigCondition(ctx context.Context, no return &ctrl.Result{}, nil } - // If the stream was implicitly resolved to rhel-9 due to runc usage, set an informational condition. + // If the stream was implicitly resolved to rhel-9 due to runc usage, surface as a condition. if nodePool.Spec.OSImageStream.Name == "" && resolvedStream == RHELStreamRHEL9 && cg.usesRunc { - log.Info("NodePool uses runc ContainerRuntimeConfig, OS stream defaulted to rhel-9") + SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ + Type: hyperv1.NodePoolValidMachineConfigConditionType, + Status: corev1.ConditionTrue, + Reason: hyperv1.AsExpectedReason, + Message: "OS stream defaulted to rhel-9: NodePool uses runc ContainerRuntimeConfig which is not supported on rhel-10", + ObservedGeneration: nodePool.Generation, + }) + } else { + SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ + Type: hyperv1.NodePoolValidMachineConfigConditionType, + Status: corev1.ConditionTrue, + Reason: hyperv1.AsExpectedReason, + ObservedGeneration: nodePool.Generation, + }) } - SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ - Type: hyperv1.NodePoolValidMachineConfigConditionType, - Status: corev1.ConditionTrue, - Reason: hyperv1.AsExpectedReason, - ObservedGeneration: nodePool.Generation, - }) - return nil, nil } diff --git a/hypershift-operator/controllers/nodepool/config.go b/hypershift-operator/controllers/nodepool/config.go index ba4729db1c7f..f02d4253d0fa 100644 --- a/hypershift-operator/controllers/nodepool/config.go +++ b/hypershift-operator/controllers/nodepool/config.go @@ -143,7 +143,7 @@ func (cg *ConfigGenerator) Hash() string { // TODO(alberto): This was left inconsistent in https://github.com/openshift/hypershift/pull/3795/files. It should also contain cg.globalConfig. // This is kept like this for now to contain the scope of the refactor and avoid backward compatibility issues. func (cg *ConfigGenerator) HashWithoutVersion() string { - return supportutil.HashSimple(cg.mcoRawConfig + cg.pullSecretName + cg.additionalTrustBundleName) + return supportutil.HashSimple(cg.mcoRawConfig + cg.pullSecretName + cg.additionalTrustBundleName + cg.rhelStream) } func (cg *ConfigGenerator) Version() string { diff --git a/hypershift-operator/controllers/nodepool/config_test.go b/hypershift-operator/controllers/nodepool/config_test.go index ef9096ab218c..2714f744a251 100644 --- a/hypershift-operator/controllers/nodepool/config_test.go +++ b/hypershift-operator/controllers/nodepool/config_test.go @@ -520,6 +520,39 @@ func TestHash(t *testing.T) { } }) } + + t.Run("When rhelStream differs it should change the hash", func(t *testing.T) { + g := NewWithT(t) + releaseImage := &releaseinfo.ReleaseImage{ + ImageStream: &imageapi.ImageStream{ + ObjectMeta: metav1.ObjectMeta{ + Name: baseCaseReleaseVersion, + }, + }, + } + cgBase := &ConfigGenerator{ + rolloutConfig: &rolloutConfig{ + mcoRawConfig: baseCaseMCORawConfig, + pullSecretName: baseCasePullSecretName, + additionalTrustBundleName: baseCaseAdditionalTrustBundleName, + globalConfig: baseCaseGlobalConfig, + rhelStream: "", + releaseImage: releaseImage, + }, + } + cgWithStream := &ConfigGenerator{ + rolloutConfig: &rolloutConfig{ + mcoRawConfig: baseCaseMCORawConfig, + pullSecretName: baseCasePullSecretName, + additionalTrustBundleName: baseCaseAdditionalTrustBundleName, + globalConfig: baseCaseGlobalConfig, + rhelStream: "rhel-9", + releaseImage: releaseImage, + }, + } + g.Expect(cgBase.Hash()).To(Equal(baseCaseHash)) + g.Expect(cgWithStream.Hash()).ToNot(Equal(baseCaseHash)) + }) } func TestHashWithoutVersion(t *testing.T) { @@ -621,6 +654,39 @@ func TestHashWithoutVersion(t *testing.T) { g.Expect(hash).To(Equal(tc.expected)) }) } + + t.Run("When rhelStream differs it should change the hash", func(t *testing.T) { + g := NewWithT(t) + releaseImage := &releaseinfo.ReleaseImage{ + ImageStream: &imageapi.ImageStream{ + ObjectMeta: metav1.ObjectMeta{ + Name: baseCaseReleaseVersion, + }, + }, + } + cgBase := &ConfigGenerator{ + rolloutConfig: &rolloutConfig{ + mcoRawConfig: baseCaseMCORawConfig, + pullSecretName: baseCasePullSecretName, + additionalTrustBundleName: baseCaseAdditionalTrustBundleName, + globalConfig: baseCaseGlobalConfig, + rhelStream: "", + releaseImage: releaseImage, + }, + } + cgWithStream := &ConfigGenerator{ + rolloutConfig: &rolloutConfig{ + mcoRawConfig: baseCaseMCORawConfig, + pullSecretName: baseCasePullSecretName, + additionalTrustBundleName: baseCaseAdditionalTrustBundleName, + globalConfig: baseCaseGlobalConfig, + rhelStream: "rhel-9", + releaseImage: releaseImage, + }, + } + g.Expect(cgBase.HashWithoutVersion()).To(Equal(baseCaseHash)) + g.Expect(cgWithStream.HashWithoutVersion()).ToNot(Equal(baseCaseHash)) + }) } func TestGenerateMCORawConfig(t *testing.T) { @@ -1452,6 +1518,52 @@ status: } } +func TestUsesRuncDetection(t *testing.T) { + containerRuntimeConfigRunc := `apiVersion: machineconfiguration.openshift.io/v1 +kind: ContainerRuntimeConfig +metadata: + name: set-runc +spec: + containerRuntimeConfig: + defaultRuntime: runc +` + containerRuntimeConfigCrun := `apiVersion: machineconfiguration.openshift.io/v1 +kind: ContainerRuntimeConfig +metadata: + name: set-crun +spec: + containerRuntimeConfig: + defaultRuntime: crun +` + + testCases := []struct { + name string + manifest string + expectRunc bool + }{ + { + name: "When ContainerRuntimeConfig sets defaultRuntime to runc it should set usesRunc to true", + manifest: containerRuntimeConfigRunc, + expectRunc: true, + }, + { + name: "When ContainerRuntimeConfig sets defaultRuntime to crun it should not set usesRunc", + manifest: containerRuntimeConfigCrun, + expectRunc: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + cg := &ConfigGenerator{} + _, err := cg.defaultAndValidateConfigManifest([]byte(tc.manifest)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cg.usesRunc).To(Equal(tc.expectRunc)) + }) + } +} + func TestMissingCoreConfigError(t *testing.T) { g := NewWithT(t) err := &MissingCoreConfigError{ diff --git a/hypershift-operator/controllers/nodepool/token.go b/hypershift-operator/controllers/nodepool/token.go index ad376dbcf969..d9814ed2be31 100644 --- a/hypershift-operator/controllers/nodepool/token.go +++ b/hypershift-operator/controllers/nodepool/token.go @@ -9,6 +9,7 @@ import ( hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" "github.com/openshift/hypershift/hypershift-operator/controllers/manifests/ignitionserver" + ignserver "github.com/openshift/hypershift/ignition-server/controllers" "github.com/openshift/hypershift/support/backwardcompat" "github.com/openshift/hypershift/support/globalconfig" "github.com/openshift/hypershift/support/k8sutil" @@ -43,7 +44,6 @@ const ( TokenSecretHCConfigurationHashKey = "hc-configuration-hash" TokenSecretAdditionalTrustBundleKey = "additional-trust-bundle-hash" TokenSecretConfigKey = "config" - TokenSecretOSStreamKey = "os-stream" TokenSecretAnnotation = "hypershift.openshift.io/ignition-config" TokenSecretIgnitionReachedAnnotation = "hypershift.openshift.io/ignition-reached" TokenSecretNodePoolUpgradeType = "hypershift.openshift.io/node-pool-upgrade-type" @@ -373,7 +373,7 @@ func (t *Token) reconcileTokenSecret(tokenSecret *corev1.Secret) error { // Store the resolved OS stream so the ignition server can generate the OSImageStream CR. if t.osStream != "" { - tokenSecret.Data[TokenSecretOSStreamKey] = []byte(t.osStream) + tokenSecret.Data[ignserver.TokenSecretOSStreamKey] = []byte(t.osStream) } } // TODO (alberto): Only apply this on creation and change the hash generation to only use triggering upgrade fields. diff --git a/hypershift-operator/controllers/nodepool/token_test.go b/hypershift-operator/controllers/nodepool/token_test.go index 5266d08331cb..9da9a118e22e 100644 --- a/hypershift-operator/controllers/nodepool/token_test.go +++ b/hypershift-operator/controllers/nodepool/token_test.go @@ -11,6 +11,7 @@ import ( hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" "github.com/openshift/hypershift/hypershift-operator/controllers/manifests/ignitionserver" + ignserver "github.com/openshift/hypershift/ignition-server/controllers" "github.com/openshift/hypershift/support/globalconfig" karpenterutil "github.com/openshift/hypershift/support/karpenter" "github.com/openshift/hypershift/support/releaseinfo" @@ -827,6 +828,68 @@ func TestTokenReconcile(t *testing.T) { } } +func TestReconcileTokenSecretOSStreamKey(t *testing.T) { + testCases := []struct { + name string + osStream string + expectOSStreamIn bool + }{ + { + name: "When osStream is non-empty it should write os-stream key to token secret data", + osStream: "rhel-10", + expectOSStreamIn: true, + }, + { + name: "When osStream is empty it should not write os-stream key to token secret data", + osStream: "", + expectOSStreamIn: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + token := &Token{ + ConfigGenerator: &ConfigGenerator{ + hostedCluster: &hyperv1.HostedCluster{}, + nodePool: &hyperv1.NodePool{ + Spec: hyperv1.NodePoolSpec{ + Management: hyperv1.NodePoolManagement{ + UpgradeType: hyperv1.UpgradeTypeReplace, + }, + Release: hyperv1.Release{Image: "image:5.0"}, + }, + }, + rolloutConfig: &rolloutConfig{ + releaseImage: &releaseinfo.ReleaseImage{ + ImageStream: &imageapi.ImageStream{ + ObjectMeta: metav1.ObjectMeta{Name: "5.0.0"}, + }, + }, + mcoRawConfig: "raw-config", + }, + }, + cpoCapabilities: &CPOCapabilities{DecompressAndDecodeConfig: true}, + osStream: tc.osStream, + } + + tokenSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-token"}, + } + err := token.reconcileTokenSecret(tokenSecret) + g.Expect(err).ToNot(HaveOccurred()) + + if tc.expectOSStreamIn { + g.Expect(tokenSecret.Data).To(HaveKey(ignserver.TokenSecretOSStreamKey)) + g.Expect(string(tokenSecret.Data[ignserver.TokenSecretOSStreamKey])).To(Equal(tc.osStream)) + } else { + g.Expect(tokenSecret.Data).ToNot(HaveKey(ignserver.TokenSecretOSStreamKey)) + } + }) + } +} + func TestTokenUserDataSecret(t *testing.T) { testCases := []struct { name string diff --git a/hypershift-operator/controllers/nodepool/version.go b/hypershift-operator/controllers/nodepool/version.go index 090a29c24604..568acc2c462f 100644 --- a/hypershift-operator/controllers/nodepool/version.go +++ b/hypershift-operator/controllers/nodepool/version.go @@ -112,7 +112,7 @@ func (r *NodePoolReconciler) setNodesInfoStatus(ctx context.Context, nodePool *h // inferOSStreamFromMachines inspects CAPI Machine NodeInfo to determine the RHEL OS stream. // RHCOS version strings starting with "4" (e.g., "4xx.x.x") indicate RHEL 9, while // version strings starting with "5" (e.g., "5xx.x.x") indicate RHEL 10. -// Returns the stream observed on the majority of nodes, or empty if no determination can be made. +// Returns the stream observed on the plurality of nodes (RHEL 10 wins ties), or empty if no determination can be made. func inferOSStreamFromMachines(machines []*capiv1.Machine) string { rhel9Count := 0 rhel10Count := 0 @@ -149,19 +149,30 @@ func inferOSStreamFromMachines(machines []*capiv1.Machine) string { // inferStreamFromOSImage extracts the RHEL stream from a node's OSImage string. // RHCOS versions starting with "4" map to RHEL 9, versions starting with "5" map to RHEL 10. func inferStreamFromOSImage(osImage string) string { - // Expected format: "Red Hat Enterprise Linux CoreOS 417.94.202501011234-0" or similar - // Look for the version number part + // Expected format: "Red Hat Enterprise Linux CoreOS 417.94.202501011234-0" or similar. + // Match tokens that look like a RHCOS version: 3+ digits, a dot, then more digits (e.g. "417.94"). parts := strings.Fields(osImage) for _, part := range parts { - if len(part) >= 3 && part[0] >= '0' && part[0] <= '9' { - // This looks like a version number - if strings.HasPrefix(part, "4") { - return RHELStreamRHEL9 - } - if strings.HasPrefix(part, "5") { - return RHELStreamRHEL10 + dotIdx := strings.IndexByte(part, '.') + if dotIdx < 3 || dotIdx >= len(part)-1 { + continue + } + allDigitsBeforeDot := true + for i := 0; i < dotIdx; i++ { + if part[i] < '0' || part[i] > '9' { + allDigitsBeforeDot = false + break } } + if !allDigitsBeforeDot { + continue + } + if part[0] == '4' { + return RHELStreamRHEL9 + } + if part[0] == '5' { + return RHELStreamRHEL10 + } } return "" } diff --git a/hypershift-operator/controllers/nodepool/version_test.go b/hypershift-operator/controllers/nodepool/version_test.go index f86455e9609b..b626bed4181b 100644 --- a/hypershift-operator/controllers/nodepool/version_test.go +++ b/hypershift-operator/controllers/nodepool/version_test.go @@ -397,6 +397,23 @@ func TestInferOSStreamFromMachines(t *testing.T) { }, want: "", }, + { + name: "When machines have mixed RHEL 9 and RHEL 10 with equal counts it should return rhel-10 (tie-break)", + machines: []*v1beta1.Machine{ + {Status: v1beta1.MachineStatus{NodeInfo: &corev1.NodeSystemInfo{OSImage: "Red Hat Enterprise Linux CoreOS 417.94.202501011234-0"}}}, + {Status: v1beta1.MachineStatus{NodeInfo: &corev1.NodeSystemInfo{OSImage: "Red Hat Enterprise Linux CoreOS 500.94.202506011234-0"}}}, + }, + want: RHELStreamRHEL10, + }, + { + name: "When RHEL 9 machines outnumber RHEL 10 it should return rhel-9", + machines: []*v1beta1.Machine{ + {Status: v1beta1.MachineStatus{NodeInfo: &corev1.NodeSystemInfo{OSImage: "Red Hat Enterprise Linux CoreOS 417.94.202501011234-0"}}}, + {Status: v1beta1.MachineStatus{NodeInfo: &corev1.NodeSystemInfo{OSImage: "Red Hat Enterprise Linux CoreOS 418.94.202503011234-0"}}}, + {Status: v1beta1.MachineStatus{NodeInfo: &corev1.NodeSystemInfo{OSImage: "Red Hat Enterprise Linux CoreOS 500.94.202506011234-0"}}}, + }, + want: RHELStreamRHEL9, + }, } for _, tt := range tests { diff --git a/ignition-server/controllers/local_ignitionprovider.go b/ignition-server/controllers/local_ignitionprovider.go index 4da559eba310..1b93b38be742 100644 --- a/ignition-server/controllers/local_ignitionprovider.go +++ b/ignition-server/controllers/local_ignitionprovider.go @@ -808,7 +808,7 @@ kind: OSImageStream metadata: name: cluster spec: - defaultStream: %q + defaultStream: %s `, osStream) dest := filepath.Join(mccDir, "99_osimagestream.yaml") diff --git a/ignition-server/controllers/local_ignitionprovider_test.go b/ignition-server/controllers/local_ignitionprovider_test.go index 2101b9fc40a8..637fbaa27daa 100644 --- a/ignition-server/controllers/local_ignitionprovider_test.go +++ b/ignition-server/controllers/local_ignitionprovider_test.go @@ -1394,7 +1394,13 @@ func TestWriteOSImageStreamCR(t *testing.T) { name string osStream string wantErr bool + noop bool }{ + { + name: "When osStream is empty it should be a no-op", + osStream: "", + noop: true, + }, { name: "When osStream is rhel-9 it should write the CR with defaultStream rhel-9", osStream: "rhel-9", @@ -1407,7 +1413,7 @@ func TestWriteOSImageStreamCR(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - g := NewGomegaWithT(t) + g := NewWithT(t) mccDir := t.TempDir() err := writeOSImageStreamCR(mccDir, tt.osStream) @@ -1417,11 +1423,17 @@ func TestWriteOSImageStreamCR(t *testing.T) { } g.Expect(err).ToNot(HaveOccurred()) + if tt.noop { + _, statErr := os.Stat(filepath.Join(mccDir, "99_osimagestream.yaml")) + g.Expect(os.IsNotExist(statErr)).To(BeTrue(), "expected no file to be written for empty osStream") + return + } + content, err := os.ReadFile(filepath.Join(mccDir, "99_osimagestream.yaml")) g.Expect(err).ToNot(HaveOccurred()) g.Expect(string(content)).To(ContainSubstring("kind: OSImageStream")) g.Expect(string(content)).To(ContainSubstring("name: cluster")) - g.Expect(string(content)).To(ContainSubstring(fmt.Sprintf("defaultStream: %q", tt.osStream))) + g.Expect(string(content)).To(ContainSubstring(fmt.Sprintf("defaultStream: %s", tt.osStream))) }) } } From e0f0a0e21932ac838e034354b88baa4303739f82 Mon Sep 17 00:00:00 2001 From: OpenShift CI Bot Date: Mon, 15 Jun 2026 10:26:26 +0000 Subject: [PATCH 7/9] docs: add dual-stream RHEL OS support documentation Document the UX for all dual-stream RHEL permutations including the OS stream resolution matrix, stream transitions, runc compatibility, observability via status fields and conditions, and common upgrade scenarios. Co-Authored-By: Claude Opus 4.6 --- .../dual-stream-rhel.md | 126 ++++++++++++++++++ docs/mkdocs.yml | 1 + 2 files changed, 127 insertions(+) create mode 100644 docs/content/how-to/automated-machine-management/dual-stream-rhel.md diff --git a/docs/content/how-to/automated-machine-management/dual-stream-rhel.md b/docs/content/how-to/automated-machine-management/dual-stream-rhel.md new file mode 100644 index 000000000000..71939341a756 --- /dev/null +++ b/docs/content/how-to/automated-machine-management/dual-stream-rhel.md @@ -0,0 +1,126 @@ +--- +title: Dual-Stream RHEL OS Support +--- + +# Dual-Stream RHEL OS Support + +Starting with OCP 5.0, HyperShift NodePools support dual-stream RHEL OS provisioning. Cluster administrators can choose between **RHEL 9** and **RHEL 10** OS images for worker nodes on a per-NodePool basis using the `spec.osImageStream` field. + +!!! note "Feature Gate" + + This feature requires the `OSStreams` feature gate to be enabled. + +## How It Works + +Each NodePool resolves an OS stream based on three inputs: + +1. **Explicit selection** (`spec.osImageStream.name`): The user can explicitly set `rhel-9` or `rhel-10`. +2. **OCP release version**: The release image version determines which streams are available. +3. **Container runtime**: If a `ContainerRuntimeConfig` using `runc` is attached to the NodePool, RHEL 10 is unavailable because RHEL 10 does not include `runc`. + +When the stream is resolved, the NodePool controller generates an `OSImageStream` custom resource in the ignition payload, which the Machine Config Operator uses to select the correct OS and extension images. + +## OS Stream Resolution Matrix + +The following table describes the resolved OS stream for every combination of user selection, release version, and container runtime: + +| `spec.osImageStream.name` | OCP Version | Uses runc | Resolved Stream | Outcome | +|---|---|---|---|---| +| *(unset)* | < 5.0 | Any | *(none)* | Legacy behavior — default RHCOS images are used. | +| *(unset)* | ≥ 5.0 | No | `rhel-10` | Default for OCP 5.0+. | +| *(unset)* | ≥ 5.0 | Yes | `rhel-9` | Automatic fallback — a condition message surfaces the reason. | +| `rhel-9` | Any | Any | `rhel-9` | Explicit selection, always valid. | +| `rhel-10` | < 5.0 | Any | **Error** | RHEL 10 is not supported on releases before 5.0. | +| `rhel-10` | ≥ 5.0 | Yes | **Error** | RHEL 10 does not support `runc`. | +| `rhel-10` | ≥ 5.0 | No | `rhel-10` | Explicit selection, valid. | + +## Selecting an OS Stream + +To explicitly select an OS stream for a NodePool, set `spec.osImageStream.name`: + +```yaml +apiVersion: hypershift.openshift.io/v1beta1 +kind: NodePool +metadata: + name: my-nodepool + namespace: clusters +spec: + release: + image: quay.io/openshift-release-dev/ocp-release:5.0.0-x86_64 + osImageStream: + name: rhel-10 + # ...other fields +``` + +When omitted, the stream is resolved automatically based on the release version and runtime configuration. + +## Stream Transitions + +Changing `spec.osImageStream.name` triggers a NodePool rollout (Replace or InPlace, depending on `spec.management.upgradeType`). + +**Allowed transitions:** + +- *(unset)* → `rhel-9` or `rhel-10` +- `rhel-9` → `rhel-10` *(forward upgrade)* + +**Blocked transitions:** + +- `rhel-10` → `rhel-9` *(downgrade)* — rejected by API validation. To move back to RHEL 9, create a new NodePool. + +!!! warning + + OS stream downgrades from `rhel-10` to `rhel-9` are not supported because in-place OS major version downgrades are not safe. If you need RHEL 9 nodes after upgrading to RHEL 10, create a new NodePool with `spec.osImageStream.name: rhel-9` and migrate workloads. + +## runc Compatibility + +RHEL 10 removes support for the `runc` container runtime. If a NodePool references a `ContainerRuntimeConfig` that sets `runc` as the default runtime: + +- **Explicit `rhel-10` selection**: The NodePool controller rejects the configuration and sets a `ValidMachineConfig=False` condition with the message: *"OS stream rhel-10 is not compatible with ContainerRuntimeConfig using runc: RHEL 10 does not support runc"*. +- **No explicit stream on OCP ≥ 5.0**: The controller automatically falls back to `rhel-9` and surfaces a condition message: *"OS stream defaulted to rhel-9: NodePool uses runc ContainerRuntimeConfig which is not supported on rhel-10"*. + +To use RHEL 10, remove or update the `ContainerRuntimeConfig` to use `crun` (the default on RHEL 10). + +## Observability + +### Status Fields + +The resolved OS stream observed on running nodes is reported in `status.osImageStream`: + +```yaml +status: + osImageStream: + name: rhel-10 +``` + +This field is inferred from the CAPI Machine `NodeInfo.OSImage` field of registered nodes. It reflects what is actually running, not what was requested in the spec. + +### Conditions + +OS stream validation errors and informational messages appear on the `ValidMachineConfig` condition: + +| Scenario | Condition Status | Message | +|---|---|---| +| Valid configuration | `True` | *(empty or standard)* | +| Invalid stream for release | `False` | `invalid OS stream configuration: OS stream "rhel-10" is not supported for release version 4.x (requires >= 5.0.0)` | +| runc + rhel-10 conflict | `False` | `invalid OS stream configuration: OS stream "rhel-10" is not compatible with ContainerRuntimeConfig using runc` | +| Implicit rhel-9 fallback | `True` | `OS stream defaulted to rhel-9: NodePool uses runc ContainerRuntimeConfig which is not supported on rhel-10` | + +## Common Scenarios + +### Upgrading from OCP 4.x to 5.0 + +When upgrading a HostedCluster from OCP 4.x to 5.0+, NodePools with no explicit `osImageStream` will automatically transition from legacy RHCOS images to `rhel-10` (or `rhel-9` if runc is in use). This transition is handled as part of the normal NodePool rollout triggered by the release image change. + +### Mixed OS Pools in a Cluster + +You can run multiple NodePools with different OS streams in the same HostedCluster. For example: + +- `nodepool-default`: `osImageStream.name: rhel-10` — for general workloads +- `nodepool-legacy`: `osImageStream.name: rhel-9` — for workloads that require runc or RHEL 9 compatibility + +### Migrating from RHEL 9 to RHEL 10 + +1. Verify the HostedCluster is running OCP ≥ 5.0. +2. Ensure no `ContainerRuntimeConfig` with runc is attached to the NodePool. +3. Set `spec.osImageStream.name: rhel-10` on the NodePool. +4. Monitor the rollout via `status.conditions` and `status.osImageStream`. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index f0e3deb02ae1..d69be12bc90a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -78,6 +78,7 @@ nav: - 'Automated Machine Management': - how-to/automated-machine-management/index.md - how-to/automated-machine-management/configure-machines.md + - 'Dual-Stream RHEL OS': how-to/automated-machine-management/dual-stream-rhel.md - how-to/automated-machine-management/performance-profiling.md - how-to/automated-machine-management/node-tuning.md - how-to/automated-machine-management/nodepool-lifecycle.md From dae60ce10fb207f29a12672a5db8840e72144546 Mon Sep 17 00:00:00 2001 From: OpenShift CI Bot Date: Mon, 15 Jun 2026 10:31:39 +0000 Subject: [PATCH 8/9] fix(nodepool): add rolloutConfig to TestNewToken and fix semver in TestTokenReconcile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dual-stream RHEL support added OS stream resolution to NewToken() which requires configGenerator.releaseImage to be set. Three TestNewToken cases (success, missing ignition endpoint, and missing CA cert) reached the new code path without a rolloutConfig, causing a nil pointer dereference on configGenerator.releaseImage.Version(). Additionally, TestTokenReconcile used ImageStream Name "4.17" which is not valid semver — semver.Parse requires Major.Minor.Patch format. Fix by adding rolloutConfig with a valid releaseImage to the affected TestNewToken cases and updating TestTokenReconcile to use "4.17.0". Co-Authored-By: Claude Opus 4.6 --- .../controllers/nodepool/token_test.go | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/hypershift-operator/controllers/nodepool/token_test.go b/hypershift-operator/controllers/nodepool/token_test.go index 9da9a118e22e..df67b6f14d65 100644 --- a/hypershift-operator/controllers/nodepool/token_test.go +++ b/hypershift-operator/controllers/nodepool/token_test.go @@ -102,6 +102,13 @@ func TestNewToken(t *testing.T) { }, nodePool: &hyperv1.NodePool{}, controlplaneNamespace: controlplaneNamespace, + rolloutConfig: &rolloutConfig{ + releaseImage: &releaseinfo.ReleaseImage{ + ImageStream: &imageapi.ImageStream{ + ObjectMeta: metav1.ObjectMeta{Name: "4.17.0"}, + }, + }, + }, }, fakeObjects: []crclient.Object{ pullSecret, @@ -133,6 +140,13 @@ func TestNewToken(t *testing.T) { }, nodePool: &hyperv1.NodePool{}, controlplaneNamespace: controlplaneNamespace, + rolloutConfig: &rolloutConfig{ + releaseImage: &releaseinfo.ReleaseImage{ + ImageStream: &imageapi.ImageStream{ + ObjectMeta: metav1.ObjectMeta{Name: "4.17.0"}, + }, + }, + }, }, fakeObjects: []crclient.Object{ pullSecret, @@ -224,6 +238,13 @@ func TestNewToken(t *testing.T) { }, nodePool: &hyperv1.NodePool{}, controlplaneNamespace: controlplaneNamespace, + rolloutConfig: &rolloutConfig{ + releaseImage: &releaseinfo.ReleaseImage{ + ImageStream: &imageapi.ImageStream{ + ObjectMeta: metav1.ObjectMeta{Name: "4.17.0"}, + }, + }, + }, }, fakeObjects: []crclient.Object{ pullSecret, @@ -615,7 +636,7 @@ func TestTokenReconcile(t *testing.T) { releaseImage: &releaseinfo.ReleaseImage{ ImageStream: &imageapi.ImageStream{ ObjectMeta: metav1.ObjectMeta{ - Name: "4.17", + Name: "4.17.0", }, }, }, @@ -677,7 +698,7 @@ func TestTokenReconcile(t *testing.T) { releaseImage: &releaseinfo.ReleaseImage{ ImageStream: &imageapi.ImageStream{ ObjectMeta: metav1.ObjectMeta{ - Name: "4.17", + Name: "4.17.0", }, }, }, From 677760cbe31544b21a249423880d6d7ff93f4efe Mon Sep 17 00:00:00 2001 From: OpenShift CI Bot Date: Mon, 15 Jun 2026 10:39:44 +0000 Subject: [PATCH 9/9] docs: regenerate aggregated-docs.md Co-Authored-By: Claude Opus 4.6 --- docs/content/reference/aggregated-docs.md | 132 ++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/docs/content/reference/aggregated-docs.md b/docs/content/reference/aggregated-docs.md index d70c1b359e8d..28c9aafad3af 100644 --- a/docs/content/reference/aggregated-docs.md +++ b/docs/content/reference/aggregated-docs.md @@ -5384,6 +5384,138 @@ spec: oc apply -f custom-nodepool.yaml ``` +--- + +## Source: docs/content/how-to/automated-machine-management/dual-stream-rhel.md + +--- +title: Dual-Stream RHEL OS Support +--- + +# Dual-Stream RHEL OS Support + +Starting with OCP 5.0, HyperShift NodePools support dual-stream RHEL OS provisioning. Cluster administrators can choose between **RHEL 9** and **RHEL 10** OS images for worker nodes on a per-NodePool basis using the `spec.osImageStream` field. + +!!! note "Feature Gate" + + This feature requires the `OSStreams` feature gate to be enabled. + +## How It Works + +Each NodePool resolves an OS stream based on three inputs: + +1. **Explicit selection** (`spec.osImageStream.name`): The user can explicitly set `rhel-9` or `rhel-10`. +2. **OCP release version**: The release image version determines which streams are available. +3. **Container runtime**: If a `ContainerRuntimeConfig` using `runc` is attached to the NodePool, RHEL 10 is unavailable because RHEL 10 does not include `runc`. + +When the stream is resolved, the NodePool controller generates an `OSImageStream` custom resource in the ignition payload, which the Machine Config Operator uses to select the correct OS and extension images. + +## OS Stream Resolution Matrix + +The following table describes the resolved OS stream for every combination of user selection, release version, and container runtime: + +| `spec.osImageStream.name` | OCP Version | Uses runc | Resolved Stream | Outcome | +|---|---|---|---|---| +| *(unset)* | < 5.0 | Any | *(none)* | Legacy behavior — default RHCOS images are used. | +| *(unset)* | ≥ 5.0 | No | `rhel-10` | Default for OCP 5.0+. | +| *(unset)* | ≥ 5.0 | Yes | `rhel-9` | Automatic fallback — a condition message surfaces the reason. | +| `rhel-9` | Any | Any | `rhel-9` | Explicit selection, always valid. | +| `rhel-10` | < 5.0 | Any | **Error** | RHEL 10 is not supported on releases before 5.0. | +| `rhel-10` | ≥ 5.0 | Yes | **Error** | RHEL 10 does not support `runc`. | +| `rhel-10` | ≥ 5.0 | No | `rhel-10` | Explicit selection, valid. | + +## Selecting an OS Stream + +To explicitly select an OS stream for a NodePool, set `spec.osImageStream.name`: + +```yaml +apiVersion: hypershift.openshift.io/v1beta1 +kind: NodePool +metadata: + name: my-nodepool + namespace: clusters +spec: + release: + image: quay.io/openshift-release-dev/ocp-release:5.0.0-x86_64 + osImageStream: + name: rhel-10 + # ...other fields +``` + +When omitted, the stream is resolved automatically based on the release version and runtime configuration. + +## Stream Transitions + +Changing `spec.osImageStream.name` triggers a NodePool rollout (Replace or InPlace, depending on `spec.management.upgradeType`). + +**Allowed transitions:** + +- *(unset)* → `rhel-9` or `rhel-10` +- `rhel-9` → `rhel-10` *(forward upgrade)* + +**Blocked transitions:** + +- `rhel-10` → `rhel-9` *(downgrade)* — rejected by API validation. To move back to RHEL 9, create a new NodePool. + +!!! warning + + OS stream downgrades from `rhel-10` to `rhel-9` are not supported because in-place OS major version downgrades are not safe. If you need RHEL 9 nodes after upgrading to RHEL 10, create a new NodePool with `spec.osImageStream.name: rhel-9` and migrate workloads. + +## runc Compatibility + +RHEL 10 removes support for the `runc` container runtime. If a NodePool references a `ContainerRuntimeConfig` that sets `runc` as the default runtime: + +- **Explicit `rhel-10` selection**: The NodePool controller rejects the configuration and sets a `ValidMachineConfig=False` condition with the message: *"OS stream rhel-10 is not compatible with ContainerRuntimeConfig using runc: RHEL 10 does not support runc"*. +- **No explicit stream on OCP ≥ 5.0**: The controller automatically falls back to `rhel-9` and surfaces a condition message: *"OS stream defaulted to rhel-9: NodePool uses runc ContainerRuntimeConfig which is not supported on rhel-10"*. + +To use RHEL 10, remove or update the `ContainerRuntimeConfig` to use `crun` (the default on RHEL 10). + +## Observability + +### Status Fields + +The resolved OS stream observed on running nodes is reported in `status.osImageStream`: + +```yaml +status: + osImageStream: + name: rhel-10 +``` + +This field is inferred from the CAPI Machine `NodeInfo.OSImage` field of registered nodes. It reflects what is actually running, not what was requested in the spec. + +### Conditions + +OS stream validation errors and informational messages appear on the `ValidMachineConfig` condition: + +| Scenario | Condition Status | Message | +|---|---|---| +| Valid configuration | `True` | *(empty or standard)* | +| Invalid stream for release | `False` | `invalid OS stream configuration: OS stream "rhel-10" is not supported for release version 4.x (requires >= 5.0.0)` | +| runc + rhel-10 conflict | `False` | `invalid OS stream configuration: OS stream "rhel-10" is not compatible with ContainerRuntimeConfig using runc` | +| Implicit rhel-9 fallback | `True` | `OS stream defaulted to rhel-9: NodePool uses runc ContainerRuntimeConfig which is not supported on rhel-10` | + +## Common Scenarios + +### Upgrading from OCP 4.x to 5.0 + +When upgrading a HostedCluster from OCP 4.x to 5.0+, NodePools with no explicit `osImageStream` will automatically transition from legacy RHCOS images to `rhel-10` (or `rhel-9` if runc is in use). This transition is handled as part of the normal NodePool rollout triggered by the release image change. + +### Mixed OS Pools in a Cluster + +You can run multiple NodePools with different OS streams in the same HostedCluster. For example: + +- `nodepool-default`: `osImageStream.name: rhel-10` — for general workloads +- `nodepool-legacy`: `osImageStream.name: rhel-9` — for workloads that require runc or RHEL 9 compatibility + +### Migrating from RHEL 9 to RHEL 10 + +1. Verify the HostedCluster is running OCP ≥ 5.0. +2. Ensure no `ContainerRuntimeConfig` with runc is attached to the NodePool. +3. Set `spec.osImageStream.name: rhel-10` on the NodePool. +4. Monitor the rollout via `status.conditions` and `status.osImageStream`. + + --- ## Source: docs/content/how-to/automated-machine-management/haproxy-image-override.md