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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions hypershift-operator/controllers/nodepool/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,24 +93,45 @@ func (r *NodePoolReconciler) setNodesInfoStatus(nodePool *hyperv1.NodePool, mach
}
}

// rhcosOSImageRe matches the RHCOS version from the NodeInfo.OSImage string.
// The first capture group is the leading digit of the RHCOS version (e.g. "4"
// in "419.97…"), which determines the RHEL generation (4xx → RHEL 9, 5xx → RHEL 10).
var rhcosOSImageRe = regexp.MustCompile(`Red Hat Enterprise Linux CoreOS (\d)\d{2}\.`)
// rhcosOSImageRe matches two RHCOS version formats from NodeInfo.OSImage:
// - Legacy (OCP ≤ 4.x): "Red Hat Enterprise Linux CoreOS 419.97.202505081234-0 (Plow)"
// The leading digit of the 3-digit version determines the RHEL generation
// (4xx → RHEL 9, 5xx → RHEL 10).
// - New (OCP 5.0+): "Red Hat Enterprise Linux CoreOS 9.8.20260721-0 (Plow)"
// The major version directly encodes the RHEL generation (9 → RHEL 9, 10 → RHEL 10).
//
// Capture group 1: the legacy leading digit (e.g. "4" from "419.")
// Capture group 2: the new major version (e.g. "9" from "9.", or "10" from "10.")
var rhcosOSImageRe = regexp.MustCompile(`Red Hat Enterprise Linux CoreOS (?:(\d)\d{2}\.|(9|10)\.)`)

// rhcosStreamFromOSImage parses a Machine's NodeInfo.OSImage string and
// returns the corresponding RHEL stream name. RHCOS versions starting with
// 4xx map to RHEL 9 and 5xx to RHEL 10.
// returns the corresponding RHEL stream name.
// Legacy format: 4xx → RHEL 9, 5xx → RHEL 10.
// New format: 9 → RHEL 9, 10 → RHEL 10.
// Returns empty string if the OS image string is unrecognized.
func rhcosStreamFromOSImage(osImage string) string {
matches := rhcosOSImageRe.FindStringSubmatch(osImage)
if len(matches) < 2 {
if len(matches) < 3 {
return ""
}
switch matches[1] {
case "4":

// Legacy format matched (capture group 1).
if matches[1] != "" {
switch matches[1] {
case "4":
return StreamRHEL9
case "5":
return StreamRHEL10
default:
return ""
}
}

// New format matched (capture group 2).
switch matches[2] {
case "9":
return StreamRHEL9
case "5":
case "10":
return StreamRHEL10
default:
return ""
Expand Down
15 changes: 15 additions & 0 deletions hypershift-operator/controllers/nodepool/version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,21 @@ func TestRhcosStreamFromOSImage(t *testing.T) {
osImage: "Red Hat Enterprise Linux CoreOS 300.97.202505081234-0 (Plow)",
expected: "",
},
{
name: "When OSImage uses new OCP 5.0 format with RHEL 9 it should return rhel-9",
osImage: "Red Hat Enterprise Linux CoreOS 9.8.20260721-0 (Plow)",
expected: StreamRHEL9,
},
{
name: "When OSImage uses new OCP 5.0 format with RHEL 10 it should return rhel-10",
osImage: "Red Hat Enterprise Linux CoreOS 10.2.20260801-0 (Plow)",
expected: StreamRHEL10,
},
{
name: "When OSImage uses new format with unknown major it should return empty string",
osImage: "Red Hat Enterprise Linux CoreOS 8.5.20260101-0 (Plow)",
expected: "",
},
}

for _, tc := range testCases {
Expand Down
12 changes: 12 additions & 0 deletions test/e2e/util/eventually.go
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,18 @@ func (needle Condition) Matches(condition Condition) bool {
(needle.Message == "" || needle.Message == condition.Message)
}

// OSImageStreamPredicate returns a predicate that validates that a NodePool's
// status.osImageStream.name matches the expected value.
func OSImageStreamPredicate(expected string) Predicate[*hyperv1.NodePool] {
return func(pool *hyperv1.NodePool) (bool, string, error) {
actual := pool.Status.OSImageStream.Name
if actual == expected {
return true, fmt.Sprintf("status.osImageStream.name is %s", expected), nil
}
return false, fmt.Sprintf("status.osImageStream.name is %s, want %s", actual, expected), nil
}
}

// ConditionPredicate returns a predicate that validates that a particular condition type exists and has the requisite status, reason and/or message.
func ConditionPredicate[T client.Object](needle Condition) Predicate[T] {
return func(item T) (bool, string, error) {
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/v2/lifecycle/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ func (a *AzurePlatformConfig) TestMatrix(releaseImage string) TestMatrix {
{
Name: "private",
ClusterFile: "cluster-name-private",
LabelFilter: "self-managed-azure-private || hosted-cluster-compliance",
LabelFilter: "self-managed-azure-private || hosted-cluster-compliance || nodepool-osimagestream",
JUnitFile: "junit_self_managed_azure_private.xml",
},
{
Expand Down
50 changes: 50 additions & 0 deletions test/e2e/v2/tests/nodepool_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,9 @@ func NodePoolReplaceUpgradeTest(getTestCtx internal.TestContextGetter) {

e2eutil.WaitForReadyNodesByNodePool(GinkgoTB(), ctx, hcClient, np, hc.Spec.Platform.Type)

// Verify osImageStream status after upgrade, if the OSStreams feature gate is enabled.
verifyOSImageStreamAfterUpgrade(ctx, testCtx, np)

// TODO: EnsureNodesLabelsAndTaints, EnsureNodesRuntime require *testing.T
})
}
Expand Down Expand Up @@ -488,6 +491,9 @@ func NodePoolInPlaceUpgradeTest(getTestCtx internal.TestContextGetter) {

e2eutil.WaitForReadyNodesByNodePool(GinkgoTB(), ctx, hcClient, np, hc.Spec.Platform.Type)

// Verify osImageStream status after upgrade, if the OSStreams feature gate is enabled.
verifyOSImageStreamAfterUpgrade(ctx, testCtx, np)

// TODO: EnsureNodesLabelsAndTaints, EnsureNodesRuntime require *testing.T
})
}
Expand Down Expand Up @@ -1457,6 +1463,50 @@ func waitForDaemonSetRollout(ctx context.Context, client crclient.Client, ds *ap
)
}

// verifyOSImageStreamAfterUpgrade checks that status.osImageStream is set correctly
// on the NodePool after an upgrade completes. If the OSStreams feature gate is not
// enabled, the assertion is skipped (the upgrade test itself still passes).
//
// TODO(CNTRLPLANE-3032): The default OS stream is currently hardcoded to rhel-9 for all
// OCP versions. When the hardcoding is removed and OCP >= 5.0 defaults to rhel-10,
// update expectedStream to use rhel-10 on >= 5.0.
func verifyOSImageStreamAfterUpgrade(ctx context.Context, testCtx *internal.TestContext, np *hyperv1.NodePool) {
GinkgoHelper()

hasSpecField, err := e2eutil.HasFieldInCRDSchema(ctx, testCtx.MgmtClient,
"nodepools.hypershift.openshift.io", "spec.osImageStream")
Expect(err).NotTo(HaveOccurred(), "failed to check CRD schema for spec.osImageStream")
if !hasSpecField {
GinkgoWriter.Println("OSStreams feature gate is not enabled; skipping osImageStream assertion")
return
}

hasStatusField, err := e2eutil.HasFieldInCRDSchema(ctx, testCtx.MgmtClient,
"nodepools.hypershift.openshift.io", "status.osImageStream")
Expect(err).NotTo(HaveOccurred(), "failed to check CRD schema for status.osImageStream")
if !hasStatusField {
GinkgoWriter.Println("OSStreams feature gate is not enabled for status; skipping osImageStream assertion")
return
}

expectedStream := hyperv1.OSImageStreamRHEL9

e2eutil.EventuallyObject[*hyperv1.NodePool](
GinkgoTB(), ctx,
fmt.Sprintf("NodePool %s/%s status to report osImageStream=%s after upgrade", np.Namespace, np.Name, expectedStream),
func(pollCtx context.Context) (*hyperv1.NodePool, error) {
pool := &hyperv1.NodePool{}
err := testCtx.MgmtClient.Get(pollCtx, crclient.ObjectKeyFromObject(np), pool)
return pool, err
},
[]e2eutil.Predicate[*hyperv1.NodePool]{
e2eutil.OSImageStreamPredicate(expectedStream),
},
e2eutil.WithTimeout(10*time.Minute),
e2eutil.WithInterval(15*time.Second),
)
}

// nodePoolUpgradeTimeout returns the appropriate timeout for NodePool upgrades
// based on the platform type.
func nodePoolUpgradeTimeout(platform hyperv1.PlatformType) time.Duration {
Expand Down
Loading