From 97ee29105a82a53d6f66c893763c9f88fb173b28 Mon Sep 17 00:00:00 2001 From: Juan Manuel Parrilla Madrid Date: Fri, 12 Jun 2026 10:22:18 +0200 Subject: [PATCH 1/3] feat(releaseinfo): add multi-stream CoreOS metadata parsing and stream resolution Add support for parsing the new multi-stream boot image ConfigMap format introduced in OCP 5.0 payloads. The ConfigMap now carries a "streams" key alongside the legacy "stream" key, mapping stream names (rhel-9, rhel-10) to per-architecture boot image metadata. - Update DeserializeImageMetadata to parse both "streams" and "stream" keys, returning the parsed OSStreams map alongside the default metadata - Add OSStreams field to ReleaseImage for holding per-stream metadata - Add StreamForName convenience method on ReleaseImage for stream lookup - Add GetRHELStream pure function implementing the stream resolution table from the dual-stream RHEL enhancement - Add 5.0 boot image fixture extracted from 5.0.0-ec.2 release payload - Fallback: when ConfigMap has only "streams" without "stream", populate StreamMetadata from the first available stream to prevent nil panics in platform consumers Signed-off-by: Juan Manuel Parrilla Madrid Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Juan Manuel Parrilla Madrid --- .../controllers/nodepool/stream.go | 48 + .../controllers/nodepool/stream_test.go | 171 +++ support/releaseinfo/deserialize.go | 30 +- support/releaseinfo/deserialize_test.go | 267 ++++- .../5.0-installer-coreos-bootimages.yaml | 1047 +++++++++++++++++ support/releaseinfo/fixtures/fixtures.go | 3 + .../releaseinfo/registry_mirror_provider.go | 1 + .../releaseinfo/registryclient_provider.go | 12 +- support/releaseinfo/releaseinfo.go | 30 + support/releaseinfo/releaseinfo_test.go | 183 ++- 10 files changed, 1767 insertions(+), 25 deletions(-) create mode 100644 hypershift-operator/controllers/nodepool/stream.go create mode 100644 hypershift-operator/controllers/nodepool/stream_test.go create mode 100644 support/releaseinfo/fixtures/5.0-installer-coreos-bootimages.yaml diff --git a/hypershift-operator/controllers/nodepool/stream.go b/hypershift-operator/controllers/nodepool/stream.go new file mode 100644 index 000000000000..06068811f1ca --- /dev/null +++ b/hypershift-operator/controllers/nodepool/stream.go @@ -0,0 +1,48 @@ +package nodepool + +import ( + "fmt" + + "github.com/blang/semver" +) + +const ( + StreamRHEL9 = "rhel-9" + StreamRHEL10 = "rhel-10" +) + +// GetRHELStream resolves which RHEL CoreOS stream a NodePool should use. +// Returns the resolved stream name, or an error for invalid combinations. +// An empty return means "use legacy single-stream behavior" (OCP 4.x). +// Exported for use by integration tests and future Phase 2 consumers +// (token secret plumbing, validMachineConfigCondition). +func GetRHELStream(explicitStream string, releaseVersion semver.Version, usesRunc bool) (string, error) { + isOCP5Plus := releaseVersion.Major >= 5 + + if explicitStream != "" { + switch explicitStream { + case StreamRHEL9: + return StreamRHEL9, nil + case StreamRHEL10: + if !isOCP5Plus { + return "", fmt.Errorf("stream %q requires OCP >= 5.0, but release version is %s", explicitStream, releaseVersion) + } + if usesRunc { + return "", fmt.Errorf("stream %q is incompatible with runc: RHEL 10 does not ship runc", explicitStream) + } + return StreamRHEL10, nil + default: + return "", fmt.Errorf("unknown RHEL stream %q; valid values are %q and %q", explicitStream, StreamRHEL9, StreamRHEL10) + } + } + + if !isOCP5Plus { + return "", nil + } + + if usesRunc { + return StreamRHEL9, nil + } + + return StreamRHEL10, nil +} diff --git a/hypershift-operator/controllers/nodepool/stream_test.go b/hypershift-operator/controllers/nodepool/stream_test.go new file mode 100644 index 000000000000..202057cabb73 --- /dev/null +++ b/hypershift-operator/controllers/nodepool/stream_test.go @@ -0,0 +1,171 @@ +package nodepool + +import ( + "testing" + + . "github.com/onsi/gomega" + + "github.com/blang/semver" +) + +func TestGetRHELStream(t *testing.T) { + tests := []struct { + name string + explicitStream string + releaseVersion semver.Version + usesRunc bool + expectResult string + expectError bool + }{ + // --- Implicit stream (explicitStream = "") --- + { + name: "When no explicit stream and release is 4.x it should return empty string", + explicitStream: "", + releaseVersion: semver.MustParse("4.18.0"), + expectResult: "", + }, + { + name: "When no explicit stream and release is 4.x with runc it should return empty string", + explicitStream: "", + releaseVersion: semver.MustParse("4.19.0"), + usesRunc: true, + expectResult: "", + }, + { + name: "When no explicit stream and release is 5.0 it should return rhel-10", + explicitStream: "", + releaseVersion: semver.MustParse("5.0.0"), + expectResult: "rhel-10", + }, + { + name: "When no explicit stream and release is 5.0 with runc it should return rhel-9", + explicitStream: "", + releaseVersion: semver.MustParse("5.0.0"), + usesRunc: true, + expectResult: "rhel-9", + }, + { + name: "When no explicit stream and release is 5.1 it should return rhel-10", + explicitStream: "", + releaseVersion: semver.MustParse("5.1.0"), + expectResult: "rhel-10", + }, + { + name: "When no explicit stream and release is 5.1 with runc it should return rhel-9", + explicitStream: "", + releaseVersion: semver.MustParse("5.1.0"), + usesRunc: true, + expectResult: "rhel-9", + }, + + // --- Explicit rhel-9 --- + { + name: "When explicit rhel-9 and release is 4.x it should return rhel-9", + explicitStream: "rhel-9", + releaseVersion: semver.MustParse("4.18.0"), + expectResult: "rhel-9", + }, + { + name: "When explicit rhel-9 and release is 4.x with runc it should return rhel-9", + explicitStream: "rhel-9", + releaseVersion: semver.MustParse("4.18.0"), + usesRunc: true, + expectResult: "rhel-9", + }, + { + name: "When explicit rhel-9 and release is 5.0 it should return rhel-9", + explicitStream: "rhel-9", + releaseVersion: semver.MustParse("5.0.0"), + expectResult: "rhel-9", + }, + { + name: "When explicit rhel-9 and release is 5.0 with runc it should return rhel-9", + explicitStream: "rhel-9", + releaseVersion: semver.MustParse("5.0.0"), + usesRunc: true, + expectResult: "rhel-9", + }, + { + name: "When explicit rhel-9 and release is 5.1 it should return rhel-9", + explicitStream: "rhel-9", + releaseVersion: semver.MustParse("5.1.0"), + expectResult: "rhel-9", + }, + { + name: "When explicit rhel-9 and release is 5.1 with runc it should return rhel-9", + explicitStream: "rhel-9", + releaseVersion: semver.MustParse("5.1.0"), + usesRunc: true, + expectResult: "rhel-9", + }, + + // --- Explicit rhel-10 --- + { + name: "When explicit rhel-10 and release is 4.x it should return error", + explicitStream: "rhel-10", + releaseVersion: semver.MustParse("4.19.0"), + expectError: true, + }, + { + name: "When explicit rhel-10 and release is 4.x with runc it should return error", + explicitStream: "rhel-10", + releaseVersion: semver.MustParse("4.19.0"), + usesRunc: true, + expectError: true, + }, + { + name: "When explicit rhel-10 and release is 5.0 it should return rhel-10", + explicitStream: "rhel-10", + releaseVersion: semver.MustParse("5.0.0"), + expectResult: "rhel-10", + }, + { + name: "When explicit rhel-10 and release is 5.0 with runc it should return error", + explicitStream: "rhel-10", + releaseVersion: semver.MustParse("5.0.0"), + usesRunc: true, + expectError: true, + }, + { + name: "When explicit rhel-10 and release is 5.1 it should return rhel-10", + explicitStream: "rhel-10", + releaseVersion: semver.MustParse("5.1.0"), + expectResult: "rhel-10", + }, + { + name: "When explicit rhel-10 and release is 5.1 with runc it should return error", + explicitStream: "rhel-10", + releaseVersion: semver.MustParse("5.1.0"), + usesRunc: true, + expectError: true, + }, + + // --- Unknown stream --- + { + name: "When explicit unknown stream and release is 4.x it should return error", + explicitStream: "rhel-8", + releaseVersion: semver.MustParse("4.18.0"), + expectError: true, + }, + { + name: "When explicit unknown stream and release is 5.0 it should return error", + explicitStream: "rhel-8", + releaseVersion: semver.MustParse("5.0.0"), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + result, err := GetRHELStream(tt.explicitStream, tt.releaseVersion, tt.usesRunc) + if tt.expectError { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(Equal(tt.expectResult)) + }) + } +} diff --git a/support/releaseinfo/deserialize.go b/support/releaseinfo/deserialize.go index 0b031a98a3e5..b355eed2d7d6 100644 --- a/support/releaseinfo/deserialize.go +++ b/support/releaseinfo/deserialize.go @@ -21,18 +21,32 @@ func DeserializeImageStream(data []byte) (*imageapi.ImageStream, error) { return &imageStream, nil } -func DeserializeImageMetadata(data []byte) (*stream.Stream, error) { +func DeserializeImageMetadata(data []byte) (*stream.Stream, map[string]*stream.Stream, error) { var coreOSMetaCM corev1.ConfigMap if err := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(data), 100).Decode(&coreOSMetaCM); err != nil { - return nil, fmt.Errorf("couldn't read image lookup data as serialized ConfigMap: %w\nraw data:\n%s", err, string(data)) + return nil, nil, fmt.Errorf("couldn't read image lookup data as serialized ConfigMap: %w\nraw data:\n%s", err, string(data)) } + + var osStreams map[string]*stream.Stream + if streamsData, ok := coreOSMetaCM.Data["streams"]; ok { + if err := json.Unmarshal([]byte(streamsData), &osStreams); err != nil { + return nil, nil, fmt.Errorf("couldn't decode multi-stream metadata: %w\n%s", err, streamsData) + } + } + hasOSStreams := len(osStreams) > 0 + streamData, hasStreamData := coreOSMetaCM.Data["stream"] - if !hasStreamData { - return nil, fmt.Errorf("coreos stream metadata configmap is missing the 'stream' key") + if !hasStreamData && !hasOSStreams { + return nil, nil, fmt.Errorf("coreos stream metadata configmap is missing both 'stream' and 'streams' keys") } - var coreOSMeta stream.Stream - if err := json.Unmarshal([]byte(streamData), &coreOSMeta); err != nil { - return nil, fmt.Errorf("couldn't decode stream metadata data: %w\n%s", err, streamData) + + var coreOSMeta *stream.Stream + if hasStreamData { + coreOSMeta = &stream.Stream{} + if err := json.Unmarshal([]byte(streamData), coreOSMeta); err != nil { + return nil, nil, fmt.Errorf("couldn't decode stream metadata: %w\n%s", err, streamData) + } } - return &coreOSMeta, nil + + return coreOSMeta, osStreams, nil } diff --git a/support/releaseinfo/deserialize_test.go b/support/releaseinfo/deserialize_test.go index 25f357b716c3..4fe46741d6a6 100644 --- a/support/releaseinfo/deserialize_test.go +++ b/support/releaseinfo/deserialize_test.go @@ -1,11 +1,12 @@ package releaseinfo import ( + "fmt" "testing" - "github.com/openshift/hypershift/support/releaseinfo/fixtures" + . "github.com/onsi/gomega" - "github.com/coreos/stream-metadata-go/stream" + "github.com/openshift/hypershift/support/releaseinfo/fixtures" ) func TestDeserializeImageStream(t *testing.T) { @@ -16,22 +17,260 @@ func TestDeserializeImageStream(t *testing.T) { } } +func testConfigMap(dataFields map[string]string) []byte { + cm := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\ndata:\n" + for k, v := range dataFields { + cm += fmt.Sprintf(" %s: %s\n", k, v) + } + return []byte(cm) +} + func TestDeserializeImageMetadata(t *testing.T) { - for _, imageMetadata := range [][]byte{fixtures.CoreOSBootImagesYAML_4_8, fixtures.CoreOSBootImagesYAML_4_10} { - var coreOSMetadata *stream.Stream - coreOSMetadata, err := DeserializeImageMetadata(imageMetadata) - if err != nil { - t.Fatal(err) - } + tests := []struct { + name string + data []byte + expectHasArch bool + expectOSStream bool + expectError bool + }{ + { + name: "When parsing a single-stream 4.8 ConfigMap it should populate Architectures and leave OSStreams nil", + data: fixtures.CoreOSBootImagesYAML_4_8, + expectHasArch: true, + }, + { + name: "When parsing a single-stream 4.10 ConfigMap it should populate Architectures and leave OSStreams nil", + data: fixtures.CoreOSBootImagesYAML_4_10, + expectHasArch: true, + }, + { + name: "When parsing a multi-stream 5.0 ConfigMap it should populate both Architectures and OSStreams", + data: fixtures.CoreOSBootImagesYAML_5_0, + expectHasArch: true, + expectOSStream: true, + }, + { + name: "When ConfigMap has only streams key it should populate OSStreams", + data: testConfigMap(map[string]string{ + "streams": `'{"rhel-9":{"stream":"rhcos-4.21","architectures":{"x86_64":{"artifacts":{},"images":{}}}}}'`, + }), + expectOSStream: true, + }, + { + name: "When ConfigMap is missing both stream and streams keys it should return an error", + data: testConfigMap(map[string]string{"releaseVersion": `"5.0.0"`}), + expectError: true, + }, + { + name: "When stream JSON is invalid it should return an error", + data: testConfigMap(map[string]string{"stream": `"not valid json {"`}), + expectError: true, + }, + { + name: "When streams JSON is invalid it should return an error", + data: testConfigMap(map[string]string{"streams": `"not valid json {"`}), + expectError: true, + }, + { + name: "When input is empty it should return an error", + data: []byte{}, + expectError: true, + }, + { + name: "When input is not valid YAML it should return an error", + data: []byte(`{not yaml at all`), + expectError: true, + }, + { + name: "When streams map is empty it should return an error", + data: testConfigMap(map[string]string{"streams": `"{}"`}), + expectError: true, + }, + { + name: "When streams is valid but stream JSON is invalid it should return an error", + data: testConfigMap(map[string]string{ + "streams": `'{"rhel-9":{"stream":"rhcos-4.21","architectures":{"x86_64":{"artifacts":{},"images":{}}}}}'`, + "stream": `"not valid json {"`, + }), + expectError: true, + }, + } - arch, ok := coreOSMetadata.Architectures["x86_64"] - if !ok { - t.Fatal("missing x86_64 architecture") - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + result, osStreams, err := DeserializeImageMetadata(tt.data) + if tt.expectError { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).ToNot(HaveOccurred()) + + if tt.expectHasArch { + _, hasX86 := result.Architectures["x86_64"] + g.Expect(hasX86).To(BeTrue()) + } + + if tt.expectOSStream { + g.Expect(osStreams).ToNot(BeNil()) + g.Expect(len(osStreams)).To(BeNumerically(">", 0)) + } else { + g.Expect(osStreams).To(BeNil()) + } + }) + } +} - if arch.RHELCoreOSExtensions == nil || arch.RHELCoreOSExtensions.AzureDisk == nil || arch.RHELCoreOSExtensions.AzureDisk.URL == "" { - t.Fatal("missing azure disk URL") +func TestDeserializeImageMetadataStreamsOnlyFallback(t *testing.T) { + g := NewWithT(t) + + data := testConfigMap(map[string]string{ + "streams": `'{"rhel-9":{"stream":"rhcos-4.21","architectures":{"x86_64":{"artifacts":{},"images":{"aws":{"regions":{"us-east-1":{"release":"9.8","image":"ami-fallback"}}}}}}}}'`, + }) + + defaultStream, osStreams, err := DeserializeImageMetadata(data) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(defaultStream).To(BeNil()) + g.Expect(osStreams).ToNot(BeNil()) + + // Simulate the fallback that registryclient_provider.go does: + // when defaultStream is nil, pick the first non-nil stream from osStreams. + if defaultStream == nil && len(osStreams) > 0 { + for _, s := range osStreams { + if s != nil { + defaultStream = s + break + } } + } + + ri := &ReleaseImage{ + StreamMetadata: defaultStream, + OSStreams: osStreams, + } + + t.Run("When ConfigMap has only streams key StreamMetadata should be populated via fallback", func(t *testing.T) { + g := NewWithT(t) + g.Expect(ri.StreamMetadata).ToNot(BeNil()) + g.Expect(ri.StreamMetadata.Architectures).To(HaveKey("x86_64")) + }) + + t.Run("When ConfigMap has only streams key platform consumers should not panic", func(t *testing.T) { + g := NewWithT(t) + ami := ri.StreamMetadata.Architectures["x86_64"].Images.Aws.Regions["us-east-1"].Image + g.Expect(ami).To(Equal("ami-fallback")) + }) +} + +func TestDeserializeImageMetadataMultiStreamContent(t *testing.T) { + defaultStream, osStreams, err := DeserializeImageMetadata(fixtures.CoreOSBootImagesYAML_5_0) + if err != nil { + t.Fatalf("failed to parse 5.0 fixture: %v", err) + } + + tests := []struct { + name string + assert func(g Gomega) + }{ + { + name: "When parsing a 5.0 ConfigMap it should have rhel-9 and rhel-10 in OSStreams", + assert: func(g Gomega) { + g.Expect(osStreams).To(HaveKey("rhel-9")) + g.Expect(osStreams).To(HaveKey("rhel-10")) + }, + }, + { + name: "When looking up rhel-9 stream it should have distinct AWS AMIs from rhel-10", + assert: func(g Gomega) { + rhel9AMI := osStreams["rhel-9"].Architectures["x86_64"].Images.Aws.Regions["us-east-1"].Image + rhel10AMI := osStreams["rhel-10"].Architectures["x86_64"].Images.Aws.Regions["us-east-1"].Image + g.Expect(rhel9AMI).To(Equal("ami-06a6b025350ff1e23")) + g.Expect(rhel10AMI).To(Equal("ami-04b3d999e39d62c5b")) + g.Expect(rhel9AMI).ToNot(Equal(rhel10AMI)) + }, + }, + { + name: "When looking up rhel-9 stream it should have distinct GCP images from rhel-10", + assert: func(g Gomega) { + rhel9GCP := osStreams["rhel-9"].Architectures["x86_64"].Images.Gcp.Name + rhel10GCP := osStreams["rhel-10"].Architectures["x86_64"].Images.Gcp.Name + g.Expect(rhel9GCP).To(Equal("rhcos-9-8-20260403-0-gcp-x86-64")) + g.Expect(rhel10GCP).To(Equal("rhcos-10-2-20260405-0-gcp-x86-64")) + }, + }, + { + name: "When looking up rhel-9 stream it should have distinct KubeVirt images from rhel-10", + assert: func(g Gomega) { + rhel9KV := osStreams["rhel-9"].Architectures["x86_64"].Images.KubeVirt.DigestRef + rhel10KV := osStreams["rhel-10"].Architectures["x86_64"].Images.KubeVirt.DigestRef + g.Expect(rhel9KV).ToNot(BeEmpty()) + g.Expect(rhel10KV).ToNot(BeEmpty()) + g.Expect(rhel9KV).ToNot(Equal(rhel10KV)) + }, + }, + { + name: "When looking up streams both rhel-9 and rhel-10 should have ppc64le architecture", + assert: func(g Gomega) { + _, rhel9HasPPC := osStreams["rhel-9"].Architectures["ppc64le"] + _, rhel10HasPPC := osStreams["rhel-10"].Architectures["ppc64le"] + g.Expect(rhel9HasPPC).To(BeTrue()) + g.Expect(rhel10HasPPC).To(BeTrue()) + }, + }, + { + name: "When looking up a non-existent stream it should not exist in OSStreams", + assert: func(g Gomega) { + _, exists := osStreams["rhel-8"] + g.Expect(exists).To(BeFalse()) + }, + }, + { + name: "When the default stream is parsed it should match the rhel-9 stream content", + assert: func(g Gomega) { + defaultAMI := defaultStream.Architectures["x86_64"].Images.Aws.Regions["us-east-1"].Image + rhel9AMI := osStreams["rhel-9"].Architectures["x86_64"].Images.Aws.Regions["us-east-1"].Image + g.Expect(defaultAMI).To(Equal(rhel9AMI)) + }, + }, + { + name: "When looking up stream names it should report correct coreos stream identifiers", + assert: func(g Gomega) { + g.Expect(osStreams["rhel-9"].Stream).To(Equal("rhcos-4.21")) + g.Expect(osStreams["rhel-10"].Stream).To(Equal("rhcos-4.22")) + }, + }, + { + name: "When looking up aarch64 architecture it should have distinct AMIs between streams", + assert: func(g Gomega) { + rhel9ARM := osStreams["rhel-9"].Architectures["aarch64"].Images.Aws.Regions["us-east-1"].Image + rhel10ARM := osStreams["rhel-10"].Architectures["aarch64"].Images.Aws.Regions["us-east-1"].Image + g.Expect(rhel9ARM).To(Equal("ami-0e73a95fb409d8abd")) + g.Expect(rhel10ARM).To(Equal("ami-0d7237e6b04d9a9e1")) + g.Expect(rhel9ARM).ToNot(Equal(rhel10ARM)) + }, + }, + { + name: "When looking up Azure marketplace data rhel-10 should have no no-purchase-plan entries", + assert: func(g Gomega) { + rhel9Ext := osStreams["rhel-9"].Architectures["x86_64"].RHELCoreOSExtensions + rhel10Ext := osStreams["rhel-10"].Architectures["x86_64"].RHELCoreOSExtensions + g.Expect(rhel9Ext).ToNot(BeNil()) + g.Expect(rhel9Ext.Marketplace).ToNot(BeNil()) + g.Expect(rhel9Ext.Marketplace.Azure).ToNot(BeNil()) + g.Expect(rhel9Ext.Marketplace.Azure.NoPurchasePlan).ToNot(BeNil()) + g.Expect(rhel9Ext.Marketplace.Azure.NoPurchasePlan.Gen2).ToNot(BeNil()) + if rhel10Ext != nil && rhel10Ext.Marketplace != nil && rhel10Ext.Marketplace.Azure != nil && rhel10Ext.Marketplace.Azure.NoPurchasePlan != nil { + g.Expect(rhel10Ext.Marketplace.Azure.NoPurchasePlan.Gen1).To(BeNil()) + g.Expect(rhel10Ext.Marketplace.Azure.NoPurchasePlan.Gen2).To(BeNil()) + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.assert(NewWithT(t)) + }) } } diff --git a/support/releaseinfo/fixtures/5.0-installer-coreos-bootimages.yaml b/support/releaseinfo/fixtures/5.0-installer-coreos-bootimages.yaml new file mode 100644 index 000000000000..860547376504 --- /dev/null +++ b/support/releaseinfo/fixtures/5.0-installer-coreos-bootimages.yaml @@ -0,0 +1,1047 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: coreos-bootimages + namespace: openshift-machine-config-operator +data: + releaseVersion: 5.0.0-ec.2 + stream: |- + { + "stream": "rhcos-4.21", + "architectures": { + "x86_64": { + "artifacts": { + "openstack": { + "release": "9.8.20260403-0", + "formats": { + "qcow2.gz": { + "disk": { + "location": "https://rhcos.mirror.openshift.com/art/storage/prod/streams/rhel-9.8/builds/9.8.20260403-0/x86_64/rhcos-9.8.20260403-0-openstack.x86_64.qcow2.gz", + "sha256": "d774960b396372c0ed2ea00a4d98151b1cfea9c5bcfc106d3e51566b4e9cce33", + "uncompressed-sha256": "4664af3a371164888f096806e4b6e887f49e74d36e01c3a3a187600143a7d6e3" + } + } + } + } + }, + "images": { + "aws": { + "regions": { + "us-east-1": { + "release": "9.8.20260403-0", + "image": "ami-06a6b025350ff1e23" + }, + "us-west-2": { + "release": "9.8.20260403-0", + "image": "ami-087857c3acb318eac" + } + } + }, + "gcp": { + "release": "9.8.20260403-0", + "project": "rhcos-cloud", + "name": "rhcos-9-8-20260403-0-gcp-x86-64" + }, + "kubevirt": { + "release": "9.8.20260403-0", + "image": "quay.io/openshift-release-dev/ocp-v4.0-art-dev:rhel-9.8-coreos-kubevirt", + "digest-ref": "quay.io/openshift-release-dev/ocp-v4.0-art-dev@sha256:833cbd9ad76a1dfae732741380fbca39220e9b123123995cc93ba0702a497d65" + } + }, + "rhel-coreos-extensions": { + "azure-disk": { + "release": "9.8.20260403-0", + "url": "https://rhcos.blob.core.windows.net/imagebucket/rhcos-9.8.20260403-0-azure.x86_64.vhd" + }, + "marketplace": { + "azure": { + "no-purchase-plan": { + "hyperVGen1": { + "publisher": "azureopenshift", + "offer": "aro4", + "sku": "aro_420", + "version": "9.6.20251015" + }, + "hyperVGen2": { + "publisher": "azureopenshift", + "offer": "aro4", + "sku": "420-v2", + "version": "9.6.20251015" + } + }, + "ocp": { + "hyperVGen1": { + "publisher": "redhat", + "offer": "rh-ocp-worker", + "sku": "rh-ocp-worker-gen1", + "version": "4.18.2025031114" + }, + "hyperVGen2": { + "publisher": "redhat", + "offer": "rh-ocp-worker", + "sku": "rh-ocp-worker", + "version": "4.18.2025031114" + } + }, + "opp": { + "hyperVGen1": { + "publisher": "redhat", + "offer": "rh-opp-worker", + "sku": "rh-opp-worker-gen1", + "version": "4.18.2025031114" + }, + "hyperVGen2": { + "publisher": "redhat", + "offer": "rh-opp-worker", + "sku": "rh-opp-worker", + "version": "4.18.2025031114" + } + }, + "oke": { + "hyperVGen1": { + "publisher": "redhat", + "offer": "rh-oke-worker", + "sku": "rh-oke-worker-gen1", + "version": "4.18.2025031114" + }, + "hyperVGen2": { + "publisher": "redhat", + "offer": "rh-oke-worker", + "sku": "rh-oke-worker", + "version": "4.18.2025031114" + } + }, + "ocp-emea": { + "hyperVGen1": { + "publisher": "redhat-limited", + "offer": "rh-ocp-worker", + "sku": "rh-ocp-worker-gen1", + "version": "4.18.2025031114" + }, + "hyperVGen2": { + "publisher": "redhat-limited", + "offer": "rh-ocp-worker", + "sku": "rh-ocp-worker", + "version": "4.18.2025031114" + } + }, + "opp-emea": { + "hyperVGen1": { + "publisher": "redhat-limited", + "offer": "rh-opp-worker", + "sku": "rh-opp-worker-gen1", + "version": "4.18.2025031114" + }, + "hyperVGen2": { + "publisher": "redhat-limited", + "offer": "rh-opp-worker", + "sku": "rh-opp-worker", + "version": "4.18.2025031114" + } + }, + "oke-emea": { + "hyperVGen1": { + "publisher": "redhat-limited", + "offer": "rh-oke-worker", + "sku": "rh-oke-worker-gen1", + "version": "4.18.2025031114" + }, + "hyperVGen2": { + "publisher": "redhat-limited", + "offer": "rh-oke-worker", + "sku": "rh-oke-worker", + "version": "4.18.2025031114" + } + } + } + }, + "aws-winli": { + "regions": { + "af-south-1": { + "release": "9.8.20260403-0", + "image": "ami-0c10eabe1eaf19070" + }, + "ap-east-1": { + "release": "9.8.20260403-0", + "image": "ami-0000a100764a57637" + }, + "ap-east-2": { + "release": "9.8.20260403-0", + "image": "ami-0d0a9b4f768e40721" + }, + "ap-northeast-1": { + "release": "9.8.20260403-0", + "image": "ami-06bccded362a4e123" + }, + "ap-northeast-2": { + "release": "9.8.20260403-0", + "image": "ami-074a3c1013b645e2b" + }, + "ap-northeast-3": { + "release": "9.8.20260403-0", + "image": "ami-0a973131ad159455f" + }, + "ap-south-1": { + "release": "9.8.20260403-0", + "image": "ami-01a91ed16e6fe8c42" + }, + "ap-south-2": { + "release": "9.8.20260403-0", + "image": "ami-088c4f18c1ba6d16f" + }, + "ap-southeast-1": { + "release": "9.8.20260403-0", + "image": "ami-0aa3b2c76834dfc35" + }, + "ap-southeast-2": { + "release": "9.8.20260403-0", + "image": "ami-0e3287c7017d769e6" + }, + "ap-southeast-3": { + "release": "9.8.20260403-0", + "image": "ami-01a6db14a61da69d2" + }, + "ap-southeast-4": { + "release": "9.8.20260403-0", + "image": "ami-06bfcd79e173b66fc" + }, + "ap-southeast-5": { + "release": "9.8.20260403-0", + "image": "ami-05d555e5f6ca0351b" + }, + "ap-southeast-6": { + "release": "9.8.20260403-0", + "image": "ami-003fa9bac974b122f" + }, + "ap-southeast-7": { + "release": "9.8.20260403-0", + "image": "ami-0a4ae56adf95a7571" + }, + "ca-central-1": { + "release": "9.8.20260403-0", + "image": "ami-0753bb5559cfe8de7" + }, + "ca-west-1": { + "release": "9.8.20260403-0", + "image": "ami-0cdc9f4fd19c77c20" + }, + "eu-central-1": { + "release": "9.8.20260403-0", + "image": "ami-070d80434ccbb1fa8" + }, + "eu-central-2": { + "release": "9.8.20260403-0", + "image": "ami-0c9b3aaa71a563f6d" + }, + "eu-north-1": { + "release": "9.8.20260403-0", + "image": "ami-061e33ce3cf0d8f30" + }, + "eu-south-1": { + "release": "9.8.20260403-0", + "image": "ami-060a1299d1a87244e" + }, + "eu-south-2": { + "release": "9.8.20260403-0", + "image": "ami-09d986ef8bc88b3ce" + }, + "eu-west-1": { + "release": "9.8.20260403-0", + "image": "ami-0abacf8acd414929a" + }, + "eu-west-2": { + "release": "9.8.20260403-0", + "image": "ami-0d52292d89797f1dc" + }, + "eu-west-3": { + "release": "9.8.20260403-0", + "image": "ami-08cf1b6d46bea0b36" + }, + "il-central-1": { + "release": "9.8.20260403-0", + "image": "ami-08f660deb548ef5d3" + }, + "mx-central-1": { + "release": "9.8.20260403-0", + "image": "ami-028dd27c939e3d1e9" + }, + "sa-east-1": { + "release": "9.8.20260403-0", + "image": "ami-0cbff95e01bb72566" + }, + "us-east-1": { + "release": "9.8.20260403-0", + "image": "ami-07fb473b3b815c46a" + }, + "us-east-2": { + "release": "9.8.20260403-0", + "image": "ami-0b57bb61d7bd17440" + }, + "us-west-1": { + "release": "9.8.20260403-0", + "image": "ami-0ab712daedccb957a" + }, + "us-west-2": { + "release": "9.8.20260403-0", + "image": "ami-076797a55a7d516dc" + } + } + } + } + }, + "aarch64": { + "artifacts": { + "openstack": { + "release": "9.8.20260403-0", + "formats": { + "qcow2.gz": { + "disk": { + "location": "https://rhcos.mirror.openshift.com/art/storage/prod/streams/rhel-9.8/builds/9.8.20260403-0/aarch64/rhcos-9.8.20260403-0-openstack.aarch64.qcow2.gz", + "sha256": "9132bed288761f6df76605bcccd2ac0c58a371ca64097e5fb876e01f3c20dfe2", + "uncompressed-sha256": "d6ae130c04e2347994a73a05080e857622cf9ea0ed424b01cd9181ef885cb451" + } + } + } + } + }, + "images": { + "aws": { + "regions": { + "us-east-1": { + "release": "9.8.20260403-0", + "image": "ami-0e73a95fb409d8abd" + }, + "us-west-2": { + "release": "9.8.20260403-0", + "image": "ami-0f4439b005ce8bb83" + } + } + }, + "gcp": { + "release": "9.8.20260403-0", + "project": "rhcos-cloud", + "name": "rhcos-9-8-20260403-0-gcp-aarch64" + } + }, + "rhel-coreos-extensions": { + "azure-disk": { + "release": "9.8.20260403-0", + "url": "https://rhcos.blob.core.windows.net/imagebucket/rhcos-9.8.20260403-0-azure.aarch64.vhd" + }, + "marketplace": { + "azure": { + "no-purchase-plan": { + "hyperVGen2": { + "publisher": "azureopenshift", + "offer": "aro4", + "sku": "420-arm", + "version": "9.6.20251015" + } + } + } + }, + "aws-winli": { + "regions": {} + } + } + }, + "ppc64le": { + "artifacts": { + "openstack": { + "release": "9.8.20260403-0", + "formats": { + "qcow2.gz": { + "disk": { + "location": "https://rhcos.mirror.openshift.com/art/storage/prod/streams/rhel-9.8/builds/9.8.20260403-0/ppc64le/rhcos-9.8.20260403-0-openstack.ppc64le.qcow2.gz", + "sha256": "098d862bbe8ea4bade551bbb7d0b1b39c009c51bc6e07a360fd5f21ef15a5781", + "uncompressed-sha256": "7088202adf16433b1fdf82f75d276b39e12559f0e80564ef26bda1558557d92f" + } + } + } + } + }, + "images": { + "powervs": { + "regions": { + "au-syd": { + "release": "9.8.20260403-0", + "object": "rhcos-9-8-20260403-0-ppc64le-powervs.ova.gz", + "bucket": "rhcos-powervs-images-au-syd", + "url": "https://s3.au-syd.cloud-object-storage.appdomain.cloud/rhcos-powervs-images-au-syd/rhcos-9-8-20260403-0-ppc64le-powervs.ova.gz" + } + } + } + }, + "rhel-coreos-extensions": { + "azure-disk": {}, + "marketplace": { + "azure": { + "no-purchase-plan": {} + } + }, + "aws-winli": { + "regions": {} + } + } + } + } + } + streams: |- + { + "rhel-10": { + "stream": "rhcos-4.22", + "architectures": { + "x86_64": { + "artifacts": { + "openstack": { + "release": "10.2.20260405-0", + "formats": { + "qcow2.gz": { + "disk": { + "location": "https://rhcos.mirror.openshift.com/art/storage/prod/streams/rhel-10.2/builds/10.2.20260405-0/x86_64/rhcos-10.2.20260405-0-openstack.x86_64.qcow2.gz", + "sha256": "baa7d50598d76c76e65342d38fd5e90e34e9874ae1f6e82c3f2ad8303747a3ca", + "uncompressed-sha256": "a502fba4cc14d2c057f0f37635b9269d78bce51e2f0ec4d51d2ba18f33abcd8a" + } + } + } + } + }, + "images": { + "aws": { + "regions": { + "us-east-1": { + "release": "10.2.20260405-0", + "image": "ami-04b3d999e39d62c5b" + }, + "us-west-2": { + "release": "10.2.20260405-0", + "image": "ami-07e365adf44ca432a" + } + } + }, + "gcp": { + "release": "10.2.20260405-0", + "project": "rhcos-cloud", + "name": "rhcos-10-2-20260405-0-gcp-x86-64" + }, + "kubevirt": { + "release": "10.2.20260405-0", + "image": "quay.io/openshift-release-dev/ocp-v4.0-art-dev:rhel-10.2-coreos-kubevirt", + "digest-ref": "quay.io/openshift-release-dev/ocp-v4.0-art-dev@sha256:843e75c16c01218cae95c74a879934a1f3ad80a2149963510a51b1f45c712c56" + } + }, + "rhel-coreos-extensions": { + "azure-disk": { + "release": "10.2.20260405-0", + "url": "https://rhcos.blob.core.windows.net/imagebucket/rhcos-10.2.20260405-0-azure.x86_64.vhd" + }, + "marketplace": { + "azure": { + "no-purchase-plan": {} + } + }, + "aws-winli": { + "regions": { + "af-south-1": { + "release": "10.2.20260405-0", + "image": "ami-0f00691d8919286c4" + }, + "ap-east-1": { + "release": "10.2.20260405-0", + "image": "ami-02085d30468be6719" + }, + "ap-east-2": { + "release": "10.2.20260405-0", + "image": "ami-0b96642864753f697" + }, + "ap-northeast-1": { + "release": "10.2.20260405-0", + "image": "ami-0b5b4c2fe3e88be4c" + }, + "ap-northeast-2": { + "release": "10.2.20260405-0", + "image": "ami-0be7d6848e2b14148" + }, + "ap-northeast-3": { + "release": "10.2.20260405-0", + "image": "ami-03fb1d841af122247" + }, + "ap-south-1": { + "release": "10.2.20260405-0", + "image": "ami-0609e9fa550a150e4" + }, + "ap-south-2": { + "release": "10.2.20260405-0", + "image": "ami-0036e48df55f3a921" + }, + "ap-southeast-1": { + "release": "10.2.20260405-0", + "image": "ami-03f094834eda476c7" + }, + "ap-southeast-2": { + "release": "10.2.20260405-0", + "image": "ami-00cfb331b864e5fbe" + }, + "ap-southeast-3": { + "release": "10.2.20260405-0", + "image": "ami-0042f8ac85b52471b" + }, + "ap-southeast-4": { + "release": "10.2.20260405-0", + "image": "ami-0824247384380ad67" + }, + "ap-southeast-5": { + "release": "10.2.20260405-0", + "image": "ami-048aacf6d224a325c" + }, + "ap-southeast-6": { + "release": "10.2.20260405-0", + "image": "ami-03253e81cb2e25e2e" + }, + "ap-southeast-7": { + "release": "10.2.20260405-0", + "image": "ami-01cb1c80408928303" + }, + "ca-central-1": { + "release": "10.2.20260405-0", + "image": "ami-0589bafba30fe4c4b" + }, + "ca-west-1": { + "release": "10.2.20260405-0", + "image": "ami-0f4dc7e47df2182a4" + }, + "eu-central-1": { + "release": "10.2.20260405-0", + "image": "ami-0d2f1dd6ae6c1fdbe" + }, + "eu-central-2": { + "release": "10.2.20260405-0", + "image": "ami-032877114a3719bda" + }, + "eu-north-1": { + "release": "10.2.20260405-0", + "image": "ami-031b0c6271a807e8e" + }, + "eu-south-1": { + "release": "10.2.20260405-0", + "image": "ami-0690f5cbf014a02d0" + }, + "eu-south-2": { + "release": "10.2.20260405-0", + "image": "ami-09ae00621ff968d46" + }, + "eu-west-1": { + "release": "10.2.20260405-0", + "image": "ami-09b17993cb4bf253c" + }, + "eu-west-2": { + "release": "10.2.20260405-0", + "image": "ami-02dff4f6a9f6c5ff2" + }, + "eu-west-3": { + "release": "10.2.20260405-0", + "image": "ami-0d1d7fc52c042403b" + }, + "il-central-1": { + "release": "10.2.20260405-0", + "image": "ami-03bb42b35d1200c2a" + }, + "mx-central-1": { + "release": "10.2.20260405-0", + "image": "ami-06bb2a8ffc898e0ad" + }, + "sa-east-1": { + "release": "10.2.20260405-0", + "image": "ami-0fe32d1a681daeb35" + }, + "us-east-1": { + "release": "10.2.20260405-0", + "image": "ami-0d7d1fceead28e9d8" + }, + "us-east-2": { + "release": "10.2.20260405-0", + "image": "ami-01125c8cf99da2698" + }, + "us-west-1": { + "release": "10.2.20260405-0", + "image": "ami-07c4334822b5d5674" + }, + "us-west-2": { + "release": "10.2.20260405-0", + "image": "ami-086ae350505bf8d38" + } + } + } + } + }, + "aarch64": { + "artifacts": { + "openstack": { + "release": "10.2.20260405-0", + "formats": { + "qcow2.gz": { + "disk": { + "location": "https://rhcos.mirror.openshift.com/art/storage/prod/streams/rhel-10.2/builds/10.2.20260405-0/aarch64/rhcos-10.2.20260405-0-openstack.aarch64.qcow2.gz", + "sha256": "6d534cd2f188970920ff2b506321ce947c4520992c9608d0a7ad812a9d059c1f", + "uncompressed-sha256": "e241eef78bd60d6a24b2fb8d2abaad91ef94f48ee0b7ecee570f169c174bb923" + } + } + } + } + }, + "images": { + "aws": { + "regions": { + "us-east-1": { + "release": "10.2.20260405-0", + "image": "ami-0d7237e6b04d9a9e1" + }, + "us-west-2": { + "release": "10.2.20260405-0", + "image": "ami-067c61e7fb4d54a67" + } + } + }, + "gcp": { + "release": "10.2.20260405-0", + "project": "rhcos-cloud", + "name": "rhcos-10-2-20260405-0-gcp-aarch64" + } + }, + "rhel-coreos-extensions": { + "azure-disk": { + "release": "10.2.20260405-0", + "url": "https://rhcos.blob.core.windows.net/imagebucket/rhcos-10.2.20260405-0-azure.aarch64.vhd" + }, + "marketplace": { + "azure": { + "no-purchase-plan": {} + } + }, + "aws-winli": { + "regions": {} + } + } + }, + "ppc64le": { + "artifacts": { + "openstack": { + "release": "10.2.20260405-0", + "formats": { + "qcow2.gz": { + "disk": { + "location": "https://rhcos.mirror.openshift.com/art/storage/prod/streams/rhel-10.2/builds/10.2.20260405-0/ppc64le/rhcos-10.2.20260405-0-openstack.ppc64le.qcow2.gz", + "sha256": "8c1edfc4372ad8c544103c512a8fe1b856c2dfc87cbefd4100c3d46c9e849a90", + "uncompressed-sha256": "918dac1deee062bd20eb3af6ab8134a73f9bfdd7bd071fe9f774967126373b8e" + } + } + } + } + }, + "images": { + "powervs": { + "regions": { + "au-syd": { + "release": "10.2.20260405-0", + "object": "rhcos-10-2-20260405-0-ppc64le-powervs.ova.gz", + "bucket": "rhcos-powervs-images-au-syd", + "url": "https://s3.au-syd.cloud-object-storage.appdomain.cloud/rhcos-powervs-images-au-syd/rhcos-10-2-20260405-0-ppc64le-powervs.ova.gz" + } + } + } + }, + "rhel-coreos-extensions": { + "azure-disk": {}, + "marketplace": { + "azure": { + "no-purchase-plan": {} + } + }, + "aws-winli": { + "regions": {} + } + } + } + } + }, + "rhel-9": { + "stream": "rhcos-4.21", + "architectures": { + "x86_64": { + "artifacts": { + "openstack": { + "release": "9.8.20260403-0", + "formats": { + "qcow2.gz": { + "disk": { + "location": "https://rhcos.mirror.openshift.com/art/storage/prod/streams/rhel-9.8/builds/9.8.20260403-0/x86_64/rhcos-9.8.20260403-0-openstack.x86_64.qcow2.gz", + "sha256": "d774960b396372c0ed2ea00a4d98151b1cfea9c5bcfc106d3e51566b4e9cce33", + "uncompressed-sha256": "4664af3a371164888f096806e4b6e887f49e74d36e01c3a3a187600143a7d6e3" + } + } + } + } + }, + "images": { + "aws": { + "regions": { + "us-east-1": { + "release": "9.8.20260403-0", + "image": "ami-06a6b025350ff1e23" + }, + "us-west-2": { + "release": "9.8.20260403-0", + "image": "ami-087857c3acb318eac" + } + } + }, + "gcp": { + "release": "9.8.20260403-0", + "project": "rhcos-cloud", + "name": "rhcos-9-8-20260403-0-gcp-x86-64" + }, + "kubevirt": { + "release": "9.8.20260403-0", + "image": "quay.io/openshift-release-dev/ocp-v4.0-art-dev:rhel-9.8-coreos-kubevirt", + "digest-ref": "quay.io/openshift-release-dev/ocp-v4.0-art-dev@sha256:833cbd9ad76a1dfae732741380fbca39220e9b123123995cc93ba0702a497d65" + } + }, + "rhel-coreos-extensions": { + "azure-disk": { + "release": "9.8.20260403-0", + "url": "https://rhcos.blob.core.windows.net/imagebucket/rhcos-9.8.20260403-0-azure.x86_64.vhd" + }, + "marketplace": { + "azure": { + "no-purchase-plan": { + "hyperVGen1": { + "publisher": "azureopenshift", + "offer": "aro4", + "sku": "aro_420", + "version": "9.6.20251015" + }, + "hyperVGen2": { + "publisher": "azureopenshift", + "offer": "aro4", + "sku": "420-v2", + "version": "9.6.20251015" + } + }, + "ocp": { + "hyperVGen1": { + "publisher": "redhat", + "offer": "rh-ocp-worker", + "sku": "rh-ocp-worker-gen1", + "version": "4.18.2025031114" + }, + "hyperVGen2": { + "publisher": "redhat", + "offer": "rh-ocp-worker", + "sku": "rh-ocp-worker", + "version": "4.18.2025031114" + } + }, + "opp": { + "hyperVGen1": { + "publisher": "redhat", + "offer": "rh-opp-worker", + "sku": "rh-opp-worker-gen1", + "version": "4.18.2025031114" + }, + "hyperVGen2": { + "publisher": "redhat", + "offer": "rh-opp-worker", + "sku": "rh-opp-worker", + "version": "4.18.2025031114" + } + }, + "oke": { + "hyperVGen1": { + "publisher": "redhat", + "offer": "rh-oke-worker", + "sku": "rh-oke-worker-gen1", + "version": "4.18.2025031114" + }, + "hyperVGen2": { + "publisher": "redhat", + "offer": "rh-oke-worker", + "sku": "rh-oke-worker", + "version": "4.18.2025031114" + } + }, + "ocp-emea": { + "hyperVGen1": { + "publisher": "redhat-limited", + "offer": "rh-ocp-worker", + "sku": "rh-ocp-worker-gen1", + "version": "4.18.2025031114" + }, + "hyperVGen2": { + "publisher": "redhat-limited", + "offer": "rh-ocp-worker", + "sku": "rh-ocp-worker", + "version": "4.18.2025031114" + } + }, + "opp-emea": { + "hyperVGen1": { + "publisher": "redhat-limited", + "offer": "rh-opp-worker", + "sku": "rh-opp-worker-gen1", + "version": "4.18.2025031114" + }, + "hyperVGen2": { + "publisher": "redhat-limited", + "offer": "rh-opp-worker", + "sku": "rh-opp-worker", + "version": "4.18.2025031114" + } + }, + "oke-emea": { + "hyperVGen1": { + "publisher": "redhat-limited", + "offer": "rh-oke-worker", + "sku": "rh-oke-worker-gen1", + "version": "4.18.2025031114" + }, + "hyperVGen2": { + "publisher": "redhat-limited", + "offer": "rh-oke-worker", + "sku": "rh-oke-worker", + "version": "4.18.2025031114" + } + } + } + }, + "aws-winli": { + "regions": { + "af-south-1": { + "release": "9.8.20260403-0", + "image": "ami-0c10eabe1eaf19070" + }, + "ap-east-1": { + "release": "9.8.20260403-0", + "image": "ami-0000a100764a57637" + }, + "ap-east-2": { + "release": "9.8.20260403-0", + "image": "ami-0d0a9b4f768e40721" + }, + "ap-northeast-1": { + "release": "9.8.20260403-0", + "image": "ami-06bccded362a4e123" + }, + "ap-northeast-2": { + "release": "9.8.20260403-0", + "image": "ami-074a3c1013b645e2b" + }, + "ap-northeast-3": { + "release": "9.8.20260403-0", + "image": "ami-0a973131ad159455f" + }, + "ap-south-1": { + "release": "9.8.20260403-0", + "image": "ami-01a91ed16e6fe8c42" + }, + "ap-south-2": { + "release": "9.8.20260403-0", + "image": "ami-088c4f18c1ba6d16f" + }, + "ap-southeast-1": { + "release": "9.8.20260403-0", + "image": "ami-0aa3b2c76834dfc35" + }, + "ap-southeast-2": { + "release": "9.8.20260403-0", + "image": "ami-0e3287c7017d769e6" + }, + "ap-southeast-3": { + "release": "9.8.20260403-0", + "image": "ami-01a6db14a61da69d2" + }, + "ap-southeast-4": { + "release": "9.8.20260403-0", + "image": "ami-06bfcd79e173b66fc" + }, + "ap-southeast-5": { + "release": "9.8.20260403-0", + "image": "ami-05d555e5f6ca0351b" + }, + "ap-southeast-6": { + "release": "9.8.20260403-0", + "image": "ami-003fa9bac974b122f" + }, + "ap-southeast-7": { + "release": "9.8.20260403-0", + "image": "ami-0a4ae56adf95a7571" + }, + "ca-central-1": { + "release": "9.8.20260403-0", + "image": "ami-0753bb5559cfe8de7" + }, + "ca-west-1": { + "release": "9.8.20260403-0", + "image": "ami-0cdc9f4fd19c77c20" + }, + "eu-central-1": { + "release": "9.8.20260403-0", + "image": "ami-070d80434ccbb1fa8" + }, + "eu-central-2": { + "release": "9.8.20260403-0", + "image": "ami-0c9b3aaa71a563f6d" + }, + "eu-north-1": { + "release": "9.8.20260403-0", + "image": "ami-061e33ce3cf0d8f30" + }, + "eu-south-1": { + "release": "9.8.20260403-0", + "image": "ami-060a1299d1a87244e" + }, + "eu-south-2": { + "release": "9.8.20260403-0", + "image": "ami-09d986ef8bc88b3ce" + }, + "eu-west-1": { + "release": "9.8.20260403-0", + "image": "ami-0abacf8acd414929a" + }, + "eu-west-2": { + "release": "9.8.20260403-0", + "image": "ami-0d52292d89797f1dc" + }, + "eu-west-3": { + "release": "9.8.20260403-0", + "image": "ami-08cf1b6d46bea0b36" + }, + "il-central-1": { + "release": "9.8.20260403-0", + "image": "ami-08f660deb548ef5d3" + }, + "mx-central-1": { + "release": "9.8.20260403-0", + "image": "ami-028dd27c939e3d1e9" + }, + "sa-east-1": { + "release": "9.8.20260403-0", + "image": "ami-0cbff95e01bb72566" + }, + "us-east-1": { + "release": "9.8.20260403-0", + "image": "ami-07fb473b3b815c46a" + }, + "us-east-2": { + "release": "9.8.20260403-0", + "image": "ami-0b57bb61d7bd17440" + }, + "us-west-1": { + "release": "9.8.20260403-0", + "image": "ami-0ab712daedccb957a" + }, + "us-west-2": { + "release": "9.8.20260403-0", + "image": "ami-076797a55a7d516dc" + } + } + } + } + }, + "aarch64": { + "artifacts": { + "openstack": { + "release": "9.8.20260403-0", + "formats": { + "qcow2.gz": { + "disk": { + "location": "https://rhcos.mirror.openshift.com/art/storage/prod/streams/rhel-9.8/builds/9.8.20260403-0/aarch64/rhcos-9.8.20260403-0-openstack.aarch64.qcow2.gz", + "sha256": "9132bed288761f6df76605bcccd2ac0c58a371ca64097e5fb876e01f3c20dfe2", + "uncompressed-sha256": "d6ae130c04e2347994a73a05080e857622cf9ea0ed424b01cd9181ef885cb451" + } + } + } + } + }, + "images": { + "aws": { + "regions": { + "us-east-1": { + "release": "9.8.20260403-0", + "image": "ami-0e73a95fb409d8abd" + }, + "us-west-2": { + "release": "9.8.20260403-0", + "image": "ami-0f4439b005ce8bb83" + } + } + }, + "gcp": { + "release": "9.8.20260403-0", + "project": "rhcos-cloud", + "name": "rhcos-9-8-20260403-0-gcp-aarch64" + } + }, + "rhel-coreos-extensions": { + "azure-disk": { + "release": "9.8.20260403-0", + "url": "https://rhcos.blob.core.windows.net/imagebucket/rhcos-9.8.20260403-0-azure.aarch64.vhd" + }, + "marketplace": { + "azure": { + "no-purchase-plan": { + "hyperVGen2": { + "publisher": "azureopenshift", + "offer": "aro4", + "sku": "420-arm", + "version": "9.6.20251015" + } + } + } + }, + "aws-winli": { + "regions": {} + } + } + }, + "ppc64le": { + "artifacts": { + "openstack": { + "release": "9.8.20260403-0", + "formats": { + "qcow2.gz": { + "disk": { + "location": "https://rhcos.mirror.openshift.com/art/storage/prod/streams/rhel-9.8/builds/9.8.20260403-0/ppc64le/rhcos-9.8.20260403-0-openstack.ppc64le.qcow2.gz", + "sha256": "098d862bbe8ea4bade551bbb7d0b1b39c009c51bc6e07a360fd5f21ef15a5781", + "uncompressed-sha256": "7088202adf16433b1fdf82f75d276b39e12559f0e80564ef26bda1558557d92f" + } + } + } + } + }, + "images": { + "powervs": { + "regions": { + "au-syd": { + "release": "9.8.20260403-0", + "object": "rhcos-9-8-20260403-0-ppc64le-powervs.ova.gz", + "bucket": "rhcos-powervs-images-au-syd", + "url": "https://s3.au-syd.cloud-object-storage.appdomain.cloud/rhcos-powervs-images-au-syd/rhcos-9-8-20260403-0-ppc64le-powervs.ova.gz" + } + } + } + }, + "rhel-coreos-extensions": { + "azure-disk": {}, + "marketplace": { + "azure": { + "no-purchase-plan": {} + } + }, + "aws-winli": { + "regions": {} + } + } + } + } + } + } diff --git a/support/releaseinfo/fixtures/fixtures.go b/support/releaseinfo/fixtures/fixtures.go index 8604abcb949e..a471d534d472 100644 --- a/support/releaseinfo/fixtures/fixtures.go +++ b/support/releaseinfo/fixtures/fixtures.go @@ -15,3 +15,6 @@ var ImageReferencesJSON_4_10 []byte //go:embed 4.10-installer-coreos-bootimages.yaml var CoreOSBootImagesYAML_4_10 []byte + +//go:embed 5.0-installer-coreos-bootimages.yaml +var CoreOSBootImagesYAML_5_0 []byte diff --git a/support/releaseinfo/registry_mirror_provider.go b/support/releaseinfo/registry_mirror_provider.go index e1b8803ae9fb..429ff018f9bd 100644 --- a/support/releaseinfo/registry_mirror_provider.go +++ b/support/releaseinfo/registry_mirror_provider.go @@ -42,6 +42,7 @@ func (p *RegistryMirrorProviderDecorator) Lookup(ctx context.Context, image stri return &ReleaseImage{ ImageStream: imageStream, StreamMetadata: releaseImage.StreamMetadata, + OSStreams: releaseImage.OSStreams, }, nil } diff --git a/support/releaseinfo/registryclient_provider.go b/support/releaseinfo/registryclient_provider.go index e2b9d7f9f057..708915d7cf85 100644 --- a/support/releaseinfo/registryclient_provider.go +++ b/support/releaseinfo/registryclient_provider.go @@ -36,13 +36,23 @@ func (p *RegistryClientProvider) Lookup(ctx context.Context, image string, pullS if _, ok := fileContents[ReleaseImageMetadataFile]; !ok { return nil, fmt.Errorf("release image metadata file not found in release image %s", image) } - coreOSMeta, err := DeserializeImageMetadata(fileContents[ReleaseImageMetadataFile]) + coreOSMeta, osStreams, err := DeserializeImageMetadata(fileContents[ReleaseImageMetadataFile]) if err != nil { return nil, err } + if coreOSMeta == nil && len(osStreams) > 0 { + for _, s := range osStreams { + if s != nil { + coreOSMeta = s + break + } + } + } + return &ReleaseImage{ ImageStream: imageStream, StreamMetadata: coreOSMeta, + OSStreams: osStreams, }, nil } diff --git a/support/releaseinfo/releaseinfo.go b/support/releaseinfo/releaseinfo.go index 280cd8767618..1bb3049f8223 100644 --- a/support/releaseinfo/releaseinfo.go +++ b/support/releaseinfo/releaseinfo.go @@ -40,6 +40,36 @@ type ProviderWithOpenShiftImageRegistryOverrides interface { type ReleaseImage struct { *imageapi.ImageStream `json:",inline"` StreamMetadata *stream.Stream `json:"streamMetadata"` + // OSStreams holds per-stream metadata parsed from the ConfigMap "streams" key. + // Nil for single-stream payloads (OCP < 5.0). + OSStreams map[string]*stream.Stream `json:"-"` +} + +// StreamForName returns stream metadata by name. If name is empty, returns +// the default stream (StreamMetadata). If name is non-empty, looks up +// OSStreams and returns an error if the named stream is not found. +func (i *ReleaseImage) StreamForName(name string) (*stream.Stream, error) { + if name == "" { + if i.StreamMetadata == nil { + return nil, fmt.Errorf("no default stream metadata available") + } + return i.StreamMetadata, nil + } + if i.OSStreams == nil { + return nil, fmt.Errorf("stream %q not found: no multi-stream metadata available", name) + } + meta, ok := i.OSStreams[name] + if !ok || meta == nil { + available := make([]string, 0, len(i.OSStreams)) + for k, v := range i.OSStreams { + if v != nil { + available = append(available, k) + } + } + sort.Strings(available) + return nil, fmt.Errorf("stream %q not found; available streams: %v", name, available) + } + return meta, nil } func (i *ReleaseImage) Version() string { diff --git a/support/releaseinfo/releaseinfo_test.go b/support/releaseinfo/releaseinfo_test.go index cba7521a5c4f..85274a96fc45 100644 --- a/support/releaseinfo/releaseinfo_test.go +++ b/support/releaseinfo/releaseinfo_test.go @@ -8,6 +8,8 @@ import ( "github.com/openshift/hypershift/support/releaseinfo/fixtures" imageapi "github.com/openshift/api/image/v1" + + "github.com/coreos/stream-metadata-go/stream" ) func TestParseComponentVersionsLabel(t *testing.T) { @@ -164,7 +166,7 @@ func TestReadComponentVersions(t *testing.T) { // TestReleaseInfoPowerVS test validates the presence of the powervs images in the 4.10 release func TestReleaseInfoPowerVS(t *testing.T) { - metadata, err := DeserializeImageMetadata(fixtures.CoreOSBootImagesYAML_4_10) + metadata, _, err := DeserializeImageMetadata(fixtures.CoreOSBootImagesYAML_4_10) if err != nil { t.Fatal(err) } @@ -184,7 +186,7 @@ func TestReleaseInfoPowerVS(t *testing.T) { // TestReleaseInfoKubeVirt tests validates the presence of the kubevirt images func TestReleaseInfoKubeVirt(t *testing.T) { - metadata, err := DeserializeImageMetadata(fixtures.CoreOSBootImagesYAML_4_10) + metadata, _, err := DeserializeImageMetadata(fixtures.CoreOSBootImagesYAML_4_10) if err != nil { t.Fatal(err) } @@ -196,3 +198,180 @@ func TestReleaseInfoKubeVirt(t *testing.T) { t.Fatal("metadata does not contain a digest ref for kubevirt") } } + +func TestStreamForName(t *testing.T) { + rhel9Stream := &stream.Stream{ + Stream: "rhcos-4.21", + Architectures: map[string]stream.Arch{ + "x86_64": {}, + }, + } + rhel10Stream := &stream.Stream{ + Stream: "rhcos-5.0", + Architectures: map[string]stream.Arch{ + "x86_64": {}, + }, + } + + tests := []struct { + name string + releaseImage *ReleaseImage + streamName string + expectStream string + expectError bool + expectContains string + }{ + { + name: "When name is empty it should return the default stream", + releaseImage: &ReleaseImage{ + ImageStream: &imageapi.ImageStream{}, + StreamMetadata: &stream.Stream{ + Stream: "rhcos-4.10", + Architectures: map[string]stream.Arch{"x86_64": {}}, + }, + }, + streamName: "", + expectStream: "rhcos-4.10", + }, + { + name: "When name matches a stream it should return that stream", + releaseImage: &ReleaseImage{ + ImageStream: &imageapi.ImageStream{}, + StreamMetadata: &stream.Stream{ + Stream: "rhcos-4.10", + Architectures: map[string]stream.Arch{"x86_64": {}}, + }, + OSStreams: map[string]*stream.Stream{ + "rhel-9": rhel9Stream, + "rhel-10": rhel10Stream, + }, + }, + streamName: "rhel-10", + expectStream: "rhcos-5.0", + }, + { + name: "When name does not match any stream it should return an error listing available streams", + releaseImage: &ReleaseImage{ + ImageStream: &imageapi.ImageStream{}, + StreamMetadata: &stream.Stream{ + Stream: "rhcos-4.10", + Architectures: map[string]stream.Arch{"x86_64": {}}, + }, + OSStreams: map[string]*stream.Stream{ + "rhel-9": rhel9Stream, + "rhel-10": rhel10Stream, + }, + }, + streamName: "rhel-8", + expectError: true, + expectContains: "rhel-8", + }, + { + name: "When OSStreams is nil and name is non-empty it should return an error", + releaseImage: &ReleaseImage{ + ImageStream: &imageapi.ImageStream{}, + StreamMetadata: &stream.Stream{ + Stream: "rhcos-4.10", + Architectures: map[string]stream.Arch{"x86_64": {}}, + }, + }, + streamName: "rhel-10", + expectError: true, + }, + { + name: "When StreamMetadata is nil and name is empty it should return an error", + releaseImage: &ReleaseImage{ + ImageStream: &imageapi.ImageStream{}, + }, + streamName: "", + expectError: true, + }, + { + name: "When StreamMetadata is nil and OSStreams has entries it should return an error for empty name", + releaseImage: &ReleaseImage{ + ImageStream: &imageapi.ImageStream{}, + OSStreams: map[string]*stream.Stream{ + "rhel-9": rhel9Stream, + }, + }, + streamName: "", + expectError: true, + }, + { + name: "When StreamMetadata is nil and OSStreams has matching entry it should return that stream", + releaseImage: &ReleaseImage{ + ImageStream: &imageapi.ImageStream{}, + OSStreams: map[string]*stream.Stream{ + "rhel-9": rhel9Stream, + }, + }, + streamName: "rhel-9", + expectStream: "rhcos-4.21", + }, + { + name: "When StreamMetadata is nil and OSStreams has no matching entry it should return an error", + releaseImage: &ReleaseImage{ + ImageStream: &imageapi.ImageStream{}, + OSStreams: map[string]*stream.Stream{ + "rhel-9": rhel9Stream, + }, + }, + streamName: "rhel-10", + expectError: true, + expectContains: "rhel-9", + }, + { + name: "When both StreamMetadata and OSStreams are nil it should return an error for non-empty name", + releaseImage: &ReleaseImage{ + ImageStream: &imageapi.ImageStream{}, + }, + streamName: "rhel-10", + expectError: true, + }, + { + name: "When OSStreams is an empty map it should return an error", + releaseImage: &ReleaseImage{ + ImageStream: &imageapi.ImageStream{}, + StreamMetadata: &stream.Stream{ + Stream: "rhcos-4.10", + }, + OSStreams: map[string]*stream.Stream{}, + }, + streamName: "rhel-10", + expectError: true, + }, + { + name: "When OSStreams entry has nil value it should return an error", + releaseImage: &ReleaseImage{ + ImageStream: &imageapi.ImageStream{}, + StreamMetadata: &stream.Stream{ + Stream: "rhcos-4.10", + }, + OSStreams: map[string]*stream.Stream{ + "rhel-10": nil, + "rhel-9": rhel9Stream, + }, + }, + streamName: "rhel-10", + expectError: true, + expectContains: "rhel-9", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + result, err := tt.releaseImage.StreamForName(tt.streamName) + if tt.expectError { + g.Expect(err).To(HaveOccurred()) + if tt.expectContains != "" { + g.Expect(err.Error()).To(ContainSubstring(tt.expectContains)) + } + return + } + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result.Stream).To(Equal(tt.expectStream)) + }) + } +} From 92957ce26fb2490062219d2193f1c6ee84cfee2e Mon Sep 17 00:00:00 2001 From: Juan Manuel Parrilla Madrid Date: Thu, 11 Jun 2026 18:35:08 +0200 Subject: [PATCH 2/3] feat(nodepool): decouple AWS AMI resolution from ReleaseImage Refactor defaultNodePoolAMI() to accept *stream.Stream instead of *releaseinfo.ReleaseImage, decoupling the AWS boot image resolver from the global StreamMetadata. Callers (ResolveAWSAMI, setAWSConditions, setKarpenterAMILabels) resolve the stream internally via StreamForName(""), preserving backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Juan Manuel Parrilla Madrid --- .../controllers/nodepool/aws.go | 19 +- .../nodepool/nodepool_controller.go | 9 +- .../nodepool/nodepool_controller_test.go | 169 +++++++++--------- .../controllers/nodepool/token.go | 6 +- 4 files changed, 112 insertions(+), 91 deletions(-) diff --git a/hypershift-operator/controllers/nodepool/aws.go b/hypershift-operator/controllers/nodepool/aws.go index df5223ff2a9f..729026992765 100644 --- a/hypershift-operator/controllers/nodepool/aws.go +++ b/hypershift-operator/controllers/nodepool/aws.go @@ -134,7 +134,11 @@ func resolveAWSAMI(hostedCluster *hyperv1.HostedCluster, nodePool *hyperv1.NodeP return ami, nil } // Default behavior for Linux/RHCOS AMIs - ami, err := defaultNodePoolAMI(region, arch, releaseImage) + streamMeta, err := releaseImage.StreamForName("") + if err != nil { + return "", fmt.Errorf("couldn't resolve stream metadata: %w", err) + } + ami, err := defaultNodePoolAMI(region, arch, streamMeta) if err != nil { return "", fmt.Errorf("couldn't discover an AMI for release image: %w", err) } @@ -361,7 +365,18 @@ 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) + streamMeta, err := releaseImage.StreamForName("") + if err != nil { + SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ + Type: hyperv1.NodePoolValidPlatformImageType, + Status: corev1.ConditionFalse, + Reason: hyperv1.NodePoolValidationFailedReason, + Message: fmt.Sprintf("Couldn't resolve stream metadata for release image %q: %s", nodePool.Spec.Release.Image, err.Error()), + ObservedGeneration: nodePool.Generation, + }) + return fmt.Errorf("couldn't resolve stream metadata: %w", err) + } + ami, err := defaultNodePoolAMI(hcluster.Spec.Platform.AWS.Region, nodePool.Spec.Arch, streamMeta) if err != nil { SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ Type: hyperv1.NodePoolValidPlatformImageType, diff --git a/hypershift-operator/controllers/nodepool/nodepool_controller.go b/hypershift-operator/controllers/nodepool/nodepool_controller.go index fbbd74145794..76eaf1f31b89 100644 --- a/hypershift-operator/controllers/nodepool/nodepool_controller.go +++ b/hypershift-operator/controllers/nodepool/nodepool_controller.go @@ -55,6 +55,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/blang/semver" + "github.com/coreos/stream-metadata-go/stream" "github.com/pkg/errors" ) @@ -736,11 +737,11 @@ func isAutoscalingEnabled(nodePool *hyperv1.NodePool) bool { return nodePool.Spec.AutoScaling != nil } -func defaultNodePoolAMI(region string, specifiedArch string, releaseImage *releaseinfo.ReleaseImage) (string, error) { - if releaseImage.StreamMetadata == nil { - return "", fmt.Errorf("release image stream metadata is nil") +func defaultNodePoolAMI(region string, specifiedArch string, streamMeta *stream.Stream) (string, error) { + if streamMeta == nil { + return "", fmt.Errorf("stream metadata is nil") } - arch, foundArch := releaseImage.StreamMetadata.Architectures[hyperv1.ArchAliases[specifiedArch]] + arch, foundArch := streamMeta.Architectures[hyperv1.ArchAliases[specifiedArch]] if !foundArch { return "", fmt.Errorf("couldn't find OS metadata for architecture %q", specifiedArch) } diff --git a/hypershift-operator/controllers/nodepool/nodepool_controller_test.go b/hypershift-operator/controllers/nodepool/nodepool_controller_test.go index f3ece180df0c..b9bd4d9f7371 100644 --- a/hypershift-operator/controllers/nodepool/nodepool_controller_test.go +++ b/hypershift-operator/controllers/nodepool/nodepool_controller_test.go @@ -18,6 +18,7 @@ import ( "github.com/openshift/hypershift/support/k8sutil" "github.com/openshift/hypershift/support/releaseinfo" fakereleaseprovider "github.com/openshift/hypershift/support/releaseinfo/fake" + "github.com/openshift/hypershift/support/releaseinfo/fixtures" "github.com/openshift/hypershift/support/thirdparty/library-go/pkg/image/dockerv1client" "github.com/openshift/hypershift/support/upsert" fakeimagemetadataprovider "github.com/openshift/hypershift/support/util/fakeimagemetadataprovider" @@ -540,80 +541,118 @@ func TestCreateValidGeneratedPayloadCondition(t *testing.T) { func TestDefaultNodePoolAMI(t *testing.T) { t.Parallel() + + basicStream := &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": { + Images: stream.Images{ + Aws: &stream.AwsImage{ + Regions: map[string]stream.SingleImage{ + "us-east-1": {Release: "4.12.0", Image: "us-east-1-x86_64-image"}, + }, + }, + }, + }, + "aarch64": { + Images: stream.Images{ + Aws: &stream.AwsImage{ + Regions: map[string]stream.SingleImage{ + "us-east-1": {Release: "4.12.0", Image: "us-east-1-aarch64-image"}, + "us-west-1": {Release: "4.12.0", Image: ""}, + }, + }, + }, + }, + }, + } + + defaultStream, osStreams, err := releaseinfo.DeserializeImageMetadata(fixtures.CoreOSBootImagesYAML_5_0) + if err != nil { + t.Fatalf("failed to parse multi-stream fixture: %v", err) + } + ri := &releaseinfo.ReleaseImage{StreamMetadata: defaultStream, OSStreams: osStreams} + rhel9Stream, _ := ri.StreamForName("rhel-9") + rhel10Stream, _ := ri.StreamForName("rhel-10") + testCases := []struct { name string region string specifiedArch string - releaseImage *releaseinfo.ReleaseImage - image string - err error + streamMeta *stream.Stream expectedImage string + expectedErr string }{ + // --- Happy paths --- { - name: "successfully pull amd64 AMI", + name: "When resolving amd64 AMI it should return the correct image", region: "us-east-1", specifiedArch: "amd64", + streamMeta: basicStream, expectedImage: "us-east-1-x86_64-image", }, { - name: "successfully pull arm64 AMI", + name: "When resolving arm64 AMI it should return the correct image", region: "us-east-1", specifiedArch: "arm64", + streamMeta: basicStream, expectedImage: "us-east-1-aarch64-image", }, { - name: "fail to pull amd64 AMI because region can't be found", - region: "us-east-2", + name: "When resolving rhel-9 stream it should return the rhel-9 AMI", + region: "us-east-1", specifiedArch: "amd64", - expectedImage: "", + streamMeta: rhel9Stream, + expectedImage: "ami-06a6b025350ff1e23", }, { - name: "fail to pull arm64 AMI because region can't be found", - region: "us-east-2", + name: "When resolving rhel-10 stream it should return the rhel-10 AMI", + region: "us-east-1", + specifiedArch: "amd64", + streamMeta: rhel10Stream, + expectedImage: "ami-04b3d999e39d62c5b", + }, + { + name: "When resolving rhel-10 arm64 stream it should return the rhel-10 arm64 AMI", + region: "us-east-1", specifiedArch: "arm64", - expectedImage: "", + streamMeta: rhel10Stream, + expectedImage: "ami-0d7237e6b04d9a9e1", }, { - name: "fail because architecture can't be found", - region: "us-east-2", - specifiedArch: "arm644", - expectedImage: "", + name: "When using default stream it should return the default AMI", + region: "us-east-1", + specifiedArch: "amd64", + streamMeta: defaultStream, + expectedImage: "ami-06a6b025350ff1e23", }, + // --- Sad paths --- { - name: "fail because architecture can't be found", + name: "When region is not found it should return error", region: "us-east-2", + specifiedArch: "amd64", + streamMeta: basicStream, + expectedErr: `couldn't find AWS image for region "us-east-2"`, + }, + { + name: "When architecture is not found it should return error", + region: "us-east-1", specifiedArch: "s390x", - expectedImage: "", + streamMeta: basicStream, + expectedErr: `couldn't find OS metadata for architecture "s390x"`, }, { - name: "fail because no image data is defined", + name: "When image data is empty for region it should return error", region: "us-west-1", specifiedArch: "arm64", - expectedImage: "", - }, - { - name: "fail because Aws images is nil", - region: "us-east-1", - specifiedArch: "amd64", - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": { - Images: stream.Images{}, - }, - }, - }, - }, - expectedImage: "", + streamMeta: basicStream, + expectedErr: `release image metadata has no image for region "us-west-1"`, }, { - name: "fail because stream metadata is nil", + name: "When stream metadata is nil it should return error", region: "us-east-1", specifiedArch: "amd64", - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: nil, - }, - expectedImage: "", + streamMeta: nil, + expectedErr: "stream metadata is nil", }, } @@ -622,52 +661,14 @@ func TestDefaultNodePoolAMI(t *testing.T) { t.Parallel() g := NewWithT(t) - other := []client.Object{ - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "pull-secret"}, - Data: map[string][]byte{ - corev1.DockerConfigJsonKey: nil, - }, - }, - } - - client := fake.NewClientBuilder().WithObjects(other...).Build() - releaseProvider := &fakereleaseprovider.FakeReleaseProvider{} - hc := &hyperv1.HostedCluster{ - Spec: hyperv1.HostedClusterSpec{ - PullSecret: corev1.LocalObjectReference{ - Name: "pull-secret", - }, - Release: hyperv1.Release{ - Image: "image-4.12.0", - }, - }, - } - - ctx := t.Context() - if tc.releaseImage == nil { - tc.releaseImage = fakereleaseprovider.GetReleaseImage(ctx, hc, client, releaseProvider) - } - - tc.image, tc.err = defaultNodePoolAMI(tc.region, tc.specifiedArch, tc.releaseImage) - if strings.Contains(tc.name, "successfully") { - g.Expect(tc.image).To(Equal(tc.expectedImage)) - g.Expect(tc.err).To(BeNil()) - } else if strings.Contains(tc.name, "fail to pull") { - g.Expect(tc.image).To(BeEmpty()) - g.Expect(tc.err.Error()).To(Equal("couldn't find AWS image for region \"" + tc.region + "\"")) - } else if strings.Contains(tc.name, "fail because architecture") { - g.Expect(tc.image).To(BeEmpty()) - g.Expect(tc.err.Error()).To(Equal("couldn't find OS metadata for architecture \"" + tc.specifiedArch + "\"")) - } else if strings.Contains(tc.name, "stream metadata is nil") { - g.Expect(tc.image).To(BeEmpty()) - g.Expect(tc.err.Error()).To(Equal("release image stream metadata is nil")) - } else if strings.Contains(tc.name, "Aws images is nil") { - g.Expect(tc.image).To(BeEmpty()) - g.Expect(tc.err.Error()).To(Equal("release image metadata has no AWS images")) + image, err := defaultNodePoolAMI(tc.region, tc.specifiedArch, tc.streamMeta) + if tc.expectedErr != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(Equal(tc.expectedErr)) + g.Expect(image).To(BeEmpty()) } else { - g.Expect(tc.image).To(BeEmpty()) - g.Expect(tc.err.Error()).To(Equal("release image metadata has no image for region \"" + tc.region + "\"")) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(image).To(Equal(tc.expectedImage)) } }) } diff --git a/hypershift-operator/controllers/nodepool/token.go b/hypershift-operator/controllers/nodepool/token.go index aaff447e9841..4249abfccade 100644 --- a/hypershift-operator/controllers/nodepool/token.go +++ b/hypershift-operator/controllers/nodepool/token.go @@ -404,13 +404,17 @@ func (t *Token) reconcileUserDataSecret(log logr.Logger, userDataSecret *corev1. } func setKarpenterAMILabels(log logr.Logger, userDataSecret *corev1.Secret, region string, releaseImage *releaseinfo.ReleaseImage, platform hyperv1.PlatformType) error { + streamMeta, err := releaseImage.StreamForName("") + if err != nil { + return fmt.Errorf("failed to resolve stream metadata: %w", err) + } supportedArchitectures, err := karpenterutil.SupportedArchitectures(platform) if err != nil { return fmt.Errorf("failed to get supported architectures: %w", err) } supported := 0 for _, arch := range supportedArchitectures { - ami, err := defaultNodePoolAMI(region, arch, releaseImage) + ami, err := defaultNodePoolAMI(region, arch, streamMeta) if err != nil { // skip unavailable architectures gracefully log.Error(err, "failed to get default NodePool AMI for architecture", "architecture", arch) From ad091ee372fc8ffbcc21f7fd66ae7d17bee059b4 Mon Sep 17 00:00:00 2001 From: Juan Manuel Parrilla Madrid Date: Thu, 11 Jun 2026 19:03:05 +0200 Subject: [PATCH 3/3] feat(nodepool): decouple all platform boot image resolvers from ReleaseImage Apply the same pattern from CNTRLPLANE-3026 (AWS) to all remaining platforms: GCP, Azure, PowerVS, OpenStack, and KubeVirt. Each leaf function now accepts *stream.Stream instead of *releaseinfo.ReleaseImage, and callers resolve the stream internally via StreamForName(""). - GCP: DefaultNodePoolGCPImage() exported, accepts *stream.Stream - PowerVS: getPowerVSImage() accepts *stream.Stream - Azure: getAzureMarketplaceMetadata() accepts *stream.Stream - OpenStack: OpenstackDefaultImage(), OpenStackReleaseImage(), PrefixedClusterImageName() accept *stream.Stream with nil guards - KubeVirt: defaultImage() accepts *stream.Stream, GetImage() resolves stream once and passes to both defaultImage and openstack.OpenstackDefaultImage (fallback path) Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Juan Manuel Parrilla Madrid --- .../controllers/nodepool/aws.go | 13 ++ .../controllers/nodepool/azure.go | 15 +- .../controllers/nodepool/gcp.go | 6 +- .../controllers/nodepool/gcp_test.go | 103 ++++++------ .../controllers/nodepool/kubevirt/kubevirt.go | 13 +- .../nodepool/kubevirt/kubevirt_test.go | 2 +- .../nodepool/nodepool_controller.go | 13 +- .../nodepool/nodepool_controller_test.go | 10 +- .../controllers/nodepool/openstack.go | 12 +- .../nodepool/openstack/openstack.go | 37 +++-- .../nodepool/openstack/openstack_test.go | 151 +++++++++--------- .../controllers/nodepool/powervs.go | 23 ++- .../controllers/nodepool/powervs_test.go | 22 +-- 13 files changed, 238 insertions(+), 182 deletions(-) diff --git a/hypershift-operator/controllers/nodepool/aws.go b/hypershift-operator/controllers/nodepool/aws.go index 729026992765..32cdc4434c6d 100644 --- a/hypershift-operator/controllers/nodepool/aws.go +++ b/hypershift-operator/controllers/nodepool/aws.go @@ -134,6 +134,9 @@ func resolveAWSAMI(hostedCluster *hyperv1.HostedCluster, nodePool *hyperv1.NodeP return ami, nil } // Default behavior for Linux/RHCOS AMIs + if releaseImage == nil { + return "", fmt.Errorf("release image is nil") + } streamMeta, err := releaseImage.StreamForName("") if err != nil { return "", fmt.Errorf("couldn't resolve stream metadata: %w", err) @@ -365,6 +368,16 @@ func (r *NodePoolReconciler) setAWSConditions(_ context.Context, nodePool *hyper }) } else { // Default behavior for Linux/RHCOS AMIs + if releaseImage == nil { + SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ + Type: hyperv1.NodePoolValidPlatformImageType, + Status: corev1.ConditionFalse, + Reason: hyperv1.NodePoolValidationFailedReason, + Message: fmt.Sprintf("Release image metadata is nil for release image %q", nodePool.Spec.Release.Image), + ObservedGeneration: nodePool.Generation, + }) + return fmt.Errorf("release image is nil") + } streamMeta, err := releaseImage.StreamForName("") if err != nil { SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ diff --git a/hypershift-operator/controllers/nodepool/azure.go b/hypershift-operator/controllers/nodepool/azure.go index 43a9252ce906..3e40acec8231 100644 --- a/hypershift-operator/controllers/nodepool/azure.go +++ b/hypershift-operator/controllers/nodepool/azure.go @@ -16,6 +16,7 @@ import ( capiazure "sigs.k8s.io/cluster-api-provider-azure/api/v1beta1" "github.com/blang/semver" + "github.com/coreos/stream-metadata-go/stream" ) // dummySSHKey is a base64 encoded dummy SSH public key. @@ -81,7 +82,11 @@ func defaultAzureNodePoolImage(nodePool *hyperv1.NodePool, releaseImage *release } // Extract marketplace metadata from release payload - azureMarketplace, err := getAzureMarketplaceMetadata(releaseImage, streamArch) + streamMeta, err := releaseImage.StreamForName("") + if err != nil { + return fmt.Errorf("couldn't resolve stream metadata: %w", err) + } + azureMarketplace, err := getAzureMarketplaceMetadata(streamMeta, streamArch) if err != nil { return fmt.Errorf("failed to get Azure Marketplace metadata: %w", err) } @@ -119,13 +124,13 @@ 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) { - if releaseImage.StreamMetadata == nil { +// getAzureMarketplaceMetadata extracts Azure Marketplace metadata from stream metadata. +func getAzureMarketplaceMetadata(streamMeta *stream.Stream, arch string) (*azureMarketplaceMetadata, error) { + if streamMeta == nil { return nil, nil // No stream metadata available } - 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) } diff --git a/hypershift-operator/controllers/nodepool/gcp.go b/hypershift-operator/controllers/nodepool/gcp.go index 50a107b210b4..ea3cb93803dd 100644 --- a/hypershift-operator/controllers/nodepool/gcp.go +++ b/hypershift-operator/controllers/nodepool/gcp.go @@ -168,7 +168,11 @@ func resolveGCPImage(nodePool *hyperv1.NodePool, releaseImage *releaseinfo.Relea } // Resolve image from release metadata - image, err := defaultNodePoolGCPImage(nodePool.Spec.Arch, releaseImage) + streamMeta, err := releaseImage.StreamForName("") + if err != nil { + return "", fmt.Errorf("couldn't resolve stream metadata: %w", err) + } + image, err := DefaultNodePoolGCPImage(nodePool.Spec.Arch, streamMeta) if err != nil { return "", fmt.Errorf("couldn't discover a GCP image for release image: %w", err) } diff --git a/hypershift-operator/controllers/nodepool/gcp_test.go b/hypershift-operator/controllers/nodepool/gcp_test.go index 14a3623acf82..9fd0612faf69 100644 --- a/hypershift-operator/controllers/nodepool/gcp_test.go +++ b/hypershift-operator/controllers/nodepool/gcp_test.go @@ -535,7 +535,7 @@ func TestDefaultNodePoolGCPImage(t *testing.T) { testCases := []struct { name string arch string - releaseImage *releaseinfo.ReleaseImage + streamMeta *stream.Stream expectedImage string expectedErr bool expectedErrMsg string @@ -543,55 +543,47 @@ func TestDefaultNodePoolGCPImage(t *testing.T) { { name: "When stream metadata has project and name for amd64, it should construct image path", arch: hyperv1.ArchitectureAMD64, - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": { - Images: stream.Images{ - Gcp: &stream.GcpImage{ - Project: "rhcos-cloud", - Name: "rhcos-9-6-20251023-0-gcp-x86-64", - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": { + Images: stream.Images{ + Gcp: &stream.GcpImage{ + Project: "rhcos-cloud", + Name: "rhcos-9-6-20251023-0-gcp-x86-64", }, }, }, }, }, expectedImage: "projects/rhcos-cloud/global/images/rhcos-9-6-20251023-0-gcp-x86-64", - expectedErr: false, }, { name: "When stream metadata has project and name for arm64, it should construct image path", arch: hyperv1.ArchitectureARM64, - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "aarch64": { - Images: stream.Images{ - Gcp: &stream.GcpImage{ - Project: "rhcos-cloud", - Name: "rhcos-9-6-20251023-0-gcp-aarch64", - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "aarch64": { + Images: stream.Images{ + Gcp: &stream.GcpImage{ + Project: "rhcos-cloud", + Name: "rhcos-9-6-20251023-0-gcp-aarch64", }, }, }, }, }, expectedImage: "projects/rhcos-cloud/global/images/rhcos-9-6-20251023-0-gcp-aarch64", - expectedErr: false, }, { - name: "When architecture is not found in release metadata, it should return error", + name: "When architecture is not found in stream metadata, it should return error", arch: "unsupported-arch", - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": { - Images: stream.Images{ - Gcp: &stream.GcpImage{ - Project: "rhcos-cloud", - Name: "rhcos-9-6-20251023-0-gcp-x86-64", - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": { + Images: stream.Images{ + Gcp: &stream.GcpImage{ + Project: "rhcos-cloud", + Name: "rhcos-9-6-20251023-0-gcp-x86-64", }, }, }, @@ -603,13 +595,11 @@ func TestDefaultNodePoolGCPImage(t *testing.T) { { name: "When GCP project and name are empty, it should return error", arch: hyperv1.ArchitectureAMD64, - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": { - Images: stream.Images{ - Gcp: &stream.GcpImage{}, - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": { + Images: stream.Images{ + Gcp: &stream.GcpImage{}, }, }, }, @@ -620,14 +610,12 @@ func TestDefaultNodePoolGCPImage(t *testing.T) { { name: "When GCP project is empty but name is set, it should return error", arch: hyperv1.ArchitectureAMD64, - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": { - Images: stream.Images{ - Gcp: &stream.GcpImage{ - Name: "rhcos-x86-64", - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": { + Images: stream.Images{ + Gcp: &stream.GcpImage{ + Name: "rhcos-x86-64", }, }, }, @@ -639,14 +627,12 @@ func TestDefaultNodePoolGCPImage(t *testing.T) { { name: "When GCP name is empty but project is set, it should return error", arch: hyperv1.ArchitectureAMD64, - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": { - Images: stream.Images{ - Gcp: &stream.GcpImage{ - Project: "rhcos-cloud", - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": { + Images: stream.Images{ + Gcp: &stream.GcpImage{ + Project: "rhcos-cloud", }, }, }, @@ -655,13 +641,20 @@ func TestDefaultNodePoolGCPImage(t *testing.T) { expectedErr: true, expectedErrMsg: "release image metadata has no GCP image for architecture \"amd64\"", }, + { + name: "When stream metadata is nil, it should return error", + arch: hyperv1.ArchitectureAMD64, + streamMeta: nil, + expectedErr: true, + expectedErrMsg: "stream metadata is nil", + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { g := NewWithT(t) - image, err := defaultNodePoolGCPImage(tc.arch, tc.releaseImage) + image, err := DefaultNodePoolGCPImage(tc.arch, tc.streamMeta) if tc.expectedErr { g.Expect(err).To(HaveOccurred()) diff --git a/hypershift-operator/controllers/nodepool/kubevirt/kubevirt.go b/hypershift-operator/controllers/nodepool/kubevirt/kubevirt.go index c5c087a6f3d9..209ec1a330fe 100644 --- a/hypershift-operator/controllers/nodepool/kubevirt/kubevirt.go +++ b/hypershift-operator/controllers/nodepool/kubevirt/kubevirt.go @@ -20,6 +20,7 @@ import ( capikubevirt "sigs.k8s.io/cluster-api-provider-kubevirt/api/v1alpha1" + "github.com/coreos/stream-metadata-go/stream" jsonpatch "github.com/evanphx/json-patch/v5" kubevirtv1 "kubevirt.io/api/core/v1" "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" @@ -36,7 +37,7 @@ var LocalStorageVolumes = []string{ "hotplug-disks", } -func defaultImage(nodePoolArch string, releaseImage *releaseinfo.ReleaseImage) (string, string, error) { +func defaultImage(nodePoolArch string, streamMeta *stream.Stream) (string, string, error) { var archName string switch nodePoolArch { case hyperv1.ArchitectureS390X: @@ -44,7 +45,7 @@ func defaultImage(nodePoolArch string, releaseImage *releaseinfo.ReleaseImage) ( default: archName = hyperv1.ArchAliases[hyperv1.ArchitectureAMD64] } - arch, foundArch := releaseImage.StreamMetadata.Architectures[archName] + arch, foundArch := streamMeta.Architectures[archName] if !foundArch { return "", "", fmt.Errorf("couldn't find OS metadata for architecture %q", archName) @@ -90,9 +91,13 @@ func GetImage(nodePool *hyperv1.NodePool, releaseImage *releaseinfo.ReleaseImage return newBootImage(imageName, isHTTP), nil } - imageName, imageHash, err := defaultImage(nodePool.Spec.Arch, releaseImage) + streamMeta, err := releaseImage.StreamForName("") + if err != nil { + return nil, fmt.Errorf("couldn't resolve stream metadata: %w", err) + } + imageName, imageHash, err := defaultImage(nodePool.Spec.Arch, streamMeta) if err != nil && allowUnsupportedRHCOSVariants(nodePool) { - imageName, imageHash, err = openstack.OpenstackDefaultImage(releaseImage) + imageName, imageHash, err = openstack.OpenstackDefaultImage(streamMeta) if err != nil { return nil, err } diff --git a/hypershift-operator/controllers/nodepool/kubevirt/kubevirt_test.go b/hypershift-operator/controllers/nodepool/kubevirt/kubevirt_test.go index 2084a6b65cc5..54ae9a515afc 100644 --- a/hypershift-operator/controllers/nodepool/kubevirt/kubevirt_test.go +++ b/hypershift-operator/controllers/nodepool/kubevirt/kubevirt_test.go @@ -1580,7 +1580,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.StreamMetadata) if tt.expectedError { if err == nil { t.Fatalf("expected error but got nil") diff --git a/hypershift-operator/controllers/nodepool/nodepool_controller.go b/hypershift-operator/controllers/nodepool/nodepool_controller.go index 76eaf1f31b89..59f17b622ce1 100644 --- a/hypershift-operator/controllers/nodepool/nodepool_controller.go +++ b/hypershift-operator/controllers/nodepool/nodepool_controller.go @@ -759,16 +759,13 @@ func defaultNodePoolAMI(region string, specifiedArch string, streamMeta *stream. return regionData.Image, nil } -// defaultNodePoolGCPImage returns the default GCP image for a given architecture from release metadata. -func defaultNodePoolGCPImage(specifiedArch string, releaseImage *releaseinfo.ReleaseImage) (string, error) { - if releaseImage == nil { - return "", fmt.Errorf("release image is nil, cannot determine GCP image") - } - if releaseImage.StreamMetadata == nil { - return "", fmt.Errorf("release image stream metadata is nil, cannot determine GCP image for architecture %q", specifiedArch) +// DefaultNodePoolGCPImage returns the default GCP image for a given architecture from stream metadata. +func DefaultNodePoolGCPImage(specifiedArch string, streamMeta *stream.Stream) (string, error) { + if streamMeta == nil { + return "", fmt.Errorf("stream metadata is nil, cannot determine GCP image for architecture %q", specifiedArch) } - arch, foundArch := releaseImage.StreamMetadata.Architectures[hyperv1.ArchAliases[specifiedArch]] + arch, foundArch := streamMeta.Architectures[hyperv1.ArchAliases[specifiedArch]] if !foundArch { return "", fmt.Errorf("couldn't find OS metadata for architecture %q", specifiedArch) } diff --git a/hypershift-operator/controllers/nodepool/nodepool_controller_test.go b/hypershift-operator/controllers/nodepool/nodepool_controller_test.go index b9bd4d9f7371..2c56e0299101 100644 --- a/hypershift-operator/controllers/nodepool/nodepool_controller_test.go +++ b/hypershift-operator/controllers/nodepool/nodepool_controller_test.go @@ -571,8 +571,14 @@ func TestDefaultNodePoolAMI(t *testing.T) { t.Fatalf("failed to parse multi-stream fixture: %v", err) } ri := &releaseinfo.ReleaseImage{StreamMetadata: defaultStream, OSStreams: osStreams} - rhel9Stream, _ := ri.StreamForName("rhel-9") - rhel10Stream, _ := ri.StreamForName("rhel-10") + rhel9Stream, err := ri.StreamForName("rhel-9") + if err != nil { + t.Fatalf("failed to resolve rhel-9 stream: %v", err) + } + rhel10Stream, err := ri.StreamForName("rhel-10") + if err != nil { + t.Fatalf("failed to resolve rhel-10 stream: %v", err) + } testCases := []struct { name string diff --git a/hypershift-operator/controllers/nodepool/openstack.go b/hypershift-operator/controllers/nodepool/openstack.go index bd9773caa737..3ec029d49267 100644 --- a/hypershift-operator/controllers/nodepool/openstack.go +++ b/hypershift-operator/controllers/nodepool/openstack.go @@ -51,7 +51,11 @@ func (c *CAPI) openstackMachineTemplate(templateNameGenerator func(spec any) (st } func (r *NodePoolReconciler) setOpenStackConditions(ctx context.Context, nodePool *hyperv1.NodePool, hcluster *hyperv1.HostedCluster, _ string, releaseImage *releaseinfo.ReleaseImage) error { if nodePool.Spec.Platform.OpenStack.ImageName == "" { - _, err := openstack.OpenStackReleaseImage(releaseImage) + streamMeta, err := releaseImage.StreamForName("") + if err != nil { + return fmt.Errorf("couldn't resolve stream metadata: %w", err) + } + _, err = openstack.OpenStackReleaseImage(streamMeta) if err != nil { SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ Type: hyperv1.NodePoolValidPlatformImageType, @@ -89,7 +93,11 @@ func (r *NodePoolReconciler) setOpenStackConditions(ctx context.Context, nodePoo // An ORC object will be created or updated with the image spec. // The image name will be returned. func (r *NodePoolReconciler) reconcileOpenStackImageCR(ctx context.Context, client client.Client, hcluster *hyperv1.HostedCluster, release *releaseinfo.ReleaseImage, nodePool *hyperv1.NodePool) (string, error) { - releaseVersion, err := openstack.OpenStackReleaseImage(release) + streamMeta, err := release.StreamForName("") + if err != nil { + return "", fmt.Errorf("couldn't resolve stream metadata: %w", err) + } + releaseVersion, err := openstack.OpenStackReleaseImage(streamMeta) if err != nil { return "", err } diff --git a/hypershift-operator/controllers/nodepool/openstack/openstack.go b/hypershift-operator/controllers/nodepool/openstack/openstack.go index 48110bd40d1a..e0d05cd214c9 100644 --- a/hypershift-operator/controllers/nodepool/openstack/openstack.go +++ b/hypershift-operator/controllers/nodepool/openstack/openstack.go @@ -14,6 +14,7 @@ import ( capiopenstackv1beta1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/coreos/stream-metadata-go/stream" orc "github.com/k-orc/openstack-resource-controller/api/v1alpha1" ) @@ -27,7 +28,11 @@ func MachineTemplateSpec(hcluster *hyperv1.HostedCluster, nodePool *hyperv1.Node Name: ptr.To(nodePool.Spec.Platform.OpenStack.ImageName), } } else { - releaseVersion, err := OpenStackReleaseImage(releaseImage) + streamMeta, err := releaseImage.StreamForName("") + if err != nil { + return nil, fmt.Errorf("couldn't resolve stream metadata: %w", err) + } + releaseVersion, err := OpenStackReleaseImage(streamMeta) if err != nil { return nil, err } @@ -95,7 +100,11 @@ func GetOpenStackClusterForHostedCluster(ctx context.Context, c client.Client, h // ReconcileOpenStackImageSpec reconciles the OpenStack ImageSpec for the given HostedCluster. // The image spec will be set to the default RHCOS image for the given release. func ReconcileOpenStackImageSpec(hcluster *hyperv1.HostedCluster, openStackImageSpec *orc.ImageSpec, release *releaseinfo.ReleaseImage) error { - imageURL, imageHash, err := OpenstackDefaultImage(release) + streamMeta, err := release.StreamForName("") + if err != nil { + return fmt.Errorf("couldn't resolve stream metadata: %w", err) + } + imageURL, imageHash, err := OpenstackDefaultImage(streamMeta) if err != nil { return fmt.Errorf("failed to lookup RHCOS image: %w", err) } @@ -105,7 +114,7 @@ func ReconcileOpenStackImageSpec(hcluster *hyperv1.HostedCluster, openStackImage CloudName: hcluster.Spec.Platform.OpenStack.IdentityRef.CloudName, } - imageName, err := PrefixedClusterImageName(hcluster, release) + imageName, err := PrefixedClusterImageName(hcluster, streamMeta) if err != nil { return fmt.Errorf("failed to get image name: %w", err) } @@ -129,10 +138,13 @@ func ReconcileOpenStackImageSpec(hcluster *hyperv1.HostedCluster, openStackImage return nil } -// OpenstackDefaultImage returns the default RHCOS image for the given release. +// OpenstackDefaultImage returns the default RHCOS image for the given stream metadata. // The image URL and SHA256 hash are returned. -func OpenstackDefaultImage(releaseImage *releaseinfo.ReleaseImage) (string, string, error) { - arch, foundArch := releaseImage.StreamMetadata.Architectures["x86_64"] +func OpenstackDefaultImage(streamMeta *stream.Stream) (string, string, error) { + if streamMeta == nil { + return "", "", fmt.Errorf("stream metadata is nil") + } + arch, foundArch := streamMeta.Architectures["x86_64"] if !foundArch { return "", "", fmt.Errorf("couldn't find OS metadata for architecture %q", "x86_64") } @@ -152,9 +164,12 @@ func OpenstackDefaultImage(releaseImage *releaseinfo.ReleaseImage) (string, stri } // OpenStackReleaseImage returns the release version for the OpenStack image. -// The release version is extracted from the release metadata. -func OpenStackReleaseImage(releaseImage *releaseinfo.ReleaseImage) (string, error) { - arch, foundArch := releaseImage.StreamMetadata.Architectures["x86_64"] +// The release version is extracted from stream metadata. +func OpenStackReleaseImage(streamMeta *stream.Stream) (string, error) { + if streamMeta == nil { + return "", fmt.Errorf("stream metadata is nil") + } + arch, foundArch := streamMeta.Architectures["x86_64"] if !foundArch { return "", fmt.Errorf("couldn't find OS metadata for architecture %q", "x86_64") } @@ -166,8 +181,8 @@ func OpenStackReleaseImage(releaseImage *releaseinfo.ReleaseImage) (string, erro } // PrefixedClusterImageName returns a prefixed name of the image for the given HostedCluster. -func PrefixedClusterImageName(hcluster *hyperv1.HostedCluster, releaseImage *releaseinfo.ReleaseImage) (string, error) { - releaseVersion, err := OpenStackReleaseImage(releaseImage) +func PrefixedClusterImageName(hcluster *hyperv1.HostedCluster, streamMeta *stream.Stream) (string, error) { + releaseVersion, err := OpenStackReleaseImage(streamMeta) if err != nil { return "", err } diff --git a/hypershift-operator/controllers/nodepool/openstack/openstack_test.go b/hypershift-operator/controllers/nodepool/openstack/openstack_test.go index 17da96ff3326..ef5d55322e0c 100644 --- a/hypershift-operator/controllers/nodepool/openstack/openstack_test.go +++ b/hypershift-operator/controllers/nodepool/openstack/openstack_test.go @@ -205,25 +205,28 @@ func TestOpenStackMachineTemplate(t *testing.T) { func TestOpenstackDefaultImage(t *testing.T) { testCases := []struct { name string - releaseImage *releaseinfo.ReleaseImage + streamMeta *stream.Stream expectedURL string expectedHash string expectedError bool }{ + { + name: "nil stream metadata", + streamMeta: nil, + expectedError: true, + }, { name: "valid metadata", - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": { - Artifacts: map[string]stream.PlatformArtifacts{ - "openstack": { - Formats: map[string]stream.ImageFormat{ - "qcow2.gz": { - Disk: &stream.Artifact{ - Location: "https://example.com/image.qcow2.gz", - Sha256: "abcdef1234567890", - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": { + Artifacts: map[string]stream.PlatformArtifacts{ + "openstack": { + Formats: map[string]stream.ImageFormat{ + "qcow2.gz": { + Disk: &stream.Artifact{ + Location: "https://example.com/image.qcow2.gz", + Sha256: "abcdef1234567890", }, }, }, @@ -238,29 +241,25 @@ func TestOpenstackDefaultImage(t *testing.T) { }, { name: "missing architecture", - releaseImage: &releaseinfo.ReleaseImage{StreamMetadata: &stream.Stream{Architectures: map[string]stream.Arch{}}}, + streamMeta: &stream.Stream{Architectures: map[string]stream.Arch{}}, expectedError: true, }, { name: "missing openstack artifact", - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": {Artifacts: map[string]stream.PlatformArtifacts{}}, - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": {Artifacts: map[string]stream.PlatformArtifacts{}}, }, }, expectedError: true, }, { name: "missing qcow2.gz format", - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": { - Artifacts: map[string]stream.PlatformArtifacts{ - "openstack": {Formats: map[string]stream.ImageFormat{}}, - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": { + Artifacts: map[string]stream.PlatformArtifacts{ + "openstack": {Formats: map[string]stream.ImageFormat{}}, }, }, }, @@ -269,15 +268,13 @@ func TestOpenstackDefaultImage(t *testing.T) { }, { name: "missing disk artifact", - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": { - Artifacts: map[string]stream.PlatformArtifacts{ - "openstack": { - Formats: map[string]stream.ImageFormat{ - "qcow2.gz": {}, - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": { + Artifacts: map[string]stream.PlatformArtifacts{ + "openstack": { + Formats: map[string]stream.ImageFormat{ + "qcow2.gz": {}, }, }, }, @@ -290,7 +287,7 @@ func TestOpenstackDefaultImage(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - url, hash, err := OpenstackDefaultImage(tc.releaseImage) + url, hash, err := OpenstackDefaultImage(tc.streamMeta) if tc.expectedError { if err == nil { t.Error("expected error but got nil") @@ -313,20 +310,23 @@ func TestOpenstackDefaultImage(t *testing.T) { func TestOpenStackReleaseImage(t *testing.T) { testCases := []struct { name string - releaseImage *releaseinfo.ReleaseImage + streamMeta *stream.Stream expectedResult string expectedError bool }{ + { + name: "nil stream metadata", + streamMeta: nil, + expectedError: true, + }, { name: "valid metadata", - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": { - Artifacts: map[string]stream.PlatformArtifacts{ - "openstack": { - Release: "4.9.0", - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": { + Artifacts: map[string]stream.PlatformArtifacts{ + "openstack": { + Release: "4.9.0", }, }, }, @@ -337,16 +337,14 @@ func TestOpenStackReleaseImage(t *testing.T) { }, { name: "missing architecture", - releaseImage: &releaseinfo.ReleaseImage{StreamMetadata: &stream.Stream{Architectures: map[string]stream.Arch{}}}, + streamMeta: &stream.Stream{Architectures: map[string]stream.Arch{}}, expectedError: true, }, { name: "missing openstack artifact", - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": {Artifacts: map[string]stream.PlatformArtifacts{}}, - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": {Artifacts: map[string]stream.PlatformArtifacts{}}, }, }, expectedError: true, @@ -355,7 +353,7 @@ func TestOpenStackReleaseImage(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result, err := OpenStackReleaseImage(tc.releaseImage) + result, err := OpenStackReleaseImage(tc.streamMeta) if tc.expectedError { if err == nil { t.Error("expected error but got nil") @@ -456,9 +454,7 @@ func TestReconcileOpenStackImageSpec(t *testing.T) { }, releaseImage: &releaseinfo.ReleaseImage{ StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - // Missing x86_64 architecture data will cause the OpenstackDefaultImage to fail - }, + Architectures: map[string]stream.Arch{}, }, }, expectedError: true, @@ -498,10 +494,21 @@ func TestClusterImageName(t *testing.T) { testCases := []struct { name string hostedCluster *hyperv1.HostedCluster - releaseImage *releaseinfo.ReleaseImage + streamMeta *stream.Stream expectedResult string expectedError bool }{ + { + name: "nil stream metadata", + hostedCluster: &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "clusters", + }, + }, + streamMeta: nil, + expectedError: true, + }, { name: "valid release image", hostedCluster: &hyperv1.HostedCluster{ @@ -510,14 +517,12 @@ func TestClusterImageName(t *testing.T) { Namespace: "clusters", }, }, - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": { - Artifacts: map[string]stream.PlatformArtifacts{ - "openstack": { - Release: "4.19.0", - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": { + Artifacts: map[string]stream.PlatformArtifacts{ + "openstack": { + Release: "4.19.0", }, }, }, @@ -534,10 +539,8 @@ func TestClusterImageName(t *testing.T) { Namespace: "clusters", }, }, - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{}, - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{}, }, expectedError: true, }, @@ -549,12 +552,10 @@ func TestClusterImageName(t *testing.T) { Namespace: "clusters", }, }, - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "x86_64": { - Artifacts: map[string]stream.PlatformArtifacts{}, - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "x86_64": { + Artifacts: map[string]stream.PlatformArtifacts{}, }, }, }, @@ -564,7 +565,7 @@ func TestClusterImageName(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result, err := PrefixedClusterImageName(tc.hostedCluster, tc.releaseImage) + result, err := PrefixedClusterImageName(tc.hostedCluster, tc.streamMeta) if tc.expectedError { if err == nil { t.Error("expected error but got nil") diff --git a/hypershift-operator/controllers/nodepool/powervs.go b/hypershift-operator/controllers/nodepool/powervs.go index 7f2bf8ee9fb4..742ba0caad3c 100644 --- a/hypershift-operator/controllers/nodepool/powervs.go +++ b/hypershift-operator/controllers/nodepool/powervs.go @@ -50,8 +50,12 @@ func getImageRegion(region string) string { func ibmPowerVSMachineTemplateSpec(hcluster *hyperv1.HostedCluster, nodePool *hyperv1.NodePool, releaseImage *releaseinfo.ReleaseImage) (*capipowervs.IBMPowerVSMachineTemplateSpec, error) { // Validate PowerVS platform specific input + streamMeta, err := releaseImage.StreamForName("") + if err != nil { + return nil, fmt.Errorf("couldn't resolve stream metadata: %w", err) + } var coreOSPowerVSImage *stream.SingleObject - coreOSPowerVSImage, _, err := getPowerVSImage(hcluster.Spec.Platform.PowerVS.Region, releaseImage) + coreOSPowerVSImage, _, err = getPowerVSImage(hcluster.Spec.Platform.PowerVS.Region, streamMeta) if err != nil { return nil, fmt.Errorf("couldn't discover a PowerVS Image for release image: %w", err) } @@ -110,8 +114,8 @@ func (c *CAPI) ibmPowerVSMachineTemplate(templateNameGenerator func(spec any) (s return template, nil } -func getPowerVSImage(region string, releaseImage *releaseinfo.ReleaseImage) (*stream.SingleObject, string, error) { - arch, foundArch := releaseImage.StreamMetadata.Architectures["ppc64le"] +func getPowerVSImage(region string, streamMeta *stream.Stream) (*stream.SingleObject, string, error) { + arch, foundArch := streamMeta.Architectures["ppc64le"] if !foundArch { return nil, "", fmt.Errorf("couldn't find OS metadata for architecture %q", "ppc64le") } @@ -162,7 +166,18 @@ func reconcileIBMPowerVSImage(ibmPowerVSImage *capipowervs.IBMPowerVSImage, hclu func (r *NodePoolReconciler) setPowerVSconditions(ctx context.Context, nodePool *hyperv1.NodePool, hcluster *hyperv1.HostedCluster, controlPlaneNamespace string, releaseImage *releaseinfo.ReleaseImage) error { log := ctrl.LoggerFrom(ctx) var coreOSPowerVSImage *stream.SingleObject - coreOSPowerVSImage, powervsImageRegion, err := getPowerVSImage(hcluster.Spec.Platform.PowerVS.Region, releaseImage) + streamMeta, err := releaseImage.StreamForName("") + if err != nil { + SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ + Type: hyperv1.NodePoolValidPlatformImageType, + Status: corev1.ConditionFalse, + Reason: hyperv1.NodePoolValidationFailedReason, + Message: fmt.Sprintf("Couldn't resolve stream metadata for release image %q: %s", nodePool.Spec.Release.Image, err.Error()), + ObservedGeneration: nodePool.Generation, + }) + return fmt.Errorf("couldn't resolve stream metadata: %w", err) + } + coreOSPowerVSImage, powervsImageRegion, err := getPowerVSImage(hcluster.Spec.Platform.PowerVS.Region, streamMeta) if err != nil { SetStatusCondition(&nodePool.Status.Conditions, hyperv1.NodePoolCondition{ Type: hyperv1.NodePoolValidPlatformImageType, diff --git a/hypershift-operator/controllers/nodepool/powervs_test.go b/hypershift-operator/controllers/nodepool/powervs_test.go index 4b27a0793be1..01d9c6b7eab7 100644 --- a/hypershift-operator/controllers/nodepool/powervs_test.go +++ b/hypershift-operator/controllers/nodepool/powervs_test.go @@ -5,8 +5,6 @@ import ( . "github.com/onsi/gomega" - "github.com/openshift/hypershift/support/releaseinfo" - "github.com/coreos/stream-metadata-go/stream" ) @@ -14,18 +12,16 @@ func TestGetPowerVSImage(t *testing.T) { testCases := []struct { name string region string - releaseImage *releaseinfo.ReleaseImage + streamMeta *stream.Stream expectedError string }{ { name: "When PowerVS images is nil, it should return error", region: "us-south", - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{ - "ppc64le": { - Images: stream.Images{}, - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{ + "ppc64le": { + Images: stream.Images{}, }, }, }, @@ -34,10 +30,8 @@ func TestGetPowerVSImage(t *testing.T) { { name: "When architecture is not found, it should return error", region: "us-south", - releaseImage: &releaseinfo.ReleaseImage{ - StreamMetadata: &stream.Stream{ - Architectures: map[string]stream.Arch{}, - }, + streamMeta: &stream.Stream{ + Architectures: map[string]stream.Arch{}, }, expectedError: "couldn't find OS metadata for architecture", }, @@ -46,7 +40,7 @@ func TestGetPowerVSImage(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { g := NewWithT(t) - _, _, err := getPowerVSImage(tc.region, tc.releaseImage) + _, _, err := getPowerVSImage(tc.region, tc.streamMeta) g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(ContainSubstring(tc.expectedError)) })