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/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 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 diff --git a/hypershift-operator/controllers/nodepool/conditions.go b/hypershift-operator/controllers/nodepool/conditions.go index cff009e3590e..e42439d4875c 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,12 +385,54 @@ func (r *NodePoolReconciler) validMachineConfigCondition(ctx context.Context, no }) return &ctrl.Result{}, fmt.Errorf("failed to generate config: %w", err) } - SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ - Type: hyperv1.NodePoolValidMachineConfigConditionType, - Status: corev1.ConditionTrue, - Reason: hyperv1.AsExpectedReason, - ObservedGeneration: nodePool.Generation, - }) + + // 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, + }) + log.Error(parseErr, "failed to parse release version") + return &ctrl.Result{}, nil + } + + 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, surface as a condition. + if nodePool.Spec.OSImageStream.Name == "" && resolvedStream == RHELStreamRHEL9 && cg.usesRunc { + 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, + }) + } return nil, nil } diff --git a/hypershift-operator/controllers/nodepool/config.go b/hypershift-operator/controllers/nodepool/config.go index 8771f45ea83c..f02d4253d0fa 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. @@ -126,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 { @@ -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/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/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) + } +} 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/token.go b/hypershift-operator/controllers/nodepool/token.go index aaff447e9841..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" @@ -27,6 +28,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" @@ -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[ignserver.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/hypershift-operator/controllers/nodepool/token_test.go b/hypershift-operator/controllers/nodepool/token_test.go index 5266d08331cb..df67b6f14d65 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" @@ -101,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, @@ -132,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, @@ -223,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, @@ -614,7 +636,7 @@ func TestTokenReconcile(t *testing.T) { releaseImage: &releaseinfo.ReleaseImage{ ImageStream: &imageapi.ImageStream{ ObjectMeta: metav1.ObjectMeta{ - Name: "4.17", + Name: "4.17.0", }, }, }, @@ -676,7 +698,7 @@ func TestTokenReconcile(t *testing.T) { releaseImage: &releaseinfo.ReleaseImage{ ImageStream: &imageapi.ImageStream{ ObjectMeta: metav1.ObjectMeta{ - Name: "4.17", + Name: "4.17.0", }, }, }, @@ -827,6 +849,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 d63ca3417daa..568acc2c462f 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,80 @@ 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 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 + + 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. + // 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 { + 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 581ad975c5ab..b626bed4181b 100644 --- a/hypershift-operator/controllers/nodepool/version_test.go +++ b/hypershift-operator/controllers/nodepool/version_test.go @@ -325,3 +325,103 @@ 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: "", + }, + { + 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 { + 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/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/local_ignitionprovider.go b/ignition-server/controllers/local_ignitionprovider.go index dcc8366454ff..1b93b38be742 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: %s +`, 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) diff --git a/ignition-server/controllers/local_ignitionprovider_test.go b/ignition-server/controllers/local_ignitionprovider_test.go index a6d083f0b88d..637fbaa27daa 100644 --- a/ignition-server/controllers/local_ignitionprovider_test.go +++ b/ignition-server/controllers/local_ignitionprovider_test.go @@ -1388,3 +1388,52 @@ 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 + 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", + }, + { + 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 := NewWithT(t) + mccDir := t.TempDir() + + err := writeOSImageStreamCR(mccDir, tt.osStream) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + 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: %s", tt.osStream))) + }) + } +} 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) } 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 }