diff --git a/pkg/deploy/image-puller/defaultimages.go b/pkg/deploy/image-puller/defaultimages.go new file mode 100644 index 000000000..cd2a540a2 --- /dev/null +++ b/pkg/deploy/image-puller/defaultimages.go @@ -0,0 +1,262 @@ +// +// Copyright (c) 2019-2023 Red Hat, Inc. +// This program and the accompanying materials are made +// available under the terms of the Eclipse Public License 2.0 +// which is available at https://www.eclipse.org/legal/epl-2.0/ +// +// SPDX-License-Identifier: EPL-2.0 +// +// Contributors: +// Red Hat, Inc. - initial API and implementation +// + +package imagepuller + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + "time" + + "sigs.k8s.io/yaml" + + defaults "github.com/eclipse-che/che-operator/pkg/common/operator-defaults" +) + +// DefaultImagesProvider is an interface for fetching default images from a specific source. +type DefaultImagesProvider interface { + get(namespace string) ([]string, error) + persist(images []string, path string) error +} + +type DashboardApiDefaultImagesProvider struct { + DefaultImagesProvider + // introduce in order to override in tests + requestRawDataFunc func(url string) ([]byte, error) +} + +func NewDashboardApiDefaultImagesProvider() *DashboardApiDefaultImagesProvider { + return &DashboardApiDefaultImagesProvider{ + requestRawDataFunc: doRequestRawData, + } +} + +func (p *DashboardApiDefaultImagesProvider) get(namespace string) ([]string, error) { + editorsEndpointUrl := fmt.Sprintf( + "http://%s.%s.svc:8080/dashboard/api/editors", + defaults.GetCheFlavor()+"-dashboard", + namespace) + + editorsImages, err := p.readEditorImages(editorsEndpointUrl) + if err != nil { + return []string{}, fmt.Errorf("failed to read default images: %w from endpoint %s", err, editorsEndpointUrl) + } + + samplesEndpointUrl := fmt.Sprintf( + "http://%s.%s.svc:8080/dashboard/api/airgap-sample", + defaults.GetCheFlavor()+"-dashboard", + namespace) + + samplesImages, err := p.readSampleImages(samplesEndpointUrl) + if err != nil { + return []string{}, fmt.Errorf("failed to read default images: %w from endpoint %s", err, samplesEndpointUrl) + } + + // using map to avoid duplicates + allImages := make(map[string]bool) + + for _, image := range editorsImages { + allImages[image] = true + } + for _, image := range samplesImages { + allImages[image] = true + } + + // having them sorted, prevents from constant changing CR spec + return sortImages(allImages), nil +} + +// readEditorImages reads list of images from editors: +// 1. reads list of devfile editors from the given endpoint (json objects array) +// 2. parses them and return images +func (p *DashboardApiDefaultImagesProvider) readEditorImages(entrypointUrl string) ([]string, error) { + rawData, err := p.requestRawDataFunc(entrypointUrl) + if err != nil { + return []string{}, err + } + + return parseEditorDevfiles(rawData) +} + +// readSampleImages reads list of images from samples: +// 1. reads list of samples from the given endpoint (json objects array) +// 2. parses them and retrieves urls to a devfile +// 3. read and parses devfiles (yaml) and return images +func (p *DashboardApiDefaultImagesProvider) readSampleImages(entrypointUrl string) ([]string, error) { + rawData, err := p.requestRawDataFunc(entrypointUrl) + if err != nil { + return []string{}, err + } + + urls, err := parseSamples(rawData) + if err != nil { + return []string{}, err + } + + allImages := make([]string, 0) + for _, url := range urls { + rawData, err = p.requestRawDataFunc(url) + if err != nil { + return []string{}, err + } + + images, err := parseSampleDevfile(rawData) + if err != nil { + return []string{}, err + } + + allImages = append(allImages, images...) + } + + return allImages, nil +} + +func (p *DashboardApiDefaultImagesProvider) persist(images []string, path string) error { + return os.WriteFile(path, []byte(strings.Join(images, "\n")), 0644) +} + +func sortImages(images map[string]bool) []string { + sortedImages := make([]string, len(images)) + + i := 0 + for image := range images { + sortedImages[i] = image + i++ + } + + sort.Strings(sortedImages) + return sortedImages +} + +func doRequestRawData(url string) ([]byte, error) { + client := &http.Client{ + Transport: &http.Transport{}, + Timeout: time.Second * 1, + } + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return []byte{}, err + } + + response, err := client.Do(request) + if err != nil { + return []byte{}, err + } + + rawData, err := io.ReadAll(response.Body) + if err != nil { + return []byte{}, err + } + + _ = response.Body.Close() + return rawData, nil +} + +// parseSamples parse samples to collect urls to devfiles +func parseSamples(rawData []byte) ([]string, error) { + if len(rawData) == 0 { + return []string{}, nil + } + + var samples []interface{} + if err := json.Unmarshal(rawData, &samples); err != nil { + return []string{}, err + } + + urls := make([]string, 0) + + for i := range samples { + sample, ok := samples[i].(map[string]interface{}) + if !ok { + continue + } + + if sample["url"] != nil { + urls = append(urls, sample["url"].(string)) + } + } + + return urls, nil +} + +// parseDevfiles parse sample devfile represented as yaml to collect images +func parseSampleDevfile(rawData []byte) ([]string, error) { + if len(rawData) == 0 { + return []string{}, nil + } + + var devfile map[string]interface{} + if err := yaml.Unmarshal(rawData, &devfile); err != nil { + return []string{}, err + } + + return collectDevfileImages(devfile), nil +} + +// parseEditorDevfiles parse editor devfiles represented as json array to collect images +func parseEditorDevfiles(rawData []byte) ([]string, error) { + if len(rawData) == 0 { + return []string{}, nil + } + + var devfiles []interface{} + if err := json.Unmarshal(rawData, &devfiles); err != nil { + return []string{}, err + } + + images := make([]string, 0) + + for i := range devfiles { + devfile, ok := devfiles[i].(map[string]interface{}) + if !ok { + continue + } + + images = append(images, collectDevfileImages(devfile)...) + } + + return images, nil +} + +// collectDevfileImages retrieves images container component of the devfile. +func collectDevfileImages(devfile map[string]interface{}) []string { + devfileImages := make([]string, 0) + + components, ok := devfile["components"].([]interface{}) + if !ok { + return []string{} + } + + for k := range components { + component, ok := components[k].(map[string]interface{}) + if !ok { + continue + } + + container, ok := component["container"].(map[string]interface{}) + if !ok { + continue + } + + if container["image"] != nil { + devfileImages = append(devfileImages, container["image"].(string)) + } + } + + return devfileImages +} diff --git a/pkg/deploy/image-puller/defaultimages_test.go b/pkg/deploy/image-puller/defaultimages_test.go new file mode 100644 index 000000000..c2287cfe0 --- /dev/null +++ b/pkg/deploy/image-puller/defaultimages_test.go @@ -0,0 +1,102 @@ +// +// Copyright (c) 2019-2024 Red Hat, Inc. +// This program and the accompanying materials are made +// available under the terms of the Eclipse Public License 2.0 +// which is available at https://www.eclipse.org/legal/epl-2.0/ +// +// SPDX-License-Identifier: EPL-2.0 +// +// Contributors: +// Red Hat, Inc. - initial API and implementation +// + +package imagepuller + +import ( + "fmt" + "os" + + defaults "github.com/eclipse-che/che-operator/pkg/common/operator-defaults" + + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReadEditorImages(t *testing.T) { + imagesProvider := &DashboardApiDefaultImagesProvider{ + requestRawDataFunc: func(url string) ([]byte, error) { + return os.ReadFile("image-puller-resources-test/editors.json") + }, + } + + images, err := imagesProvider.readEditorImages("") + assert.NoError(t, err) + assert.Equal(t, 2, len(images)) + assert.Contains(t, images, "image_1") + assert.Contains(t, images, "image_2") +} + +func TestSampleImages(t *testing.T) { + imagesProvider := &DashboardApiDefaultImagesProvider{ + requestRawDataFunc: func(url string) ([]byte, error) { + switch url { + case "": + return os.ReadFile("image-puller-resources-test/samples.json") + case "sample_1_url": + return os.ReadFile("image-puller-resources-test/sample_1.yaml") + case "sample_2_url": + return os.ReadFile("image-puller-resources-test/sample_2.yaml") + default: + return []byte{}, fmt.Errorf("unexpected url: %s", url) + } + }, + } + + images, err := imagesProvider.readSampleImages("") + assert.NoError(t, err) + assert.Equal(t, 2, len(images)) + assert.Contains(t, images, "image_1") + assert.Contains(t, images, "image_3") +} + +func TestGet(t *testing.T) { + imagesProvider := &DashboardApiDefaultImagesProvider{ + requestRawDataFunc: func(url string) ([]byte, error) { + samplesEndpointUrl := fmt.Sprintf( + "http://%s.eclipse-che.svc:8080/dashboard/api/airgap-sample", + defaults.GetCheFlavor()+"-dashboard") + editorsEndpointUrl := fmt.Sprintf( + "http://%s.eclipse-che.svc:8080/dashboard/api/editors", + defaults.GetCheFlavor()+"-dashboard") + + switch url { + case editorsEndpointUrl: + return os.ReadFile("image-puller-resources-test/editors.json") + case samplesEndpointUrl: + return os.ReadFile("image-puller-resources-test/samples.json") + case "sample_1_url": + return os.ReadFile("image-puller-resources-test/sample_1.yaml") + case "sample_2_url": + return os.ReadFile("image-puller-resources-test/sample_2.yaml") + default: + return []byte{}, fmt.Errorf("unexpected url: %s", url) + } + }, + } + + images, err := imagesProvider.get("eclipse-che") + assert.NoError(t, err) + assert.Equal(t, 3, len(images)) + assert.Equal(t, "image_1", images[0]) + assert.Equal(t, "image_2", images[1]) + assert.Equal(t, "image_3", images[2]) + + err = imagesProvider.persist(images, "/tmp/images.txt") + assert.NoError(t, err) + + data, err := os.ReadFile("/tmp/images.txt") + assert.NoError(t, err) + + assert.Equal(t, "image_1\nimage_2\nimage_3", string(data)) +} diff --git a/pkg/deploy/image-puller/image-puller-resources-test/editors.json b/pkg/deploy/image-puller/image-puller-resources-test/editors.json new file mode 100644 index 000000000..612e15064 --- /dev/null +++ b/pkg/deploy/image-puller/image-puller-resources-test/editors.json @@ -0,0 +1,35 @@ +[ + { + "components": [ + { + "container": { + "image": "image_2" + } + }, + { + "name": "checode", + "volume": {} + } + ] + }, + { + "components": [ + { + "container": { + "image": "image_1" + } + } + ] + }, + { + "components": [ + { + "container": {} + } + ] + }, + { + "components": [] + }, + {} +] \ No newline at end of file diff --git a/pkg/deploy/image-puller/image-puller-resources-test/imagepuller_testcase_1.json b/pkg/deploy/image-puller/image-puller-resources-test/imagepuller_testcase_1.json new file mode 100644 index 000000000..486913d30 --- /dev/null +++ b/pkg/deploy/image-puller/image-puller-resources-test/imagepuller_testcase_1.json @@ -0,0 +1,16 @@ +[ + { + "components": [ + { + "container": { + "image": "image_2" + } + }, + { + "container": { + "image": "image_1" + } + } + ] + } +] \ No newline at end of file diff --git a/pkg/deploy/image-puller/image-puller-resources-test/imagepuller_testcase_2.json b/pkg/deploy/image-puller/image-puller-resources-test/imagepuller_testcase_2.json new file mode 100644 index 000000000..21814b249 --- /dev/null +++ b/pkg/deploy/image-puller/image-puller-resources-test/imagepuller_testcase_2.json @@ -0,0 +1,21 @@ +[ + { + "components": [ + { + "container": { + "image": "image_2" + } + }, + { + "container": { + "image": "image_1" + } + }, + { + "container": { + "image": "image_3" + } + } + ] + } +] \ No newline at end of file diff --git a/pkg/deploy/image-puller/image-puller-resources-test/sample_1.yaml b/pkg/deploy/image-puller/image-puller-resources-test/sample_1.yaml new file mode 100644 index 000000000..234829f01 --- /dev/null +++ b/pkg/deploy/image-puller/image-puller-resources-test/sample_1.yaml @@ -0,0 +1,19 @@ +# +# Copyright (c) 2019-2024 Red Hat, Inc. +# This program and the accompanying materials are made +# available under the terms of the Eclipse Public License 2.0 +# which is available at https://www.eclipse.org/legal/epl-2.0/ +# +# SPDX-License-Identifier: EPL-2.0 +# +# Contributors: +# Red Hat, Inc. - initial API and implementation +# + +schemaVersion: 2.3.0 +metadata: + name: sample-devfile +components: + - name: tools + container: + image: image_1 diff --git a/pkg/deploy/image-puller/image-puller-resources-test/sample_2.yaml b/pkg/deploy/image-puller/image-puller-resources-test/sample_2.yaml new file mode 100644 index 000000000..cd3f24145 --- /dev/null +++ b/pkg/deploy/image-puller/image-puller-resources-test/sample_2.yaml @@ -0,0 +1,21 @@ +# +# Copyright (c) 2019-2024 Red Hat, Inc. +# This program and the accompanying materials are made +# available under the terms of the Eclipse Public License 2.0 +# which is available at https://www.eclipse.org/legal/epl-2.0/ +# +# SPDX-License-Identifier: EPL-2.0 +# +# Contributors: +# Red Hat, Inc. - initial API and implementation +# + +schemaVersion: 2.3.0 +metadata: + name: sample-devfile +components: + - name: tools_1 + container: + image: image_3 + - name: tools_2 + diff --git a/pkg/deploy/image-puller/image-puller-resources-test/samples.json b/pkg/deploy/image-puller/image-puller-resources-test/samples.json new file mode 100644 index 000000000..4fb25ac1b --- /dev/null +++ b/pkg/deploy/image-puller/image-puller-resources-test/samples.json @@ -0,0 +1,9 @@ +[ + { + "url": "sample_1_url" + }, + { + "url": "sample_2_url" + }, + {} +] \ No newline at end of file diff --git a/pkg/deploy/image-puller/imagepuller.go b/pkg/deploy/image-puller/imagepuller.go index 8b35e9171..7bfb9073e 100644 --- a/pkg/deploy/image-puller/imagepuller.go +++ b/pkg/deploy/image-puller/imagepuller.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2023 Red Hat, Inc. +// Copyright (c) 2019-2024 Red Hat, Inc. // This program and the accompanying materials are made // available under the terms of the Eclipse Public License 2.0 // which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -14,17 +14,15 @@ package imagepuller import ( "fmt" - "sort" "strings" - "k8s.io/apimachinery/pkg/util/validation" - "github.com/eclipse-che/che-operator/pkg/common/chetypes" "github.com/eclipse-che/che-operator/pkg/common/constants" "github.com/eclipse-che/che-operator/pkg/common/utils" "github.com/google/go-cmp/cmp/cmpopts" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/google/go-cmp/cmp" @@ -35,7 +33,7 @@ import ( ) var ( - log = ctrl.Log.WithName("image-puller") + logger = ctrl.Log.WithName("image-puller") kubernetesImagePullerDiffOpts = cmp.Options{ cmpopts.IgnoreFields(chev1alpha1.KubernetesImagePuller{}, "TypeMeta", "ObjectMeta", "Status"), } @@ -48,24 +46,47 @@ const ( defaultConfigMapName = "k8s-image-puller" defaultDeploymentName = "kubernetes-image-puller" defaultImagePullerImage = "quay.io/eclipse/kubernetes-image-puller:next" + + externalImagesFilePath = "/tmp/external_images.txt" ) type ImagePuller struct { deploy.Reconcilable + imageProvider DefaultImagesProvider } func NewImagePuller() *ImagePuller { - return &ImagePuller{} + return &ImagePuller{ + imageProvider: NewDashboardApiDefaultImagesProvider(), + } } func (ip *ImagePuller) Reconcile(ctx *chetypes.DeployContext) (reconcile.Result, bool, error) { + defaultImages, err := ip.imageProvider.get(ctx.CheCluster.Namespace) + if err != nil { + logger.Error(err, "Failed to get default images", "error", err) + + // Don't block the reconciliation if we can't get the default images + return reconcile.Result{}, true, nil + } + + // Always fetch and persist the default images before actual sync. + // The purpose is to ability read them from the file on demand by admin (should be documented) + err = ip.imageProvider.persist(defaultImages, externalImagesFilePath) + if err != nil { + logger.Error(err, "Failed to save default images", "error", err) + + // Don't block the reconciliation if we can't save the default images on FS + return reconcile.Result{}, true, nil + } + if ctx.CheCluster.Spec.Components.ImagePuller.Enable { if !utils.IsK8SResourceServed(ctx.ClusterAPI.DiscoveryClient, resourceName) { errMsg := "Kubernetes Image Puller is not installed, in order to enable the property admin should install the operator first" return reconcile.Result{}, false, fmt.Errorf(errMsg) } - if done, err := ip.syncKubernetesImagePuller(ctx); !done { + if done, err := ip.syncKubernetesImagePuller(defaultImages, ctx); !done { return reconcile.Result{Requeue: true}, false, err } } else { @@ -79,7 +100,7 @@ func (ip *ImagePuller) Reconcile(ctx *chetypes.DeployContext) (reconcile.Result, func (ip *ImagePuller) Finalize(ctx *chetypes.DeployContext) bool { done, err := ip.uninstallImagePuller(ctx) if err != nil { - log.Error(err, "Failed to uninstall Kubernetes Image Puller") + logger.Error(err, "Failed to uninstall Kubernetes Image Puller") } return done } @@ -105,7 +126,7 @@ func (ip *ImagePuller) uninstallImagePuller(ctx *chetypes.DeployContext) (bool, return true, nil } -func (ip *ImagePuller) syncKubernetesImagePuller(ctx *chetypes.DeployContext) (bool, error) { +func (ip *ImagePuller) syncKubernetesImagePuller(defaultImages []string, ctx *chetypes.DeployContext) (bool, error) { imagePuller := &chev1alpha1.KubernetesImagePuller{ TypeMeta: metav1.TypeMeta{ APIVersion: chev1alpha1.GroupVersion.String(), @@ -129,7 +150,7 @@ func (ip *ImagePuller) syncKubernetesImagePuller(ctx *chetypes.DeployContext) (b imagePuller.Spec.DeploymentName = utils.GetValue(imagePuller.Spec.DeploymentName, defaultDeploymentName) imagePuller.Spec.ImagePullerImage = utils.GetValue(imagePuller.Spec.ImagePullerImage, defaultImagePullerImage) if strings.TrimSpace(imagePuller.Spec.Images) == "" { - imagePuller.Spec.Images = getDefaultImages() + imagePuller.Spec.Images = convertToSpecField(defaultImages) } return deploy.SyncWithClient(ctx.ClusterAPI.NonCachingClient, ctx, imagePuller, kubernetesImagePullerDiffOpts) @@ -139,36 +160,6 @@ func getImagePullerCustomResourceName(ctx *chetypes.DeployContext) string { return ctx.CheCluster.Name + "-image-puller" } -func getDefaultImages() string { - allImages := make(map[string]bool) - - addImagesFromEditorsDefinitions(allImages) - - // having them sorted, prevents from constant changing CR spec - sortedImages := sortImages(allImages) - return convertToSpecField(sortedImages) -} - -func addImagesFromEditorsDefinitions(allImages map[string]bool) { - envs := utils.GetEnvsByRegExp("RELATED_IMAGE_editor_definition_.*") - for _, env := range envs { - allImages[env.Value] = true - } -} - -func sortImages(images map[string]bool) []string { - sortedImages := make([]string, len(images)) - - i := 0 - for image := range images { - sortedImages[i] = image - i++ - } - - sort.Strings(sortedImages) - return sortedImages -} - func convertToSpecField(images []string) string { specField := "" for index, image := range images { diff --git a/pkg/deploy/image-puller/imagepuller_test.go b/pkg/deploy/image-puller/imagepuller_test.go index 974ba561a..958ffd721 100644 --- a/pkg/deploy/image-puller/imagepuller_test.go +++ b/pkg/deploy/image-puller/imagepuller_test.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2023 Red Hat, Inc. +// Copyright (c) 2019-2024 Red Hat, Inc. // This program and the accompanying materials are made // available under the terms of the Eclipse Public License 2.0 // which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -14,6 +14,7 @@ package imagepuller import ( "context" + "os" chev1alpha1 "github.com/che-incubator/kubernetes-image-puller-operator/api/v1alpha1" "github.com/eclipse-che/che-operator/pkg/deploy" @@ -38,6 +39,7 @@ func TestImagePullerConfiguration(t *testing.T) { name string cheCluster *chev2.CheCluster initObjects []runtime.Object + testCaseFilePath string expectedImagePuller *chev1alpha1.KubernetesImagePuller } @@ -47,11 +49,12 @@ func TestImagePullerConfiguration(t *testing.T) { cheCluster: InitCheCluster(chev2.ImagePuller{ Enable: true, }), + testCaseFilePath: "image-puller-resources-test/imagepuller_testcase_1.json", expectedImagePuller: InitImagePuller(chev1alpha1.KubernetesImagePullerSpec{ DeploymentName: defaultDeploymentName, ConfigMapName: defaultConfigMapName, ImagePullerImage: defaultImagePullerImage, - Images: getDefaultImages(), + Images: "image-1-0=image_1;image-2-1=image_2;", }), }, { @@ -64,6 +67,7 @@ func TestImagePullerConfiguration(t *testing.T) { DeploymentName: "custom-deployment", Images: "image=image_url;", }}), + testCaseFilePath: "image-puller-resources-test/imagepuller_testcase_1.json", expectedImagePuller: InitImagePuller(chev1alpha1.KubernetesImagePullerSpec{ ConfigMapName: "custom-config-map", ImagePullerImage: "custom-image", @@ -76,6 +80,7 @@ func TestImagePullerConfiguration(t *testing.T) { cheCluster: InitCheCluster(chev2.ImagePuller{ Enable: true, }), + testCaseFilePath: "image-puller-resources-test/imagepuller_testcase_2.json", initObjects: []runtime.Object{ InitImagePuller(chev1alpha1.KubernetesImagePullerSpec{ DeploymentName: defaultDeploymentName, @@ -87,7 +92,7 @@ func TestImagePullerConfiguration(t *testing.T) { DeploymentName: defaultDeploymentName, ConfigMapName: defaultConfigMapName, ImagePullerImage: defaultImagePullerImage, - Images: getDefaultImages(), + Images: "image-1-0=image_1;image-2-1=image_2;image-3-2=image_3;", }), }, { @@ -100,6 +105,7 @@ func TestImagePullerConfiguration(t *testing.T) { DeploymentName: "custom-deployment", Images: "image=image_url;", }}), + testCaseFilePath: "image-puller-resources-test/imagepuller_testcase_1.json", initObjects: []runtime.Object{ InitImagePuller(chev1alpha1.KubernetesImagePullerSpec{ DeploymentName: defaultDeploymentName, @@ -119,6 +125,7 @@ func TestImagePullerConfiguration(t *testing.T) { cheCluster: InitCheCluster(chev2.ImagePuller{ Enable: false, }), + testCaseFilePath: "image-puller-resources-test/imagepuller_testcase_1.json", initObjects: []runtime.Object{ InitImagePuller(chev1alpha1.KubernetesImagePullerSpec{ DeploymentName: defaultDeploymentName, @@ -143,7 +150,13 @@ func TestImagePullerConfiguration(t *testing.T) { }, } - ip := NewImagePuller() + ip := &ImagePuller{ + imageProvider: &DashboardApiDefaultImagesProvider{ + requestRawDataFunc: func(url string) ([]byte, error) { + return os.ReadFile(testCase.testCaseFilePath) + }, + }, + } _, _, err := ip.Reconcile(ctx) assert.NoError(t, err)