Skip to content
Open
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
8 changes: 8 additions & 0 deletions api/hypershift/v1beta1/nodepool_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ type NodePool struct {
}

// NodePoolSpec is the desired behavior of a NodePool.
// +openshift:validation:FeatureGateAwareXValidation:featureGate=OSStreams,rule="!has(oldSelf.osImageStream) || has(self.osImageStream)",message="osImageStream cannot be removed once set; create a new NodePool instead"
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.arch) || has(self.arch)", message="Arch is required once set"
// +kubebuilder:validation:XValidation:rule="self.arch != 'arm64' || has(self.platform.aws) || has(self.platform.azure) || has(self.platform.agent) || self.platform.type == 'GCP' || self.platform.type == 'None'", message="Setting Arch to arm64 is only supported for AWS, Azure, Agent, GCP and None"
// +kubebuilder:validation:XValidation:rule="!has(self.replicas) || !has(self.autoScaling)", message="Both replicas or autoScaling should not be set"
Expand Down Expand Up @@ -261,6 +262,13 @@ type NodePoolSpec struct {
OSImageStream OSImageStreamReference `json:"osImageStream,omitzero"`
}

const (
// OSImageStreamRHEL9 is the OS image stream name for RHEL 9.
OSImageStreamRHEL9 = "rhel-9"
// OSImageStreamRHEL10 is the OS image stream name for RHEL 10.
OSImageStreamRHEL10 = "rhel-10"
)

// OSImageStreamReference references an OSImageStream by name.
type OSImageStreamReference struct {
// name is a required reference to an OSImageStream to be used for the pool.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1528,6 +1528,9 @@ spec:
- release
type: object
x-kubernetes-validations:
- message: osImageStream cannot be removed once set; create a new NodePool
instead
rule: '!has(oldSelf.osImageStream) || has(self.osImageStream)'
- message: Arch is required once set
rule: '!has(oldSelf.arch) || has(self.arch)'
- message: Setting Arch to arm64 is only supported for AWS, Azure, Agent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,55 @@ tests:
osImageStream:
name: "rhel-10"

- name: When removing osImageStream from an existing NodePool it should fail
initial: |
apiVersion: hypershift.openshift.io/v1beta1
kind: NodePool
spec:
arch: amd64
clusterName: some-cluster
management:
autoRepair: false
upgradeType: Replace
release:
image: quay.io/openshift-release-dev/ocp-release:4.17.0-rc.0-x86_64
replicas: 0
platform:
aws:
instanceProfile: a-profile
instanceType: m6a.2xlarge
rootVolume:
size: 120
type: gp3
subnet:
id: "subnet-any"
type: AWS
osImageStream:
name: "rhel-10"
updated: |
apiVersion: hypershift.openshift.io/v1beta1
kind: NodePool
spec:
arch: amd64
clusterName: some-cluster
management:
autoRepair: false
upgradeType: Replace
release:
image: quay.io/openshift-release-dev/ocp-release:4.17.0-rc.0-x86_64
replicas: 0
platform:
aws:
instanceProfile: a-profile
instanceType: m6a.2xlarge
rootVolume:
size: 120
type: gp3
subnet:
id: "subnet-any"
type: AWS
expectedError: "osImageStream cannot be removed once set; create a new NodePool instead"

- name: When adding osImageStream to an existing NodePool it should succeed
initial: |
apiVersion: hypershift.openshift.io/v1beta1
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions hypershift-operator/controllers/nodepool/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ func isSpotEnabled(nodePool *hyperv1.NodePool) bool {
return false
}

func awsMachineTemplateSpec(infraName string, hostedCluster *hyperv1.HostedCluster, nodePool *hyperv1.NodePool, defaultSG bool, releaseImage *releaseinfo.ReleaseImage) (*capiaws.AWSMachineTemplateSpec, error) {
ami, err := resolveAWSAMI(hostedCluster, nodePool, releaseImage)
func awsMachineTemplateSpec(infraName string, hostedCluster *hyperv1.HostedCluster, nodePool *hyperv1.NodePool, defaultSG bool, releaseImage *releaseinfo.ReleaseImage, rhelStream string) (*capiaws.AWSMachineTemplateSpec, error) {
ami, err := resolveAWSAMI(hostedCluster, nodePool, releaseImage, rhelStream)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -118,7 +118,7 @@ func awsMachineTemplateSpec(infraName string, hostedCluster *hyperv1.HostedClust
return awsMachineTemplateSpec, nil
}

func resolveAWSAMI(hostedCluster *hyperv1.HostedCluster, nodePool *hyperv1.NodePool, releaseImage *releaseinfo.ReleaseImage) (string, error) {
func resolveAWSAMI(hostedCluster *hyperv1.HostedCluster, nodePool *hyperv1.NodePool, releaseImage *releaseinfo.ReleaseImage, rhelStream string) (string, error) {
// TODO: Should the region be included in the NodePool platform information?
region := hostedCluster.Spec.Platform.AWS.Region
arch := nodePool.Spec.Arch
Expand All @@ -134,7 +134,7 @@ func resolveAWSAMI(hostedCluster *hyperv1.HostedCluster, nodePool *hyperv1.NodeP
return ami, nil
}
// Default behavior for Linux/RHCOS AMIs
ami, err := defaultNodePoolAMI(region, arch, releaseImage)
ami, err := defaultNodePoolAMI(region, arch, releaseImage, rhelStream)
if err != nil {
return "", fmt.Errorf("couldn't discover an AMI for release image: %w", err)
}
Expand Down Expand Up @@ -270,7 +270,7 @@ func awsAdditionalTags(nodePool *hyperv1.NodePool, hostedCluster *hyperv1.Hosted
}

func (c *CAPI) awsMachineTemplate(ctx context.Context, templateNameGenerator func(spec any) (string, error)) (*capiaws.AWSMachineTemplate, error) {
desiredSpec, err := awsMachineTemplateSpec(c.capiClusterName, c.hostedCluster, c.nodePool, c.cpoCapabilities.CreateDefaultAWSSecurityGroup, c.releaseImage)
desiredSpec, err := awsMachineTemplateSpec(c.capiClusterName, c.hostedCluster, c.nodePool, c.cpoCapabilities.CreateDefaultAWSSecurityGroup, c.releaseImage, c.rhelStream)
if err != nil {
return nil, fmt.Errorf("failed to generate AWSMachineTemplateSpec: %w", err)
}
Expand Down Expand Up @@ -361,7 +361,7 @@ func (r *NodePoolReconciler) setAWSConditions(_ context.Context, nodePool *hyper
})
} else {
// Default behavior for Linux/RHCOS AMIs
ami, err := defaultNodePoolAMI(hcluster.Spec.Platform.AWS.Region, nodePool.Spec.Arch, releaseImage)
ami, err := defaultNodePoolAMI(hcluster.Spec.Platform.AWS.Region, nodePool.Spec.Arch, releaseImage, nodePool.Spec.OSImageStream.Name)
if err != nil {
SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{
Type: hyperv1.NodePoolValidPlatformImageType,
Expand Down
3 changes: 2 additions & 1 deletion hypershift-operator/controllers/nodepool/aws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ func TestAWSMachineTemplateSpec(t *testing.T) {
},
true,
releaseImage,
"",
)
if tc.checkError != nil {
tc.checkError(t, err)
Expand Down Expand Up @@ -1185,7 +1186,7 @@ func TestResolveAWSAMI(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
g := NewWithT(t)
ami, err := resolveAWSAMI(tc.hostedCluster, tc.nodePool, tc.releaseImage)
ami, err := resolveAWSAMI(tc.hostedCluster, tc.nodePool, tc.releaseImage, "")
if tc.expectError {
g.Expect(err).To(HaveOccurred())
} else {
Expand Down
6 changes: 6 additions & 0 deletions hypershift-operator/controllers/nodepool/capi.go
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,9 @@ func (c *CAPI) reconcileMachineDeploymentStatus(log logr.Logger, machineDeployme
"previous", nodePool.Status.Version, "new", targetVersion)
nodePool.Status.Version = targetVersion
}
if stream, err := getRHELStream(nodePool, c.releaseImage); err == nil {
nodePool.Status.OSImageStream = hyperv1.OSImageStreamReference{Name: stream}
}

if nodePool.Annotations == nil {
nodePool.Annotations = make(map[string]string)
Expand Down Expand Up @@ -1012,6 +1015,9 @@ func (c *CAPI) reconcileMachineSet(ctx context.Context,
"previous", nodePool.Status.Version, "new", targetVersion)
nodePool.Status.Version = targetVersion
}
if stream, err := getRHELStream(nodePool, c.releaseImage); err == nil {
nodePool.Status.OSImageStream = hyperv1.OSImageStreamReference{Name: stream}
}

if nodePool.Annotations == nil {
nodePool.Annotations = make(map[string]string)
Expand Down
8 changes: 7 additions & 1 deletion hypershift-operator/controllers/nodepool/capi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2641,14 +2641,15 @@ func TestReconcileMachineDeploymentStatus(t *testing.T) {
nodePoolAnnotations map[string]string
targetVersion string
expectedVersion string
expectedOSImageStream string
expectedReplicas int32
expectedConfigAnnotation bool
expectedTemplateAnnotation bool
expectedReadyConditionStatus corev1.ConditionStatus
expectedReadyConditionSet bool
}{
{
name: "When MachineDeployment is complete, it should update nodePool version and annotations",
name: "When MachineDeployment is complete, it should update nodePool version",
machineDeployment: &capiv1.MachineDeployment{
ObjectMeta: metav1.ObjectMeta{Generation: 1},
Spec: capiv1.MachineDeploymentSpec{
Expand All @@ -2666,6 +2667,7 @@ func TestReconcileMachineDeploymentStatus(t *testing.T) {
nodePoolAnnotations: map[string]string{},
targetVersion: "4.17.0",
expectedVersion: "4.17.0",
expectedOSImageStream: "",
expectedReplicas: 3,
expectedConfigAnnotation: true,
expectedTemplateAnnotation: true,
Expand Down Expand Up @@ -2766,6 +2768,10 @@ func TestReconcileMachineDeploymentStatus(t *testing.T) {
g.Expect(nodePool.Status.Replicas).To(Equal(tc.expectedReplicas))
g.Expect(nodePool.Status.Version).To(Equal(tc.expectedVersion))

if tc.expectedOSImageStream != "" {
g.Expect(nodePool.Status.OSImageStream.Name).To(Equal(tc.expectedOSImageStream))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if tc.expectedConfigAnnotation {
g.Expect(nodePool.Annotations).To(HaveKey(nodePoolAnnotationCurrentConfig))
}
Expand Down
12 changes: 12 additions & 0 deletions hypershift-operator/controllers/nodepool/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,18 @@ func (r *NodePoolReconciler) validMachineConfigCondition(ctx context.Context, no
})
return &ctrl.Result{}, fmt.Errorf("failed to generate config: %w", err)
}

if err := validateOSImageStream(nodePool); err != nil {
SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{
Type: hyperv1.NodePoolValidMachineConfigConditionType,
Status: corev1.ConditionFalse,
Reason: hyperv1.NodePoolValidationFailedReason,
Message: err.Error(),
ObservedGeneration: nodePool.Generation,
})
return &ctrl.Result{}, fmt.Errorf("failed to validate osImageStream: %w", err)
}

SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{
Type: hyperv1.NodePoolValidMachineConfigConditionType,
Status: corev1.ConditionTrue,
Expand Down
24 changes: 22 additions & 2 deletions hypershift-operator/controllers/nodepool/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ 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 OS image stream name used for hash computation.
// It is set from spec.osImageStream.Name but normalized: if the explicit
// value matches the version-derived default it is kept empty so that
// setting the default stream does not change the hash.
// Only a non-default stream (e.g. "rhel-10" on a 4.x release) produces
// a non-empty value here and triggers a rollout.
rhelStream string
}

// NewConfigGenerator is the contract to create a new ConfigGenerator.
Expand All @@ -77,6 +84,18 @@ func NewConfigGenerator(ctx context.Context, client client.Client, hostedCluster
return nil, err
}

// Normalize rhelStream for the config hash: when the user explicitly sets
// osImageStream to the version-derived default (e.g. "rhel-9" on a 4.x
// release), treat it as equivalent to "not set" so the hash doesn't change
// and no spurious rollout is triggered.
rhelStream := nodePool.Spec.OSImageStream.Name
if rhelStream != "" {
defaultStream, err := defaultRHELStream(releaseImage)
if err == nil && rhelStream == defaultStream {
rhelStream = ""
}
}

cg := &ConfigGenerator{
Client: client,
hostedCluster: hostedCluster,
Expand All @@ -87,6 +106,7 @@ func NewConfigGenerator(ctx context.Context, client client.Client, hostedCluster
pullSecretName: hostedCluster.Spec.PullSecret.Name,
globalConfig: globalConfig,
haproxyRawConfig: haproxyRawConfig,
rhelStream: rhelStream,
},
}

Expand Down Expand Up @@ -118,15 +138,15 @@ 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.
// This is only used to signal if a rollout is driven by a new release or by something else.
// 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 {
Expand Down
24 changes: 24 additions & 0 deletions hypershift-operator/controllers/nodepool/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ func TestHash(t *testing.T) {
pullSecretName string
additionalTrustBundleName string
globalConfig string
rhelStream string
expected string
}{
{
Expand Down Expand Up @@ -490,6 +491,16 @@ func TestHash(t *testing.T) {
globalConfig: "different",
expected: "e916ddfe",
},
{
name: "When rhelStream is a non-default stream, it should change the hash",
mcoRawConfig: baseCaseMCORawConfig,
releaseVersion: baseCaseReleaseVersion,
pullSecretName: baseCasePullSecretName,
additionalTrustBundleName: baseCaseAdditionalTrustBundleName,
globalConfig: baseCaseGlobalConfig,
rhelStream: "rhel-10",
expected: "2dbbd41b",
},
}

for _, tc := range testCases {
Expand All @@ -508,6 +519,7 @@ func TestHash(t *testing.T) {
pullSecretName: tc.pullSecretName,
additionalTrustBundleName: tc.additionalTrustBundleName,
globalConfig: tc.globalConfig,
rhelStream: tc.rhelStream,
releaseImage: releaseImage,
},
}
Expand Down Expand Up @@ -536,6 +548,7 @@ func TestHashWithoutVersion(t *testing.T) {
pullSecretName string
additionalTrustBundleName string
globalConfig string
rhelStream string
expected string
}{
{
Expand Down Expand Up @@ -594,6 +607,16 @@ func TestHashWithoutVersion(t *testing.T) {
globalConfig: "different",
expected: baseCaseHash,
},
{
name: "When rhelStream is a non-default stream, it should change the hash",
mcoRawConfig: baseCaseMCORawConfig,
releaseVersion: baseCaseReleaseVersion,
pullSecretName: baseCasePullSecretName,
additionalTrustBundleName: baseCaseAdditionalTrustBundleName,
globalConfig: baseCaseGlobalConfig,
rhelStream: "rhel-10",
expected: "671fe083",
},
}

for _, tc := range testCases {
Expand All @@ -612,6 +635,7 @@ func TestHashWithoutVersion(t *testing.T) {
pullSecretName: tc.pullSecretName,
additionalTrustBundleName: tc.additionalTrustBundleName,
globalConfig: tc.globalConfig,
rhelStream: tc.rhelStream,
releaseImage: releaseImage,
},
}
Expand Down
Loading