Skip to content

Commit 0e7dd9d

Browse files
committed
MCO-1956: Collect OSImageStream
This change adds all the required logic to expose two functions to load in bootstrap and runtime the available OSImageStreams.
1 parent 571a148 commit 0e7dd9d

15 files changed

+2753
-0
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package osimagestream
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
configlisters "github.com/openshift/client-go/config/listers/config/v1"
8+
)
9+
10+
// clusterVersionSingletonName is the well-known name of the cluster-scoped ClusterVersion singleton resource.
11+
const clusterVersionSingletonName = "version"
12+
13+
// GetReleasePayloadImage retrieves the release payload image from the ClusterVersion resource.
14+
func GetReleasePayloadImage(lister configlisters.ClusterVersionLister) (string, error) {
15+
clusterVersion, err := lister.Get(clusterVersionSingletonName)
16+
if err != nil {
17+
return "", fmt.Errorf("failed to get ClusterVersion: %w", err)
18+
}
19+
if clusterVersion == nil || clusterVersion.Status.Desired.Image == "" {
20+
return "", errors.New("ClusterVersion desired image is not yet available")
21+
}
22+
// Got it, store the variable and exit
23+
return clusterVersion.Status.Desired.Image, nil
24+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Assisted-by: Claude
2+
package osimagestream
3+
4+
import (
5+
"testing"
6+
7+
configv1 "github.com/openshift/api/config/v1"
8+
fakeconfigclientset "github.com/openshift/client-go/config/clientset/versioned/fake"
9+
configinformers "github.com/openshift/client-go/config/informers/externalversions"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
13+
)
14+
15+
func TestGetClusterVersionOperatorImage(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
clusterVersion *configv1.ClusterVersion
19+
expectedImage string
20+
errorContains string
21+
}{
22+
{
23+
name: "success - returns image from ClusterVersion",
24+
clusterVersion: &configv1.ClusterVersion{
25+
ObjectMeta: metav1.ObjectMeta{
26+
Name: "version",
27+
},
28+
Status: configv1.ClusterVersionStatus{
29+
Desired: configv1.Release{
30+
Image: "quay.io/openshift-release-dev/ocp-release@sha256:abc123",
31+
},
32+
},
33+
},
34+
expectedImage: "quay.io/openshift-release-dev/ocp-release@sha256:abc123",
35+
},
36+
{
37+
name: "error - ClusterVersion does not exist",
38+
errorContains: "not found",
39+
},
40+
{
41+
name: "error - ClusterVersion has empty desired image",
42+
clusterVersion: &configv1.ClusterVersion{
43+
ObjectMeta: metav1.ObjectMeta{
44+
Name: "version",
45+
},
46+
Status: configv1.ClusterVersionStatus{
47+
Desired: configv1.Release{
48+
Image: "",
49+
},
50+
},
51+
},
52+
errorContains: "ClusterVersion desired image is not yet available",
53+
},
54+
}
55+
56+
for _, tt := range tests {
57+
t.Run(tt.name, func(t *testing.T) {
58+
var fakeClient *fakeconfigclientset.Clientset
59+
if tt.clusterVersion != nil {
60+
fakeClient = fakeconfigclientset.NewSimpleClientset(tt.clusterVersion)
61+
} else {
62+
fakeClient = fakeconfigclientset.NewSimpleClientset()
63+
}
64+
65+
informerFactory := configinformers.NewSharedInformerFactory(fakeClient, 0)
66+
clusterVersionInformer := informerFactory.Config().V1().ClusterVersions()
67+
68+
if tt.clusterVersion != nil {
69+
err := clusterVersionInformer.Informer().GetIndexer().Add(tt.clusterVersion)
70+
require.NoError(t, err)
71+
}
72+
73+
image, err := GetReleasePayloadImage(clusterVersionInformer.Lister())
74+
75+
if tt.errorContains != "" {
76+
require.Error(t, err)
77+
assert.Contains(t, err.Error(), tt.errorContains)
78+
assert.Empty(t, image)
79+
return
80+
}
81+
82+
require.NoError(t, err)
83+
assert.Equal(t, tt.expectedImage, image)
84+
})
85+
}
86+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package osimagestream
2+
3+
import (
4+
"maps"
5+
"slices"
6+
7+
"github.com/openshift/api/machineconfiguration/v1alpha1"
8+
"k8s.io/klog/v2"
9+
)
10+
11+
const (
12+
// coreOSLabelStreamClass is the container image label that identifies the OS stream name.
13+
coreOSLabelStreamClass = "io.openshift.os.streamclass"
14+
// coreOSLabelDiscriminator is the container image label that distinguishes OS images from Extensions images.
15+
coreOSLabelDiscriminator = "ostree.linux"
16+
)
17+
18+
// ImageType indicates whether a container image is an OS image or an extensions image.
19+
type ImageType int
20+
21+
const (
22+
// ImageTypeOS represents a base operating system image.
23+
ImageTypeOS = iota
24+
// ImageTypeExtensions represents an OS extensions image.
25+
ImageTypeExtensions
26+
)
27+
28+
// ImageDataExtractor extracts metadata from container image labels to determine
29+
// the image type and associated OS stream.
30+
type ImageDataExtractor interface {
31+
// GetImageData analyzes container image labels and returns structured metadata,
32+
// or nil if the image is not an OS or extensions image.
33+
GetImageData(image string, labels map[string]string) *ImageData
34+
}
35+
36+
// ImageData contains metadata extracted from a container image.
37+
type ImageData struct {
38+
Image string // Container image reference
39+
Type ImageType // Image type (OS or extensions)
40+
Stream string // OS stream name
41+
}
42+
43+
// ImageStreamExtractorImpl implements ImageDataExtractor using container image labels
44+
// to identify and classify OS and extensions images.
45+
type ImageStreamExtractorImpl struct {
46+
imagesInspector ImagesInspector
47+
streamLabels []string
48+
osImageDiscriminator string
49+
}
50+
51+
// NewImageStreamExtractor creates a new ImageDataExtractor configured to recognize
52+
// CoreOS images using standard image labels.
53+
func NewImageStreamExtractor() ImageDataExtractor {
54+
// The type is thought to allow future extra label addition for
55+
// i.e. Allow a customer to bring their own images with their own labels (defining a selector)
56+
return &ImageStreamExtractorImpl{
57+
streamLabels: []string{coreOSLabelStreamClass},
58+
osImageDiscriminator: coreOSLabelDiscriminator,
59+
}
60+
}
61+
62+
// GetImageData analyzes container image labels to extract OS stream metadata.
63+
// Returns nil if the image does not have the required stream label.
64+
// Distinguishes between OS and extensions images based on the presence of the ostree discriminator label.
65+
func (e *ImageStreamExtractorImpl) GetImageData(image string, labels map[string]string) *ImageData {
66+
imageData := &ImageData{
67+
Image: image,
68+
Stream: findLabelValue(labels, e.streamLabels...),
69+
}
70+
if imageData.Stream == "" {
71+
// Not an OS/extensions image
72+
return nil
73+
}
74+
75+
if findLabelValue(labels, e.osImageDiscriminator) != "" {
76+
imageData.Type = ImageTypeOS
77+
} else {
78+
imageData.Type = ImageTypeExtensions
79+
}
80+
81+
return imageData
82+
}
83+
84+
// findLabelValue searches for the first matching key in the map and returns its value.
85+
// Returns an empty string if none of the keys are found.
86+
func findLabelValue(m map[string]string, keys ...string) string {
87+
for _, k := range keys {
88+
if v, ok := m[k]; ok {
89+
return v
90+
}
91+
}
92+
return ""
93+
}
94+
95+
// GroupOSContainerImageMetadataToStream groups OS and extensions images by stream name.
96+
// Combines pairs of OS and extensions images that share the same stream name into OSImageStreamSet objects.
97+
// If multiple images are found for the same stream and type, the last one wins.
98+
func GroupOSContainerImageMetadataToStream(imagesMetadata []*ImageData) []*v1alpha1.OSImageStreamSet {
99+
streamMaps := make(map[string]*v1alpha1.OSImageStreamSet)
100+
for _, imageMetadata := range imagesMetadata {
101+
streamURLSet, exists := streamMaps[imageMetadata.Stream]
102+
if !exists {
103+
streamMaps[imageMetadata.Stream] = NewOSImageStreamURLSetFromImageMetadata(imageMetadata)
104+
continue
105+
}
106+
107+
// The stream already exists. Maybe it has not both urls yet
108+
imageName := v1alpha1.ImageDigestFormat(imageMetadata.Image)
109+
if imageMetadata.Type == ImageTypeOS {
110+
if streamURLSet.OSImage != "" && streamURLSet.OSImage != imageName {
111+
// Looks like we have a conflict. Log it and override
112+
klog.V(4).Infof("multiple OS images for the same %s stream. Previous one was %s. Overriding with %s", streamURLSet.Name, streamURLSet.OSImage, imageName)
113+
}
114+
streamURLSet.OSImage = imageName
115+
} else {
116+
if streamURLSet.OSExtensionsImage != "" && streamURLSet.OSExtensionsImage != imageName {
117+
// Looks like we have a conflict. Log it and override
118+
klog.V(4).Infof("multiple OS Extensions images for the same %s stream. Previous one was %s. Overriding with %s", streamURLSet.Name, streamURLSet.OSImage, imageName)
119+
}
120+
streamURLSet.OSExtensionsImage = imageName
121+
}
122+
}
123+
return slices.Collect(maps.Values(streamMaps))
124+
}
125+
126+
// NewOSImageStreamURLSetFromImageMetadata creates an OSImageStreamSet from image metadata.
127+
// Populates either the OSImage or OSExtensionsImage field based on the image type.
128+
func NewOSImageStreamURLSetFromImageMetadata(imageMetadata *ImageData) *v1alpha1.OSImageStreamSet {
129+
urlSet := &v1alpha1.OSImageStreamSet{
130+
Name: imageMetadata.Stream,
131+
}
132+
imageName := v1alpha1.ImageDigestFormat(imageMetadata.Image)
133+
if imageMetadata.Type == ImageTypeOS {
134+
urlSet.OSImage = imageName
135+
} else {
136+
urlSet.OSExtensionsImage = imageName
137+
}
138+
return urlSet
139+
}

0 commit comments

Comments
 (0)