From aaadd7d1da0ea7416ec1bf2245a508313ccfe2b5 Mon Sep 17 00:00:00 2001 From: Nahshon Unna-Tsameret Date: Mon, 17 Apr 2023 07:50:43 +0300 Subject: [PATCH] Fix 6323: compress the bundle configMap When creating the bundle confogMap(s), compress the content using gzip. Signed-off-by: Nahshon Unna-Tsameret --- .../support_configmap_compression.yaml | 21 ++ .../registry/fbcindex/configMapWriter.go | 173 ++++++++++ .../registry/fbcindex/fbc_registry_pod.go | 208 ++++++------ .../fbcindex/fbc_registry_pod_test.go | 306 +++++++++++------- 4 files changed, 503 insertions(+), 205 deletions(-) create mode 100644 changelog/fragments/support_configmap_compression.yaml create mode 100644 internal/olm/operator/registry/fbcindex/configMapWriter.go diff --git a/changelog/fragments/support_configmap_compression.yaml b/changelog/fragments/support_configmap_compression.yaml new file mode 100644 index 00000000000..079ab24d186 --- /dev/null +++ b/changelog/fragments/support_configmap_compression.yaml @@ -0,0 +1,21 @@ +# entries is a list of entries to include in +# release notes and/or the migration guide +entries: + - description: > + Compress the bundle content, to avoid the configMap exceed max length error. + The error will look like this: + + `... ConfigMap ... is invalid: []: Too long: must have at most 1048576 bytes`. + + Fixes issue [#6323](https://github.com/operator-framework/operator-sdk/issues/6323) + + # kind is one of: + # - addition + # - change + # - deprecation + # - removal + # - bugfix + kind: "bugfix" + + # Is this a breaking change? + breaking: false diff --git a/internal/olm/operator/registry/fbcindex/configMapWriter.go b/internal/olm/operator/registry/fbcindex/configMapWriter.go new file mode 100644 index 00000000000..b1511e9bc01 --- /dev/null +++ b/internal/olm/operator/registry/fbcindex/configMapWriter.go @@ -0,0 +1,173 @@ +// Copyright 2023 The Operator-SDK Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fbcindex + +import ( + "bytes" + "compress/gzip" + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + yamlSeparator = "\n---\n" + gzipSuffixLength = 13 + maxGZIPLength = maxConfigMapSize - gzipSuffixLength + + ConfigMapEncodingAnnotationKey = "olm.contentEncoding" + ConfigMapEncodingAnnotationGzip = "gzip+base64" +) + +/* +This file implements the actual building of the CM list. It uses the template method design pattern to implement both +regular string VM, and compressed binary CM. + +The method itself is FBCRegistryPod.getConfigMaps. This file contains the actual implementation of the writing actions, +used by the method. +*/ + +type configMapWriter interface { + reset() + newConfigMap(string) *corev1.ConfigMap + getFilePath() string + isEmpty() bool + exceedMaxLength(cmSize int, data string) (bool, error) + closeCM(cm *corev1.ConfigMap) error + addData(data string) error + continueAddData(data string) error + writeLastFragment(cm *corev1.ConfigMap) error +} + +type gzipCMWriter struct { + actualBuff *bytes.Buffer + helperBuff *bytes.Buffer + actualWriter *gzip.Writer + helperWriter *gzip.Writer + cmName string + namespace string +} + +func newGZIPWriter(name, namespace string) *gzipCMWriter { + actualBuff := &bytes.Buffer{} + helperBuff := &bytes.Buffer{} + + return &gzipCMWriter{ + actualBuff: actualBuff, + helperBuff: helperBuff, + actualWriter: gzip.NewWriter(actualBuff), + helperWriter: gzip.NewWriter(helperBuff), + cmName: name, + namespace: namespace, + } +} + +func (cmw *gzipCMWriter) reset() { + cmw.actualBuff.Reset() + cmw.actualWriter.Reset(cmw.actualBuff) + cmw.helperBuff.Reset() + cmw.helperWriter.Reset(cmw.helperBuff) +} + +func (cmw *gzipCMWriter) newConfigMap(name string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + APIVersion: corev1.SchemeGroupVersion.String(), + Kind: "ConfigMap", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: cmw.namespace, + Name: name, + Annotations: map[string]string{ + ConfigMapEncodingAnnotationKey: ConfigMapEncodingAnnotationGzip, + }, + }, + BinaryData: map[string][]byte{}, + } +} + +func (cmw *gzipCMWriter) getFilePath() string { + return fmt.Sprintf("%s.yaml.gz", defaultConfigMapKey) +} + +func (cmw *gzipCMWriter) isEmpty() bool { + return cmw.actualBuff.Len() > 0 +} + +func (cmw *gzipCMWriter) exceedMaxLength(cmSize int, data string) (bool, error) { + _, err := cmw.helperWriter.Write([]byte(data)) + if err != nil { + return false, err + } + + err = cmw.helperWriter.Flush() + if err != nil { + return false, err + } + + return cmSize+cmw.helperBuff.Len() > maxGZIPLength, nil +} + +func (cmw *gzipCMWriter) closeCM(cm *corev1.ConfigMap) error { + err := cmw.actualWriter.Close() + if err != nil { + return err + } + + err = cmw.actualWriter.Flush() + if err != nil { + return err + } + + cm.BinaryData[defaultConfigMapKey] = make([]byte, cmw.actualBuff.Len()) + copy(cm.BinaryData[defaultConfigMapKey], cmw.actualBuff.Bytes()) + + cmw.reset() + + return nil +} + +func (cmw *gzipCMWriter) addData(data string) error { + dataBytes := []byte(data) + _, err := cmw.helperWriter.Write(dataBytes) + if err != nil { + return err + } + _, err = cmw.actualWriter.Write(dataBytes) + if err != nil { + return err + } + return nil +} + +// continueAddData completes adding the data after starting adding it in exceedMaxLength +func (cmw *gzipCMWriter) continueAddData(data string) error { + _, err := cmw.actualWriter.Write([]byte(data)) + if err != nil { + return err + } + return nil +} + +func (cmw *gzipCMWriter) writeLastFragment(cm *corev1.ConfigMap) error { + err := cmw.actualWriter.Close() + if err != nil { + return err + } + + cm.BinaryData[defaultConfigMapKey] = cmw.actualBuff.Bytes() + return nil +} diff --git a/internal/olm/operator/registry/fbcindex/fbc_registry_pod.go b/internal/olm/operator/registry/fbcindex/fbc_registry_pod.go index 32fd1e2cf1c..9da6c2414e3 100644 --- a/internal/olm/operator/registry/fbcindex/fbc_registry_pod.go +++ b/internal/olm/operator/registry/fbcindex/fbc_registry_pod.go @@ -15,13 +15,11 @@ package fbcindex import ( - "bytes" "context" "errors" "fmt" "path" "strings" - "text/template" "time" "github.com/operator-framework/api/pkg/operators/v1alpha1" @@ -45,6 +43,7 @@ const ( defaultGRPCPort = 50051 defaultContainerName = "registry-grpc" + defaultInitContainerName = "registry-grpc-init" defaultContainerPortName = "grpc" defaultConfigMapKey = "extraFBC" @@ -80,6 +79,8 @@ type FBCRegistryPod struct { //nolint:maligned configMapName string + cmWriter configMapWriter + cfg *operator.Configuration } @@ -99,6 +100,8 @@ func (f *FBCRegistryPod) init(cfg *operator.Configuration, cs *v1alpha1.CatalogS f.cfg = cfg + f.cmWriter = newGZIPWriter(f.configMapName, cfg.Namespace) + // validate the FBCRegistryPod struct and ensure required fields are set if err := f.validate(); err != nil { return fmt.Errorf("invalid FBC registry pod: %v", err) @@ -221,10 +224,7 @@ func (f *FBCRegistryPod) podForBundleRegistry(cs *v1alpha1.CatalogSource) (*core bundleImage := f.BundleItems[len(f.BundleItems)-1].ImageTag // construct the container command for pod spec - containerCmd, err := f.getContainerCmd() - if err != nil { - return nil, err - } + containerCmd := fmt.Sprintf(`opm serve %s -p %d`, f.FBCIndexRootDir, f.GRPCPort) //f.getContainerCmd() // create ConfigMap if it does not exist, // if it exists, then update it with new content. @@ -233,8 +233,11 @@ func (f *FBCRegistryPod) podForBundleRegistry(cs *v1alpha1.CatalogSource) (*core return nil, fmt.Errorf("configMap error: %w", err) } - volumes := []corev1.Volume{} - volumeMounts := []corev1.VolumeMount{} + var ( + volumes []corev1.Volume + sharedVolumeMounts []corev1.VolumeMount + gzipVolumeMount []corev1.VolumeMount + ) for _, cm := range cms { volumes = append(volumes, corev1.Volume{ @@ -244,7 +247,7 @@ func (f *FBCRegistryPod) podForBundleRegistry(cs *v1alpha1.CatalogSource) (*core Items: []corev1.KeyToPath{ { Key: defaultConfigMapKey, - Path: path.Join(cm.Name, fmt.Sprintf("%s.yaml", defaultConfigMapKey)), + Path: path.Join(cm.Name, f.cmWriter.getFilePath()), }, }, LocalObjectReference: corev1.LocalObjectReference{ @@ -254,10 +257,25 @@ func (f *FBCRegistryPod) podForBundleRegistry(cs *v1alpha1.CatalogSource) (*core }, }) - volumeMounts = append(volumeMounts, corev1.VolumeMount{ - Name: k8sutil.TrimDNS1123Label(cm.Name + "-volume"), + volumes = append(volumes, corev1.Volume{ + Name: k8sutil.TrimDNS1123Label(cm.Name + "-unzip"), + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }) + + vm := corev1.VolumeMount{ + Name: k8sutil.TrimDNS1123Label(cm.Name + "-unzip"), MountPath: path.Join(f.FBCIndexRootDir, cm.Name), SubPath: cm.Name, + } + + sharedVolumeMounts = append(sharedVolumeMounts, vm) + + gzipVolumeMount = append(gzipVolumeMount, corev1.VolumeMount{ + Name: k8sutil.TrimDNS1123Label(cm.Name + "-volume"), + MountPath: path.Join("/compressed", f.FBCIndexRootDir, cm.Name), + SubPath: cm.Name, }) } @@ -315,25 +333,47 @@ func (f *FBCRegistryPod) podForBundleRegistry(cs *v1alpha1.CatalogSource) (*core Ports: []corev1.ContainerPort{ {Name: defaultContainerPortName, ContainerPort: f.GRPCPort}, }, - VolumeMounts: volumeMounts, + VolumeMounts: sharedVolumeMounts, }, }, ServiceAccountName: f.cfg.ServiceAccount, }, } + f.addGZIPInitContainer(sharedVolumeMounts, gzipVolumeMount) + return f.pod, nil } -// container creation command for FBC type images. -const fbcCmdTemplate = `opm serve {{ .FBCIndexRootDir}} -p {{ .GRPCPort }}` +func (f *FBCRegistryPod) addGZIPInitContainer(containerVolumeMount []corev1.VolumeMount, gzipVolumeMount []corev1.VolumeMount) { + initContainerVolumeMount := append(containerVolumeMount, gzipVolumeMount...) + f.pod.Spec.InitContainers = append(f.pod.Spec.InitContainers, corev1.Container{ + Name: defaultInitContainerName, + Image: "docker.io/library/busybox:1.36.0", + Command: []string{ + "sh", + "-c", + fmt.Sprintf(`for dir in /compressed%s/*configmap-partition*; do `, f.FBCIndexRootDir) + + `for f in ${dir}/*; do ` + + `file="${f%.*}";` + + `file="${file#/compressed}";` + + `cat ${f} | gzip -d -c > "${file}";` + + "done;" + + "done;", + }, + VolumeMounts: initContainerVolumeMount, + }) +} // createConfigMap creates a ConfigMap if it does not exist and if it does, then update it with new content. // Also, sets the owner reference by making CatalogSource the owner of ConfigMap object for cleanup purposes. func (f *FBCRegistryPod) createConfigMaps(cs *v1alpha1.CatalogSource) ([]*corev1.ConfigMap, error) { // By default just use the partitioning logic. // If the entire FBC contents can fit in one ConfigMap it will. - cms := f.partitionedConfigMaps() + cms, err := f.partitionedConfigMaps() + if err != nil { + return nil, err + } // Loop through all the ConfigMaps and set the OwnerReference and try to create them for _, cm := range cms { @@ -354,81 +394,79 @@ func (f *FBCRegistryPod) createConfigMaps(cs *v1alpha1.CatalogSource) ([]*corev1 // partitionedConfigMaps will create and return a list of *corev1.ConfigMap // that represents all the ConfigMaps that will need to be created to // properly have all the FBC contents rendered in the registry pod. -func (f *FBCRegistryPod) partitionedConfigMaps() []*corev1.ConfigMap { +func (f *FBCRegistryPod) partitionedConfigMaps() ([]*corev1.ConfigMap, error) { + var err error // Split on the YAML separator `---` - yamlDefs := strings.Split(f.FBCContent, "---")[1:] - configMaps := []*corev1.ConfigMap{} + yamlDefs := strings.Split(f.FBCContent, "---") + + configMaps, err := f.getConfigMaps(yamlDefs) + if err != nil { + return nil, err + } + + return configMaps, nil +} + +// getConfigMaps builds a list of configMaps, to contain the bundle. +func (f *FBCRegistryPod) getConfigMaps(yamlDefs []string) ([]*corev1.ConfigMap, error) { + defer f.cmWriter.reset() + + cm := f.cmWriter.newConfigMap(fmt.Sprintf("%s-partition-1", f.configMapName)) + configMaps := []*corev1.ConfigMap{cm} + cmSize := cm.Size() - // Keep the number of ConfigMaps that are created to a minimum by - // stuffing them as full as possible. partitionCount := 1 - cm := f.makeBaseConfigMap() + // for each chunk of yaml see if it can be added to the ConfigMap partition for _, yamlDef := range yamlDefs { - // If the ConfigMap has data then lets attempt to add to it - if len(cm.Data) != 0 { - // Create a copy to use to verify that adding the data doesn't - // exceed the max ConfigMap size of 1 MiB. - tempCm := cm.DeepCopy() - tempCm.Data[defaultConfigMapKey] = tempCm.Data[defaultConfigMapKey] + "\n---\n" + yamlDef - - // if it would be too large adding the data then partition it. - if tempCm.Size() >= maxConfigMapSize { - // Set the ConfigMap name based on the partition it is - cm.SetName(fmt.Sprintf("%s-partition-%d", f.configMapName, partitionCount)) - // Increase the partition count + yamlDef = strings.TrimSpace(yamlDef) + if len(yamlDef) == 0 { + continue + } + + if f.cmWriter.isEmpty() { + data := yamlSeparator + yamlDef + exceeded, err := f.cmWriter.exceedMaxLength(cmSize, data) + if err != nil { + return nil, err + } + if exceeded { + err = f.cmWriter.closeCM(cm) + if err != nil { + return nil, err + } + partitionCount++ - // Add the ConfigMap to the list of ConfigMaps - configMaps = append(configMaps, cm.DeepCopy()) - - // Create a new ConfigMap - cm = f.makeBaseConfigMap() - // Since adding this data would have made the previous - // ConfigMap too large, add it to this new one. - // No chunk of YAML from the bundle should cause - // the ConfigMap size to exceed 1 MiB and if - // somehow it does then there is a problem with the - // YAML itself. We can't reasonably break it up smaller - // since it is a single object. - cm.Data[defaultConfigMapKey] = yamlDef + + cm = f.cmWriter.newConfigMap(fmt.Sprintf("%s-partition-%d", f.configMapName, partitionCount)) + configMaps = append(configMaps, cm) + cmSize = cm.Size() + + err = f.cmWriter.addData(yamlDef) + if err != nil { + return nil, err + } } else { - // if adding the data to the ConfigMap - // doesn't make the ConfigMap exceed the - // size limit then actually add it. - cm.Data = tempCm.Data + err = f.cmWriter.continueAddData(data) + if err != nil { + return nil, err + } } } else { - // If there is no data in the ConfigMap - // then this is the first pass. Since it is - // the first pass go ahead and add the data. - cm.Data[defaultConfigMapKey] = yamlDef + err := f.cmWriter.addData(yamlDef) + if err != nil { + return nil, err + } } } - // if there aren't as many ConfigMaps as partitions AND the unadded ConfigMap has data - // then add it to the list of ConfigMaps. This is so we don't miss adding a ConfigMap - // after the above loop completes. - if len(configMaps) != partitionCount && len(cm.Data) != 0 { - cm.SetName(fmt.Sprintf("%s-partition-%d", f.configMapName, partitionCount)) - configMaps = append(configMaps, cm.DeepCopy()) + // write the data of the last cm + err := f.cmWriter.writeLastFragment(cm) + if err != nil { + return nil, err } - return configMaps -} - -// makeBaseConfigMap will return the base *corev1.ConfigMap -// definition that is used by various functions when creating a ConfigMap. -func (f *FBCRegistryPod) makeBaseConfigMap() *corev1.ConfigMap { - return &corev1.ConfigMap{ - TypeMeta: metav1.TypeMeta{ - APIVersion: corev1.SchemeGroupVersion.String(), - Kind: "ConfigMap", - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: f.cfg.Namespace, - }, - Data: map[string]string{}, - } + return configMaps, nil } // createOrUpdateConfigMap will create a ConfigMap if it doesn't exist or @@ -452,6 +490,7 @@ func (f *FBCRegistryPod) createOrUpdateConfigMap(cm *corev1.ConfigMap) error { } // update ConfigMap with new FBCContent tempCm.Data = cm.Data + tempCm.BinaryData = cm.BinaryData return f.cfg.Client.Update(context.TODO(), tempCm) }); err != nil { return fmt.Errorf("error updating ConfigMap: %w", err) @@ -459,20 +498,3 @@ func (f *FBCRegistryPod) createOrUpdateConfigMap(cm *corev1.ConfigMap) error { return nil } - -// getContainerCmd uses templating to construct the container command -// and throws error if unable to parse and execute the container command -func (f *FBCRegistryPod) getContainerCmd() (string, error) { - // add the custom dirname template function to the - // template's FuncMap and parse the cmdTemplate - t := template.Must(template.New("cmd").Parse(fbcCmdTemplate)) - - // execute the command by applying the parsed template to command - // and write command output to out - out := &bytes.Buffer{} - if err := t.Execute(out, f); err != nil { - return "", fmt.Errorf("parse container command: %w", err) - } - - return out.String(), nil -} diff --git a/internal/olm/operator/registry/fbcindex/fbc_registry_pod_test.go b/internal/olm/operator/registry/fbcindex/fbc_registry_pod_test.go index 59fa0003e40..d57f84422a4 100644 --- a/internal/olm/operator/registry/fbcindex/fbc_registry_pod_test.go +++ b/internal/olm/operator/registry/fbcindex/fbc_registry_pod_test.go @@ -15,8 +15,13 @@ package fbcindex import ( + "bytes" + "compress/gzip" "context" "fmt" + "io" + "math/rand" + "regexp" "strings" "testing" "time" @@ -27,7 +32,7 @@ import ( "github.com/operator-framework/operator-sdk/internal/olm/operator" "github.com/operator-framework/operator-sdk/internal/olm/operator/registry/index" corev1 "k8s.io/api/core/v1" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/wait" @@ -61,9 +66,9 @@ var _ = Describe("FBCRegistryPod", func() { cs *v1alpha1.CatalogSource ) - BeforeEach(func() { + JustBeforeEach(func() { cs = &v1alpha1.CatalogSource{ - ObjectMeta: v1.ObjectMeta{ + ObjectMeta: metav1.ObjectMeta{ Name: "test-catalogsource", }, } @@ -85,55 +90,28 @@ var _ = Describe("FBCRegistryPod", func() { }) Context("with valid registry pod values", func() { + It("should create the FBCRegistryPod successfully", func() { expectedPodName := "quay-io-example-example-operator-bundle-0-2-0" Expect(rp).NotTo(BeNil()) Expect(rp.pod.Name).To(Equal(expectedPodName)) Expect(rp.pod.Namespace).To(Equal(rp.cfg.Namespace)) Expect(rp.pod.Spec.Containers[0].Name).To(Equal(defaultContainerName)) - if len(rp.pod.Spec.Containers) > 0 { - if len(rp.pod.Spec.Containers[0].Ports) > 0 { - Expect(rp.pod.Spec.Containers[0].Ports[0].ContainerPort).To(Equal(rp.GRPCPort)) - } - } + Expect(rp.pod.Spec.Containers).Should(HaveLen(1)) + Expect(rp.pod.Spec.Containers[0].Ports).Should(HaveLen(1)) + Expect(rp.pod.Spec.Containers[0].Ports[0].ContainerPort).To(Equal(rp.GRPCPort)) + Expect(rp.pod.Spec.Containers[0].Command).Should(HaveLen(3)) + Expect(rp.pod.Spec.Containers[0].Command).Should(ContainElements("sh", "-c", containerCommandFor(rp.FBCIndexRootDir, rp.GRPCPort))) + Expect(rp.pod.Spec.InitContainers).Should(HaveLen(1)) }) It("should create a registry pod when database path is not provided", func() { Expect(rp.FBCIndexRootDir).To(Equal(fmt.Sprintf("/%s-configs", cs.Name))) }) - - It("should return a valid container command for one image", func() { - output, err := rp.getContainerCmd() - Expect(err).ToNot(HaveOccurred()) - Expect(output).Should(Equal(containerCommandFor(rp.FBCIndexRootDir, rp.GRPCPort))) - }) - - It("should return a valid container command for three images", func() { - bundleItems := append(defaultBundleItems, - index.BundleItem{ - ImageTag: "quay.io/example/example-operator-bundle:0.3.0", - AddMode: index.ReplacesBundleAddMode, - }, - index.BundleItem{ - ImageTag: "quay.io/example/example-operator-bundle:1.0.1", - AddMode: index.SemverBundleAddMode, - }, - index.BundleItem{ - ImageTag: "localhost/example-operator-bundle:1.0.1", - AddMode: index.SemverBundleAddMode, - }, - ) - rp2 := FBCRegistryPod{ - GRPCPort: defaultGRPCPort, - BundleItems: bundleItems, - } - output, err := rp2.getContainerCmd() - Expect(err).ToNot(HaveOccurred()) - Expect(output).Should(Equal(containerCommandFor(rp2.FBCIndexRootDir, rp2.GRPCPort))) - }) }) Context("with invalid registry pod values", func() { + It("should error when bundle image is not provided", func() { expectedErr := "bundle image set cannot be empty" rp := &FBCRegistryPod{} @@ -164,53 +142,81 @@ var _ = Describe("FBCRegistryPod", func() { }) }) - Context("creating a ConfigMap", func() { - It("makeBaseConfigMap() should return a basic ConfigMap manifest", func() { - cm := rp.makeBaseConfigMap() + Context("creating a compressed ConfigMap", func() { + It("cmWriter.makeBaseConfigMap() should return a basic ConfigMap manifest", func() { + cm := rp.cmWriter.newConfigMap("test-cm") + Expect(cm.Name).Should(Equal("test-cm")) Expect(cm.GetObjectKind().GroupVersionKind()).Should(Equal(corev1.SchemeGroupVersion.WithKind("ConfigMap"))) Expect(cm.GetNamespace()).Should(Equal(cfg.Namespace)) - Expect(cm.Data).ShouldNot(BeNil()) - Expect(cm.Data).Should(BeEmpty()) + Expect(cm.Data).Should(BeNil()) + Expect(cm.BinaryData).ShouldNot(BeNil()) + Expect(cm.BinaryData).Should(BeEmpty()) }) - It("partitionedConfigMaps() should return a single ConfigMap", func() { + It("partitionedConfigMaps() should return a single compressed ConfigMap", func() { rp.FBCContent = testYaml - expectedYaml := "" - for i, yaml := range strings.Split(testYaml, "---")[1:] { - if i != 0 { - expectedYaml += "\n---\n" - } + expectedYaml := strings.TrimPrefix(strings.TrimSpace(testYaml), "---\n") - expectedYaml += yaml - } - cms := rp.partitionedConfigMaps() + cms, err := rp.partitionedConfigMaps() + Expect(err).ShouldNot(HaveOccurred()) Expect(cms).Should(HaveLen(1)) - Expect(cms[0].Data).Should(HaveKey("extraFBC")) - Expect(cms[0].Data["extraFBC"]).Should(Equal(expectedYaml)) + Expect(cms[0].BinaryData).Should(HaveKey("extraFBC")) + + By("uncompressed the BinaryData") + uncompressed := decompressCM(cms[0]) + Expect(uncompressed).Should(Equal(expectedYaml)) }) - It("partitionedConfigMaps() should return multiple ConfigMaps", func() { - // Create a large yaml manifest - largeYaml := "" - for i := len([]byte(largeYaml)); i < maxConfigMapSize; { - largeYaml += testYaml - i = len([]byte(largeYaml)) + It("partitionedConfigMaps() should return a single compressed ConfigMap for large yaml", func() { + largeYaml := strings.Builder{} + for largeYaml.Len() < maxConfigMapSize { + largeYaml.WriteString(testYaml) } + rp.FBCContent = largeYaml.String() + + expectedYaml := strings.TrimPrefix(strings.TrimSpace(largeYaml.String()), "---\n") + expectedYaml = regexp.MustCompile(`\n\n+`).ReplaceAllString(expectedYaml, "\n") + + cms, err := rp.partitionedConfigMaps() + Expect(err).ShouldNot(HaveOccurred()) + Expect(cms).Should(HaveLen(1)) + Expect(cms[0].BinaryData).Should(HaveKey("extraFBC")) + actualBinaryData := cms[0].BinaryData["extraFBC"] + Expect(len(actualBinaryData)).Should(BeNumerically("<", maxConfigMapSize)) + By("uncompress the BinaryData") + uncompressed := decompressCM(cms[0]) + Expect(uncompressed).Should(Equal(expectedYaml)) + }) + + It("partitionedConfigMaps() should return a multiple compressed ConfigMaps for a huge yaml", func() { + // build completely random yamls. This is because gzip relies on duplications, and so repeated text is + // compressed very well, so we'll need a really huge input to create more than one CM. When using random + // input, gzip will create larger output, and we can get to multiple CM with much smaller input. + largeYamlBuilder := strings.Builder{} + for largeYamlBuilder.Len() < maxConfigMapSize*2 { + largeYamlBuilder.WriteString(generateRandYaml()) + } + largeYaml := largeYamlBuilder.String() rp.FBCContent = largeYaml - cms := rp.partitionedConfigMaps() + expectedYaml := strings.TrimPrefix(strings.TrimSpace(largeYaml), "---\n") + expectedYaml = regexp.MustCompile(`\n\n+`).ReplaceAllString(expectedYaml, "\n") + + cms, err := rp.partitionedConfigMaps() + Expect(err).ShouldNot(HaveOccurred()) + Expect(cms).Should(HaveLen(2)) - Expect(cms[0].Data).Should(HaveKey("extraFBC")) - Expect(cms[0].Data["extraFBC"]).ShouldNot(BeEmpty()) - Expect(cms[1].Data).Should(HaveKey("extraFBC")) - Expect(cms[1].Data["extraFBC"]).ShouldNot(BeEmpty()) + Expect(cms[0].BinaryData).Should(HaveKey("extraFBC")) + Expect(cms[1].BinaryData).Should(HaveKey("extraFBC")) + decompressed1 := decompressCM(cms[1]) + decompressed0 := decompressCM(cms[0]) + Expect(decompressed0 + "\n---\n" + decompressed1).Should(Equal(expectedYaml)) }) - It("createOrUpdateConfigMap() should create the ConfigMap if it does not exist", func() { - cm := rp.makeBaseConfigMap() - cm.SetName("test-cm") - cm.Data["test"] = "hello test world!" + It("createOrUpdateConfigMap() should create the compressed ConfigMap if it does not exist", func() { + cm := rp.cmWriter.newConfigMap("test-cm") + cm.BinaryData["test"] = compress("hello test world!") Expect(rp.createOrUpdateConfigMap(cm)).Should(Succeed()) @@ -219,12 +225,11 @@ var _ = Describe("FBCRegistryPod", func() { Expect(testCm).Should(BeEquivalentTo(cm)) }) - It("createOrUpdateConfigMap() should update the ConfigMap if it already exists", func() { - cm := rp.makeBaseConfigMap() - cm.SetName("test-cm") - cm.Data["test"] = "hello test world!" + It("createOrUpdateConfigMap() should update the compressed ConfigMap if it already exists", func() { + cm := rp.cmWriter.newConfigMap("test-cm") + cm.BinaryData["test"] = compress("hello test world!") Expect(rp.cfg.Client.Create(context.TODO(), cm)).Should(Succeed()) - cm.Data["test"] = "hello changed world!" + cm.BinaryData["test"] = compress("hello changed world!") cm.SetResourceVersion("2") Expect(rp.createOrUpdateConfigMap(cm)).Should(Succeed()) @@ -234,17 +239,36 @@ var _ = Describe("FBCRegistryPod", func() { Expect(testCm).Should(BeEquivalentTo(cm)) }) - It("createConfigMaps() should create a single ConfigMap", func() { - rp.FBCContent = testYaml - expectedYaml := "" - for i, yaml := range strings.Split(testYaml, "---")[1:] { - if i != 0 { - expectedYaml += "\n---\n" - } - - expectedYaml += yaml + It("createOrUpdateConfigMap() should update the uncompressed-old ConfigMap if it already exists", func() { + origCM := &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + APIVersion: corev1.SchemeGroupVersion.String(), + Kind: "ConfigMap", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: rp.cfg.Namespace, + Name: "test-cm", + }, + Data: map[string]string{"test": "hello test world!"}, } + Expect(rp.cfg.Client.Create(context.TODO(), origCM)).Should(Succeed()) + cm := rp.cmWriter.newConfigMap("test-cm") + cm.BinaryData["test"] = compress("hello changed world!") + cm.SetResourceVersion("2") + + Expect(rp.createOrUpdateConfigMap(cm)).Should(Succeed()) + + testCm := &corev1.ConfigMap{} + Expect(rp.cfg.Client.Get(context.TODO(), types.NamespacedName{Namespace: rp.cfg.Namespace, Name: cm.GetName()}, testCm)).Should(Succeed()) + Expect(cm.Data).Should(BeNil()) + Expect(testCm.BinaryData).Should(BeEquivalentTo(cm.BinaryData)) + }) + + It("createConfigMaps() should create a single compressed ConfigMap", func() { + rp.FBCContent = testYaml + + expectedYaml := strings.TrimPrefix(strings.TrimSpace(testYaml), "---\n") expectedName := fmt.Sprintf("%s-configmap-partition-1", cs.GetName()) cms, err := rp.createConfigMaps(cs) @@ -252,46 +276,53 @@ var _ = Describe("FBCRegistryPod", func() { Expect(cms).Should(HaveLen(1)) Expect(cms[0].GetNamespace()).Should(Equal(rp.cfg.Namespace)) Expect(cms[0].GetName()).Should(Equal(expectedName)) - Expect(cms[0].Data).Should(HaveKey("extraFBC")) - Expect(cms[0].Data["extraFBC"]).Should(Equal(expectedYaml)) + Expect(cms[0].Data).Should(BeNil()) + Expect(cms[0].BinaryData).Should(HaveKey("extraFBC")) + uncompressed := decompressCM(cms[0]) + Expect(uncompressed).Should(Equal(expectedYaml)) testCm := &corev1.ConfigMap{} Expect(rp.cfg.Client.Get(context.TODO(), types.NamespacedName{Namespace: rp.cfg.Namespace, Name: expectedName}, testCm)).Should(Succeed()) - Expect(testCm.Data).Should(HaveKey("extraFBC")) - Expect(testCm.Data["extraFBC"]).Should(Equal(expectedYaml)) + Expect(testCm.BinaryData).Should(HaveKey("extraFBC")) + Expect(testCm.Data).Should(BeNil()) + uncompressed = decompressCM(testCm) + Expect(uncompressed).Should(Equal(expectedYaml)) Expect(testCm.OwnerReferences).Should(HaveLen(1)) }) - It("createConfigMaps() should create multiple ConfigMaps", func() { - largeYaml := "" - for i := len([]byte(largeYaml)); i < maxConfigMapSize; { - largeYaml += testYaml - i = len([]byte(largeYaml)) - } - rp.FBCContent = largeYaml - - cms, err := rp.createConfigMaps(cs) - Expect(err).ShouldNot(HaveOccurred()) - Expect(cms).Should(HaveLen(2)) - - for i, cm := range cms { - expectedName := fmt.Sprintf("%s-configmap-partition-%d", cs.GetName(), i+1) - Expect(cm.Data).Should(HaveKey("extraFBC")) - Expect(cm.Data["extraFBC"]).ShouldNot(BeEmpty()) - Expect(cm.GetNamespace()).Should(Equal(rp.cfg.Namespace)) - Expect(cm.GetName()).Should(Equal(expectedName)) - - testCm := &corev1.ConfigMap{} - Expect(rp.cfg.Client.Get(context.TODO(), types.NamespacedName{Namespace: rp.cfg.Namespace, Name: expectedName}, testCm)).Should(Succeed()) - Expect(testCm.Data).Should(HaveKey("extraFBC")) - Expect(testCm.Data["extraFBC"]).Should(Equal(cm.Data["extraFBC"])) - Expect(testCm.OwnerReferences).Should(HaveLen(1)) - } + It("should create the compressed FBCRegistryPod successfully", func() { + expectedPodName := "quay-io-example-example-operator-bundle-0-2-0" + Expect(rp).NotTo(BeNil()) + Expect(rp.pod.Name).To(Equal(expectedPodName)) + Expect(rp.pod.Namespace).To(Equal(rp.cfg.Namespace)) + Expect(rp.pod.Spec.Containers[0].Name).To(Equal(defaultContainerName)) + Expect(rp.pod.Spec.Containers).Should(HaveLen(1)) + Expect(rp.pod.Spec.Containers[0].Ports).Should(HaveLen(1)) + Expect(rp.pod.Spec.Containers[0].Ports[0].ContainerPort).To(Equal(rp.GRPCPort)) + Expect(rp.pod.Spec.Containers[0].Command).Should(HaveLen(3)) + Expect(rp.pod.Spec.Containers[0].Command).Should(ContainElements("sh", "-c", containerCommandFor(rp.FBCIndexRootDir, rp.GRPCPort))) + Expect(rp.pod.Spec.InitContainers).Should(HaveLen(1)) + Expect(rp.pod.Spec.InitContainers[0].VolumeMounts).Should(HaveLen(2)) }) }) }) }) +func decompressCM(cm *corev1.ConfigMap) string { + actualBinaryData := cm.BinaryData["extraFBC"] + ExpectWithOffset(1, len(actualBinaryData)).Should(BeNumerically("<", maxConfigMapSize)) + By("uncompress the BinaryData") + compressed := bytes.NewBuffer(actualBinaryData) + reader, err := gzip.NewReader(compressed) + ExpectWithOffset(1, err).ShouldNot(HaveOccurred()) + var uncompressed bytes.Buffer + ExpectWithOffset(1, reader.Close()).Should(Succeed()) + _, err = io.Copy(&uncompressed, reader) + ExpectWithOffset(1, err).ShouldNot(HaveOccurred()) + + return uncompressed.String() +} + // containerCommandFor returns the expected container command for a db path and set of bundle items. func containerCommandFor(indexRootDir string, grpcPort int32) string { //nolint:unparam return fmt.Sprintf("opm serve %s -p %d", indexRootDir, grpcPort) @@ -332,3 +363,54 @@ address: postcode: '89393' country: 'French Southern Territories' ` +const charTbl = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+=*&^%$#@!,.;~/\\|" + +var rnd = rand.New(rand.NewSource(time.Now().UnixMilli())) + +func randField() string { + + fieldNameLength := rnd.Intn(15) + 5 + fieldName := make([]byte, fieldNameLength) + for i := 0; i < fieldNameLength; i++ { + fieldName[i] = charTbl[rnd.Intn('z'-'a'+1)] + } + + // random field name between 5 and 45 + size := rnd.Intn(40) + 5 + + value := make([]byte, size) + for i := 0; i < size; i++ { + value[i] = charTbl[rnd.Intn(len(charTbl))] + } + return fmt.Sprintf("%s: %q\n", fieldName, value) +} + +func generateRandYaml() string { + numLines := rnd.Intn(45) + 5 + + b := strings.Builder{} + b.WriteString("---\n") + for i := 0; i < numLines; i++ { + b.WriteString(randField()) + } + return b.String() +} + +var ( + compressBuff = &bytes.Buffer{} + compressor = gzip.NewWriter(compressBuff) +) + +func compress(s string) []byte { + compressBuff.Reset() + compressor.Reset(compressBuff) + + input := bytes.NewBufferString(s) + _, err := io.Copy(compressor, input) + ExpectWithOffset(1, err).ShouldNot(HaveOccurred()) + + Expect(compressor.Flush()).Should(Succeed()) + Expect(compressor.Close()).Should(Succeed()) + + return compressBuff.Bytes() +}