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
23 changes: 13 additions & 10 deletions hypershift-operator/controllers/nodepool/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ type azureMarketplaceImageInfo struct {

// defaultAzureNodePoolImage applies Azure Marketplace image defaults for OCP >= 4.20
// when Type is AzureMarketplace and azureMarketplace data is not provided and marketplace metadata is available in the release payload.
func defaultAzureNodePoolImage(nodePool *hyperv1.NodePool, releaseImage *releaseinfo.ReleaseImage) error {
func defaultAzureNodePoolImage(nodePool *hyperv1.NodePool, releaseImage *releaseinfo.ReleaseImage, streamName string) error {
// Skip if ImageID is explicitly set
if nodePool.Spec.Platform.Azure.Image.ImageID != nil {
return nil
Expand Down Expand Up @@ -82,7 +82,7 @@ func defaultAzureNodePoolImage(nodePool *hyperv1.NodePool, releaseImage *release
}

// Extract marketplace metadata from release payload
azureMarketplace, err := getAzureMarketplaceMetadata(releaseImage, streamArch)
azureMarketplace, err := getAzureMarketplaceMetadata(releaseImage, streamArch, streamName)
if err != nil {
return fmt.Errorf("failed to get Azure Marketplace metadata: %w", err)
}
Expand Down Expand Up @@ -120,15 +120,18 @@ func defaultAzureNodePoolImage(nodePool *hyperv1.NodePool, releaseImage *release
return nil
}

// getAzureMarketplaceMetadata extracts Azure Marketplace metadata from the release payload
func getAzureMarketplaceMetadata(releaseImage *releaseinfo.ReleaseImage, arch string) (*azureMarketplaceMetadata, error) {
// TODO(CNTRLPLANE-3553): use releaseImage.StreamForName(rhelStream) instead of
// accessing StreamMetadata directly, to support dual-stream payloads.
if releaseImage.StreamMetadata == nil {
return nil, nil // No stream metadata available
// getAzureMarketplaceMetadata extracts Azure Marketplace metadata from the release payload.
// Note: unlike the pre-streamName code this returns an error (rather than nil, nil)
// when no stream metadata is available at all. The version guard in
// defaultAzureNodePoolImage (< 4.20 returns early) protects older payloads;
// for 4.20+ a missing StreamMetadata indicates a broken release image.
func getAzureMarketplaceMetadata(releaseImage *releaseinfo.ReleaseImage, arch string, streamName string) (*azureMarketplaceMetadata, error) {
streamMeta, err := releaseImage.StreamForName(streamName)
if err != nil {
return nil, fmt.Errorf("couldn't resolve stream metadata for stream %q: %w", streamName, err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up: the old code returned (nil, nil) when StreamMetadata == nil, which let the caller in defaultAzureNodePoolImage fall through silently (no marketplace, no error, continue to AzureDiskImage). The new code returns an error from StreamForName.

Is this intentional? For pre-4.20 payloads or release images that legitimately have no stream metadata, this would now surface as "failed to get Azure Marketplace metadata" instead of silently skipping marketplace defaulting. The version guard at line 48 (< 4.20 returns early) may protect against this, but worth double-checking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is, adding a comment


archData, foundArch := releaseImage.StreamMetadata.Architectures[arch]
archData, foundArch := streamMeta.Architectures[arch]
if !foundArch {
return nil, fmt.Errorf("architecture %s not found in stream metadata", arch)
}
Expand Down Expand Up @@ -270,7 +273,7 @@ func azureMachineTemplateSpec(nodePool *hyperv1.NodePool, acrIdentityResourceID

func (c *CAPI) azureMachineTemplate(_ context.Context, templateNameGenerator func(spec any) (string, error)) (*capiazure.AzureMachineTemplate, error) {
// Apply Azure Marketplace image defaults before generating machine template spec
if err := defaultAzureNodePoolImage(c.nodePool, c.ConfigGenerator.rolloutConfig.releaseImage); err != nil {
if err := defaultAzureNodePoolImage(c.nodePool, c.ConfigGenerator.rolloutConfig.releaseImage, c.resolvedRHELStreamForBootImage); err != nil {
return nil, fmt.Errorf("failed to apply Azure image defaults: %w", err)
}

Expand Down
57 changes: 56 additions & 1 deletion hypershift-operator/controllers/nodepool/azure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,7 @@ func TestDefaultAzureNodePoolImage(t *testing.T) {
name string
nodePool *hyperv1.NodePool
releaseImage *releaseinfo.ReleaseImage
streamName string
expectedImageType hyperv1.AzureVMImageType
expectedMarketplaceImage *hyperv1.AzureMarketplaceImage
expectedError bool
Expand Down Expand Up @@ -1271,13 +1272,67 @@ func TestDefaultAzureNodePoolImage(t *testing.T) {
ImageGeneration: ptr.To(hyperv1.Gen1),
},
},
{
name: "When named stream is used with multi-stream ReleaseImage it should resolve marketplace from the named stream",
nodePool: &hyperv1.NodePool{
Spec: hyperv1.NodePoolSpec{
Arch: hyperv1.ArchitectureAMD64,
Platform: hyperv1.NodePoolPlatform{
Type: hyperv1.AzurePlatform,
Azure: &hyperv1.AzureNodePoolPlatform{
Image: hyperv1.AzureVMImage{},
},
},
},
},
releaseImage: &releaseinfo.ReleaseImage{
ImageStream: &imageapi.ImageStream{
ObjectMeta: metav1.ObjectMeta{Name: "4.20.0"},
},
OSStreams: map[string]*stream.Stream{
"rhel-9": {
Architectures: map[string]stream.Arch{
"x86_64": {
RHELCoreOSExtensions: &rhcos.Extensions{
AzureDisk: &rhcos.AzureDisk{
Release: "9.6.20250701-0",
URL: "https://rhcos.blob.core.windows.net/imagebucket/rhcos-9.6.20250701-0-azure.x86_64.vhd",
},
Marketplace: &rhcos.Marketplace{
Azure: &rhcos.AzureMarketplace{
NoPurchasePlan: &rhcos.AzureMarketplaceImages{
Gen2: &rhcos.AzureMarketplaceImage{
Publisher: "azureopenshift",
Offer: "aro4",
SKU: "aro_rhel9_420-v2",
Version: "420.9.20250701",
},
},
},
},
},
},
},
},
},
},
streamName: "rhel-9",
expectedImageType: hyperv1.AzureMarketplace,
expectedMarketplaceImage: &hyperv1.AzureMarketplaceImage{
Publisher: "azureopenshift",
Offer: "aro4",
SKU: "aro_rhel9_420-v2",
Version: "420.9.20250701",
ImageGeneration: ptr.To(hyperv1.Gen2),
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
g := NewGomegaWithT(t)

err := defaultAzureNodePoolImage(tc.nodePool, tc.releaseImage)
err := defaultAzureNodePoolImage(tc.nodePool, tc.releaseImage, tc.streamName)

if tc.expectedError {
g.Expect(err).To(HaveOccurred())
Expand Down
4 changes: 1 addition & 3 deletions hypershift-operator/controllers/nodepool/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,9 +367,7 @@ func (r *NodePoolReconciler) validMachineConfigCondition(ctx context.Context, no
}

// Validate osImageStream before expensive config generation to fail fast.
// TODO(CNTRLPLANE-3553): add integration test covering this condition path
// (invalid osImageStream.Name → ValidMachineConfig condition False + error return).
if err := validateOSImageStream(nodePool, releaseImage); err != nil {
if err := validateOSImageStream(ctx, r.Client, nodePool, releaseImage); err != nil {
SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{
Type: hyperv1.NodePoolValidMachineConfigConditionType,
Status: corev1.ConditionFalse,
Expand Down
13 changes: 10 additions & 3 deletions hypershift-operator/controllers/nodepool/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ type rolloutConfig struct {
// setting the default stream does not change the hash.
// Only a non-default stream (e.g. "rhel-9" on a ≥5.0 release) produces
// a non-empty value here and triggers a rollout.
//
// See also ConfigGenerator.resolvedRHELStreamForBootImage, which controls
// boot image resolution and is intentionally separate from the hash.
rhelStream string
}

Expand All @@ -100,12 +103,16 @@ func NewConfigGenerator(ctx context.Context, client client.Client, hostedCluster
// default explicitly does not change the hash and trigger a spurious rollout.
rhelStream := nodePool.Spec.OSImageStream.Name
if rhelStream != "" {
version, err := semver.Parse(releaseImage.Version())
var version semver.Version
version, err = semver.Parse(releaseImage.Version())
if err != nil {
return nil, fmt.Errorf("failed to parse release image version %q: %w", releaseImage.Version(), err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor style: runcErr and streamErr are unusual for Go — the convention is to reuse err with := for sequential error checks. Since this is inside the if rhelStream != "" block, := creates block-scoped vars that won't shadow the outer err:

usesRunc, err := usesRuncRuntime(ctx, client, nodePool)
if err != nil {
    return nil, fmt.Errorf("failed to detect container runtime: %w", err)
}
defaultStream, err := GetRHELStream("", version, usesRunc)

Up to you — it works as-is, just reads a bit unusual.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed runcErr→err, streamErr→err using block-scoped :=

}
// TODO(CNTRLPLANE-3553): pass actual usesRunc once container runtime detection is wired in.
defaultStream, err := GetRHELStream("", version, false)
usesRunc, err := usesRuncRuntime(ctx, client, nodePool)
if err != nil {
return nil, fmt.Errorf("failed to detect container runtime: %w", err)
}
defaultStream, err := GetRHELStream("", version, usesRunc)
if err != nil {
return nil, fmt.Errorf("failed to resolve default RHEL stream: %w", err)
}
Expand Down
57 changes: 57 additions & 0 deletions hypershift-operator/controllers/nodepool/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,63 @@ spec:
client: true,
error: nil,
},
{
name: "When runc ContainerRuntimeConfig on 5.0.0 with explicit rhel-9, it should normalize rhelStream to empty",
expectedHash: "72ea1773",
expectedHashWithoutVersion: "6d5a7b66",
nodePool: &hyperv1.NodePool{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test",
},
Spec: hyperv1.NodePoolSpec{
OSImageStream: hyperv1.OSImageStreamReference{Name: "rhel-9"},
Config: []corev1.LocalObjectReference{
{Name: "runc-ctrcfg"},
},
},
},
config: []crclient.Object{
runcContainerRuntimeConfigMap("test", "runc-ctrcfg"),
},
releaseImage: &releaseinfo.ReleaseImage{
ImageStream: &imageapi.ImageStream{
ObjectMeta: metav1.ObjectMeta{
Name: "5.0.0",
},
},
},
hostedCluster: hostedCluster,
client: true,
error: nil,
},
{
name: "When runc ContainerRuntimeConfig on 5.0.0 with no osImageStream, it should match explicit rhel-9 hash",
expectedHash: "72ea1773",
expectedHashWithoutVersion: "6d5a7b66",
nodePool: &hyperv1.NodePool{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test",
},
Spec: hyperv1.NodePoolSpec{
Config: []corev1.LocalObjectReference{
{Name: "runc-ctrcfg"},
},
},
},
config: []crclient.Object{
runcContainerRuntimeConfigMap("test", "runc-ctrcfg"),
},
releaseImage: &releaseinfo.ReleaseImage{
ImageStream: &imageapi.ImageStream{
ObjectMeta: metav1.ObjectMeta{
Name: "5.0.0",
},
},
},
hostedCluster: hostedCluster,
client: true,
error: nil,
},
{
name: "When additionalTrustBundle is specified it should be included in rolloutConfig",
expectedHash: "632801f8",
Expand Down
6 changes: 4 additions & 2 deletions hypershift-operator/controllers/nodepool/kubevirt.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ func (r *NodePoolReconciler) setKubevirtConditions(ctx context.Context, nodePool

nodePool.Status.Platform.KubeVirt.Credentials = hcluster.Spec.Platform.Kubevirt.Credentials.DeepCopy()
}
kubevirtBootImage, err := kubevirt.GetImage(nodePool, releaseImage, infraNS)
// TODO(CNTRLPLANE-3553): hardcode to rhel-9 until the MCO can install
// rhel-10 OS images. Use getRHELStreamForBootImage once MCO support lands.
kubevirtBootImage, err := kubevirt.GetImage(nodePool, releaseImage, infraNS, StreamRHEL9)
if err != nil {
SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{
Type: hyperv1.NodePoolValidPlatformImageType,
Expand Down Expand Up @@ -166,7 +168,7 @@ func (r *NodePoolReconciler) setAllMachinesLMCondition(ctx context.Context, node

func (c *CAPI) kubevirtMachineTemplate(templateNameGenerator func(spec any) (string, error)) (*capikubevirt.KubevirtMachineTemplate, error) {
nodePool := c.nodePool
spec, err := kubevirt.MachineTemplateSpec(nodePool, c.hostedCluster, c.releaseImage, nil)
spec, err := kubevirt.MachineTemplateSpec(nodePool, c.hostedCluster, c.releaseImage, nil, c.resolvedRHELStreamForBootImage)
if err != nil {
SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{
Type: hyperv1.NodePoolValidMachineTemplateConditionType,
Expand Down
18 changes: 11 additions & 7 deletions hypershift-operator/controllers/nodepool/kubevirt/kubevirt.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,19 @@ var LocalStorageVolumes = []string{
"hotplug-disks",
}

func defaultImage(nodePoolArch string, releaseImage *releaseinfo.ReleaseImage) (string, string, error) {
func defaultImage(nodePoolArch string, releaseImage *releaseinfo.ReleaseImage, streamName string) (string, string, error) {
var archName string
switch nodePoolArch {
case hyperv1.ArchitectureS390X:
archName = hyperv1.ArchitectureS390X
default:
archName = hyperv1.ArchAliases[hyperv1.ArchitectureAMD64]
}
arch, foundArch := releaseImage.StreamMetadata.Architectures[archName]
streamMeta, err := releaseImage.StreamForName(streamName)
if err != nil {
return "", "", fmt.Errorf("couldn't resolve stream metadata for stream %q: %w", streamName, err)
}
arch, foundArch := streamMeta.Architectures[archName]

if !foundArch {
return "", "", fmt.Errorf("couldn't find OS metadata for architecture %q", archName)
Expand Down Expand Up @@ -74,7 +78,7 @@ func allowUnsupportedRHCOSVariants(nodePool *hyperv1.NodePool) bool {
return false
}

func GetImage(nodePool *hyperv1.NodePool, releaseImage *releaseinfo.ReleaseImage, hostedNamespace string) (BootImage, error) {
func GetImage(nodePool *hyperv1.NodePool, releaseImage *releaseinfo.ReleaseImage, hostedNamespace string, streamName string) (BootImage, error) {
var rootVolume *hyperv1.KubevirtRootVolume
isHTTP := false
if nodePool.Spec.Platform.Kubevirt != nil {
Expand All @@ -90,9 +94,9 @@ func GetImage(nodePool *hyperv1.NodePool, releaseImage *releaseinfo.ReleaseImage
return newBootImage(imageName, isHTTP), nil
}

imageName, imageHash, err := defaultImage(nodePool.Spec.Arch, releaseImage)
imageName, imageHash, err := defaultImage(nodePool.Spec.Arch, releaseImage, streamName)
if err != nil && allowUnsupportedRHCOSVariants(nodePool) {
imageName, imageHash, err = openstack.OpenstackDefaultImage(releaseImage)
imageName, imageHash, err = openstack.OpenstackDefaultImage(releaseImage, streamName)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -351,7 +355,7 @@ func shouldAttachDefaultNetwork(kvPlatform *hyperv1.KubevirtNodePoolPlatform) bo
return kvPlatform.AttachDefaultNetwork == nil || *kvPlatform.AttachDefaultNetwork
}

func MachineTemplateSpec(nodePool *hyperv1.NodePool, hcluster *hyperv1.HostedCluster, releaseImage *releaseinfo.ReleaseImage, bootImage BootImage) (*capikubevirt.KubevirtMachineTemplateSpec, error) {
func MachineTemplateSpec(nodePool *hyperv1.NodePool, hcluster *hyperv1.HostedCluster, releaseImage *releaseinfo.ReleaseImage, bootImage BootImage, streamName string) (*capikubevirt.KubevirtMachineTemplateSpec, error) {
if bootImage == nil {
infraNS := manifests.HostedControlPlaneNamespace(hcluster.Namespace, hcluster.Name)
if hcluster.Spec.Platform.Kubevirt != nil &&
Expand All @@ -361,7 +365,7 @@ func MachineTemplateSpec(nodePool *hyperv1.NodePool, hcluster *hyperv1.HostedClu
infraNS = hcluster.Spec.Platform.Kubevirt.Credentials.InfraNamespace
}
var err error
bootImage, err = GetImage(nodePool, releaseImage, infraNS)
bootImage, err = GetImage(nodePool, releaseImage, infraNS, streamName)
if err != nil {
return nil, fmt.Errorf("couldn't discover a KubeVirt Image in release payload image: %w", err)
}
Expand Down
29 changes: 26 additions & 3 deletions hypershift-operator/controllers/nodepool/kubevirt/kubevirt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,7 @@ func TestKubevirtMachineTemplate(t *testing.T) {
}

bootImage := newCachedBootImage(bootImageName, imageHash, hostedClusterNamespace, false, np)
result, err := MachineTemplateSpec(tc.nodePool, tc.hcluster, &releaseinfo.ReleaseImage{}, bootImage)
result, err := MachineTemplateSpec(tc.nodePool, tc.hcluster, &releaseinfo.ReleaseImage{}, bootImage, "")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result).To(Equal(tc.expected), "Comparison failed\n%v", cmp.Diff(tc.expected, result))
})
Expand Down Expand Up @@ -1189,7 +1189,7 @@ func TestJsonPatch(t *testing.T) {

bootImage := newCachedBootImage(bootImageName, imageHash, hostedClusterNamespace, false, nil)
bootImage.dvName = bootImageNamePrefix + "12345"
result, err := MachineTemplateSpec(tc.nodePool, tc.hcluster, &releaseinfo.ReleaseImage{}, bootImage)
result, err := MachineTemplateSpec(tc.nodePool, tc.hcluster, &releaseinfo.ReleaseImage{}, bootImage, "")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result).To(Equal(tc.expected), "Comparison failed\n%v", cmp.Diff(tc.expected, result))
})
Expand Down Expand Up @@ -1535,6 +1535,7 @@ func TestDefaultImage(t *testing.T) {
tests := []struct {
name string
arch string
streamName string
releaseImage *releaseinfo.ReleaseImage
expectedImage string
expectedDigest string
Expand Down Expand Up @@ -1572,6 +1573,28 @@ func TestDefaultImage(t *testing.T) {
expectedImage: "quay.io/openshift/release@sha256:x86_641234",
expectedDigest: "sha256:x86_641234",
},
{
name: "When named stream is used with multi-stream ReleaseImage it should resolve from the named stream",
arch: hyperv1.ArchitectureAMD64,
streamName: "rhel-9",
releaseImage: &releaseinfo.ReleaseImage{
OSStreams: map[string]*stream.Stream{
"rhel-9": {
Architectures: map[string]stream.Arch{
hyperv1.ArchAliases[hyperv1.ArchitectureAMD64]: {
Images: stream.Images{
KubeVirt: &stream.ContainerImage{
DigestRef: "quay.io/openshift/release@sha256:rhel9kubevirt",
},
},
},
},
},
},
},
expectedImage: "quay.io/openshift/release@sha256:rhel9kubevirt",
expectedDigest: "sha256:rhel9kubevirt",
},
}

for _, tt := range tests {
Expand All @@ -1580,7 +1603,7 @@ func TestDefaultImage(t *testing.T) {
if testRI == nil {
testRI = ri
}
img, digest, err := defaultImage(tt.arch, testRI)
img, digest, err := defaultImage(tt.arch, testRI, tt.streamName)
if tt.expectedError {
if err == nil {
t.Fatalf("expected error but got nil")
Expand Down
Loading