From 689fe7a3af567fa9e461281ed43cd3f8aa180fc5 Mon Sep 17 00:00:00 2001 From: Nahshon Unna-Tsameret Date: Mon, 17 Apr 2023 07:50:43 +0300 Subject: [PATCH] Fix 6323: allow compression of the configMap Add the `--gzip-configmap=true` CLI parameter in order to compress the configMap, if it exeeds the max length. Signed-off-by: Nahshon Unna-Tsameret --- .../support_configmap_compression.yaml | 19 ++ internal/olm/operator/config.go | 3 + .../registry/fbcindex/configMapWriter.go | 282 +++++++++++++++++ .../registry/fbcindex/fbc_registry_pod.go | 98 ++---- .../fbcindex/fbc_registry_pod_test.go | 288 +++++++++++++++--- .../en/docs/cli/operator-sdk_cleanup.md | 1 + .../cli/operator-sdk_run_bundle-upgrade.md | 1 + .../en/docs/cli/operator-sdk_run_bundle.md | 1 + 8 files changed, 580 insertions(+), 113 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..d54ce2a4a3a --- /dev/null +++ b/changelog/fragments/support_configmap_compression.yaml @@ -0,0 +1,19 @@ +# entries is a list of entries to include in +# release notes and/or the migration guide +entries: + - description: > + Allow compress the bundle content. Added a new cli flag `--gzip-configmap=true` to the `operator-sdk run bundle`. + This will create compressed configmaps. Use it when getting this error: + `... 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/config.go b/internal/olm/operator/config.go index 10338c5bb8b..9e976e4fc3d 100644 --- a/internal/olm/operator/config.go +++ b/internal/olm/operator/config.go @@ -37,6 +37,7 @@ type Configuration struct { Client client.Client Scheme *runtime.Scheme Timeout time.Duration + GzipCM bool overrides *clientcmd.ConfigOverrides } @@ -62,6 +63,8 @@ func (c *Configuration) BindFlags(fs *pflag.FlagSet) { "This value does not override the operator's service account") fs.DurationVar(&c.Timeout, "timeout", 2*time.Minute, "Duration to wait for the command to complete before failing") + fs.BoolVar(&c.GzipCM, "gzip-configmap", false, `If 'true', the configmap data will be compressed. `+ + `Use if in case of getting the like "... ConfigMap ... is invalid: []: Too long: must have at most 1048576 bytes"`) } func (c *Configuration) Load() error { diff --git a/internal/olm/operator/registry/fbcindex/configMapWriter.go b/internal/olm/operator/registry/fbcindex/configMapWriter.go new file mode 100644 index 00000000000..d16e1bbfb33 --- /dev/null +++ b/internal/olm/operator/registry/fbcindex/configMapWriter.go @@ -0,0 +1,282 @@ +package fbcindex + +import ( + "bytes" + "compress/gzip" + "fmt" + "strings" + "text/template" + + 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" +) + +type configMapWriter interface { + newConfigMap() *corev1.ConfigMap + writeYaml([]string) ([]*corev1.ConfigMap, error) + getFilePath() string + getCommandTemplate() *template.Template +} + +type defaultCMWriter struct { + cmName string + namespace string + template *template.Template +} + +func newDefaultWriter(name, namespace string) *defaultCMWriter { + t := template.Must(template.New("cmd").Parse(fbcCmdTemplate)) + return &defaultCMWriter{ + cmName: name, + namespace: namespace, + template: t, + } +} + +// container entrypoint command for FBC type images. +const fbcCmdTemplate = `opm serve {{ .FBCIndexRootDir}} -p {{ .GRPCPort }}` + +func (cmw defaultCMWriter) newConfigMap() *corev1.ConfigMap { + return &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + APIVersion: corev1.SchemeGroupVersion.String(), + Kind: "ConfigMap", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: cmw.namespace, + }, + Data: map[string]string{}, + } +} + +func (cmw defaultCMWriter) writeYaml(yamlDefs []string) ([]*corev1.ConfigMap, error) { + cm := cmw.newConfigMap() + cm.SetName(fmt.Sprintf("%s-partition-1", cmw.cmName)) + configMaps := []*corev1.ConfigMap{cm} + + partitionCount := 1 + + // for each chunk of yaml see if it can be added to the ConfigMap partition + for _, yamlDef := range yamlDefs { + yamlDef = strings.TrimSpace(yamlDef) + if len(strings.TrimSpace(yamlDef)) == 0 { + continue + } + // 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] + yamlSeparator + yamlDef + + // if it would be too large adding the data then partition it. + if tempCm.Size() >= maxConfigMapSize { + // Create a new ConfigMap + cm = cmw.newConfigMap() + + // Set the ConfigMap name based on the partition it is + cm.SetName(fmt.Sprintf("%s-partition-%d", cmw.cmName, partitionCount+1)) + + // 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 + + // Add the ConfigMap to the list of ConfigMaps + configMaps = append(configMaps, cm) + + } 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 + } + } 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 + } + } + + return configMaps, nil +} + +func (cmw defaultCMWriter) getFilePath() string { + return fmt.Sprintf("%s.yaml", defaultConfigMapKey) +} + +func (cmw defaultCMWriter) getCommandTemplate() *template.Template { + return cmw.template +} + +type gzipCMWriter struct { + actualBuff *bytes.Buffer + helperBuff *bytes.Buffer + actualWriter *gzip.Writer + helperWriter *gzip.Writer + cmName string + namespace string + template *template.Template +} + +func newGZIPWriter(name, namespace string) *gzipCMWriter { + actualBuff := &bytes.Buffer{} + helperBuff := &bytes.Buffer{} + t := template.Must(template.New("cmd").Parse(fbcGzipCmdTemplate)) + + return &gzipCMWriter{ + actualBuff: actualBuff, + helperBuff: helperBuff, + actualWriter: gzip.NewWriter(actualBuff), + helperWriter: gzip.NewWriter(helperBuff), + cmName: name, + namespace: namespace, + template: t, + } +} + +// container entrypoint command for gzipped FBC type images. +const fbcGzipCmdTemplate = `` + + `serverDir=/var/tmp/{{ .FBCIndexRootDir }}/;` + + `mkdir ${serverDir};` + + `for dir in {{ .FBCIndexRootDir }}/*configmap-partition-*;` + + `do targetDir="/var/tmp/${dir}";` + + `mkdir ${targetDir};` + + `for f in ${dir}/*gz; ` + + `do cat $f | gzip -d -c > "/var/tmp/${f%.*}";` + + `done;` + + `done;` + + `opm serve ${serverDir} -p {{ .GRPCPort }}` + +func (cmw *gzipCMWriter) reset() { + cmw.actualBuff.Reset() + cmw.actualWriter.Reset(cmw.actualBuff) + cmw.helperBuff.Reset() + cmw.helperWriter.Reset(cmw.helperBuff) +} + +func (cmw *gzipCMWriter) newConfigMap() *corev1.ConfigMap { + return &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + APIVersion: corev1.SchemeGroupVersion.String(), + Kind: "ConfigMap", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: cmw.namespace, + Annotations: map[string]string{ + ConfigMapEncodingAnnotationKey: ConfigMapEncodingAnnotationGzip, + }, + }, + BinaryData: map[string][]byte{}, + } +} + +func (cmw *gzipCMWriter) writeYaml(yamlDefs []string) ([]*corev1.ConfigMap, error) { + defer cmw.reset() + + cm := cmw.newConfigMap() + cm.SetName(fmt.Sprintf("%s-partition-1", cmw.cmName)) + configMaps := []*corev1.ConfigMap{cm} + + partitionCount := 1 + + // for each chunk of yaml see if it can be added to the ConfigMap partition + for _, yamlDef := range yamlDefs { + yamlDef = strings.TrimSpace(yamlDef) + if len(yamlDef) == 0 { + continue + } + + if cmw.actualBuff.Len() > 0 { + data := []byte(yamlSeparator + yamlDef) + _, err := cmw.helperWriter.Write(data) + if err != nil { + return nil, err + } + + err = cmw.helperWriter.Flush() + if err != nil { + return nil, err + } + + if cm.Size()+cmw.helperBuff.Len() > maxGZIPLength { + err = cmw.actualWriter.Close() + if err != nil { + return nil, err + } + + err = cmw.actualWriter.Flush() + if err != nil { + return nil, err + } + + cm.BinaryData[defaultConfigMapKey] = make([]byte, cmw.actualBuff.Len()) + copy(cm.BinaryData[defaultConfigMapKey], cmw.actualBuff.Bytes()) + + cmw.reset() + + partitionCount++ + cm = cmw.newConfigMap() + cm.SetName(fmt.Sprintf("%s-partition-%d", cmw.cmName, partitionCount)) + configMaps = append(configMaps, cm) + + data = []byte(yamlDef) + _, err = cmw.helperWriter.Write(data) + if err != nil { + return nil, err + } + _, err = cmw.actualWriter.Write(data) + if err != nil { + return nil, err + } + } else { + _, err = cmw.actualWriter.Write(data) + if err != nil { + return nil, err + } + } + } else { + data := []byte(yamlDef) + _, err := cmw.helperWriter.Write(data) + if err != nil { + return nil, err + } + _, err = cmw.actualWriter.Write(data) + if err != nil { + return nil, err + } + } + } + + // write the data of the last cm + err := cmw.actualWriter.Close() + if err != nil { + return nil, err + } + + cm.BinaryData[defaultConfigMapKey] = cmw.actualBuff.Bytes() + + return configMaps, nil +} + +func (cmw *gzipCMWriter) getFilePath() string { + return fmt.Sprintf("%s.yaml.gz", defaultConfigMapKey) +} + +func (cmw *gzipCMWriter) getCommandTemplate() *template.Template { + return cmw.template +} diff --git a/internal/olm/operator/registry/fbcindex/fbc_registry_pod.go b/internal/olm/operator/registry/fbcindex/fbc_registry_pod.go index 32fd1e2cf1c..4d54c47b9d7 100644 --- a/internal/olm/operator/registry/fbcindex/fbc_registry_pod.go +++ b/internal/olm/operator/registry/fbcindex/fbc_registry_pod.go @@ -21,7 +21,6 @@ import ( "fmt" "path" "strings" - "text/template" "time" "github.com/operator-framework/api/pkg/operators/v1alpha1" @@ -80,6 +79,8 @@ type FBCRegistryPod struct { //nolint:maligned configMapName string + cmWriter configMapWriter + cfg *operator.Configuration } @@ -99,6 +100,12 @@ func (f *FBCRegistryPod) init(cfg *operator.Configuration, cs *v1alpha1.CatalogS f.cfg = cfg + if f.cfg.GzipCM { + f.cmWriter = newGZIPWriter(f.configMapName, cfg.Namespace) + } else { + f.cmWriter = newDefaultWriter(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) @@ -244,7 +251,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{ @@ -325,15 +332,15 @@ func (f *FBCRegistryPod) podForBundleRegistry(cs *v1alpha1.CatalogSource) (*core return f.pod, nil } -// container creation command for FBC type images. -const fbcCmdTemplate = `opm serve {{ .FBCIndexRootDir}} -p {{ .GRPCPort }}` - // 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 +361,25 @@ 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{} - - // 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 - 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 - } 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 - } - } 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 - } - } + yamlDefs := strings.Split(f.FBCContent, "---") - // 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()) + cm := f.cmWriter.newConfigMap() + cm.SetName(fmt.Sprintf("%s-partition-1", f.configMapName)) + configMaps, err := f.cmWriter.writeYaml(yamlDefs) + if err != nil { + return nil, err } - return configMaps + return configMaps, nil } // 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 f.cmWriter.newConfigMap() } // createOrUpdateConfigMap will create a ConfigMap if it doesn't exist or @@ -452,6 +403,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) @@ -465,7 +417,7 @@ func (f *FBCRegistryPod) createOrUpdateConfigMap(cm *corev1.ConfigMap) error { 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)) + t := f.cmWriter.getCommandTemplate() // execute the command by applying the parsed template to command // and write command output to out 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 94d11de0232..d36a7865a98 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" @@ -56,12 +61,13 @@ var _ = Describe("FBCRegistryPod", func() { Describe("creating registry pod", func() { var ( - rp *FBCRegistryPod - cfg *operator.Configuration - cs *v1alpha1.CatalogSource + rp *FBCRegistryPod + cfg *operator.Configuration + cs *v1alpha1.CatalogSource + gzipCM bool ) - BeforeEach(func() { + JustBeforeEach(func() { cs = &v1alpha1.CatalogSource{ ObjectMeta: v1.ObjectMeta{ Name: "test-catalogsource", @@ -75,6 +81,7 @@ var _ = Describe("FBCRegistryPod", func() { Client: newFakeClient(), Namespace: "test-default", Scheme: schm, + GzipCM: gzipCM, } rp = &FBCRegistryPod{ BundleItems: defaultBundleItems, @@ -85,6 +92,9 @@ var _ = Describe("FBCRegistryPod", func() { }) Context("with valid registry pod values", func() { + BeforeEach(func() { + gzipCM = false + }) It("should create the FBCRegistryPod successfully", func() { expectedPodName := "quay-io-example-example-operator-bundle-0-2-0" Expect(rp).NotTo(BeNil()) @@ -127,13 +137,18 @@ var _ = Describe("FBCRegistryPod", func() { GRPCPort: defaultGRPCPort, BundleItems: bundleItems, } + _ = rp2.init(cfg, cs) // ignore expected error output, err := rp2.getContainerCmd() - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(output).Should(Equal(containerCommandFor(rp2.FBCIndexRootDir, rp2.GRPCPort))) }) }) Context("with invalid registry pod values", func() { + BeforeEach(func() { + gzipCM = false + }) + It("should error when bundle image is not provided", func() { expectedErr := "bundle image set cannot be empty" rp := &FBCRegistryPod{} @@ -165,41 +180,38 @@ var _ = Describe("FBCRegistryPod", func() { }) Context("creating a ConfigMap", func() { - It("makeBaseConfigMap() should return a basic ConfigMap manifest", func() { + BeforeEach(func() { + gzipCM = false + }) + + It("defaultWriter.makeBaseConfigMap() should return a basic ConfigMap manifest", func() { cm := rp.makeBaseConfigMap() Expect(cm.GetObjectKind().GroupVersionKind()).Should(Equal(corev1.SchemeGroupVersion.WithKind("ConfigMap"))) Expect(cm.GetNamespace()).Should(Equal(cfg.Namespace)) Expect(cm.Data).ShouldNot(BeNil()) - Expect(len(cm.Data)).Should(Equal(0)) + Expect(cm.Data).Should(BeEmpty()) + Expect(cm.BinaryData).Should(BeNil()) }) It("partitionedConfigMaps() should return a single ConfigMap", func() { rp.FBCContent = testYaml - expectedYaml := "" - for i, yaml := range strings.Split(testYaml, "---")[1:] { - if i != 0 { - expectedYaml += "\n---\n" - } - - expectedYaml += yaml - } - cms := rp.partitionedConfigMaps() - Expect(len(cms)).Should(Equal(1)) - Expect(cms[0].Data).Should(HaveKey("extraFBC")) - Expect(cms[0].Data["extraFBC"]).Should(Equal(expectedYaml)) + expectedYaml := strings.TrimPrefix(strings.TrimSpace(testYaml), "---\n") + cms, err := rp.partitionedConfigMaps() + Expect(err).ShouldNot(HaveOccurred()) + Expect(cms).Should(HaveLen(1)) + Expect(cms[0].Data).Should(HaveKeyWithValue("extraFBC", expectedYaml)) }) - It("partitionedConfigMaps() should return multiple ConfigMaps", func() { // Create a large yaml manifest largeYaml := "" - for i := len([]byte(largeYaml)); i < maxConfigMapSize; { + for i := 0; i < maxConfigMapSize; i = len([]byte(largeYaml)) { largeYaml += testYaml - i = len([]byte(largeYaml)) } rp.FBCContent = largeYaml - cms := rp.partitionedConfigMaps() + cms, err := rp.partitionedConfigMaps() + Expect(err).ShouldNot(HaveOccurred()) Expect(len(cms)).Should(Equal(2)) Expect(cms[0].Data).Should(HaveKey("extraFBC")) Expect(cms[0].Data["extraFBC"]).ShouldNot(BeEmpty()) @@ -236,43 +248,34 @@ var _ = Describe("FBCRegistryPod", func() { 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 - } + expectedYaml := strings.TrimPrefix(strings.TrimSpace(testYaml), "---\n") expectedName := fmt.Sprintf("%s-configmap-partition-1", cs.GetName()) cms, err := rp.createConfigMaps(cs) Expect(err).ShouldNot(HaveOccurred()) - Expect(len(cms)).Should(Equal(1)) + 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(HaveKeyWithValue("extraFBC", 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(len(testCm.OwnerReferences)).Should(Equal(1)) + Expect(testCm.Data).Should(HaveKeyWithValue("extraFBC", expectedYaml)) + Expect(testCm.OwnerReferences).Should(HaveLen(1)) }) It("createConfigMaps() should create multiple ConfigMaps", func() { largeYaml := "" - for i := len([]byte(largeYaml)); i < maxConfigMapSize; { + for i := 0; i < maxConfigMapSize; i = len([]byte(largeYaml)) { largeYaml += testYaml - i = len([]byte(largeYaml)) } + rp.FBCContent = largeYaml cms, err := rp.createConfigMaps(cs) Expect(err).ShouldNot(HaveOccurred()) - Expect(len(cms)).Should(Equal(2)) + Expect(cms).Should(HaveLen(2)) for i, cm := range cms { expectedName := fmt.Sprintf("%s-configmap-partition-%d", cs.GetName(), i+1) @@ -288,10 +291,164 @@ var _ = Describe("FBCRegistryPod", func() { Expect(len(testCm.OwnerReferences)).Should(Equal(1)) } }) + + }) + + Context("creating a compressed ConfigMap", func() { + BeforeEach(func() { + gzipCM = true + }) + + It("defaultWriter.makeBaseConfigMap() should return a basic ConfigMap manifest", func() { + cm := rp.makeBaseConfigMap() + Expect(cm.GetObjectKind().GroupVersionKind()).Should(Equal(corev1.SchemeGroupVersion.WithKind("ConfigMap"))) + Expect(cm.GetNamespace()).Should(Equal(cfg.Namespace)) + Expect(cm.Data).Should(BeNil()) + Expect(cm.BinaryData).ShouldNot(BeNil()) + Expect(cm.BinaryData).Should(BeEmpty()) + }) + + It("partitionedConfigMaps() should return a single compressed ConfigMap", func() { + rp.FBCContent = testYaml + expectedYaml := strings.TrimPrefix(strings.TrimSpace(testYaml), "---\n") + + cms, err := rp.partitionedConfigMaps() + Expect(err).ShouldNot(HaveOccurred()) + Expect(cms).Should(HaveLen(1)) + Expect(cms[0].BinaryData).Should(HaveKey("extraFBC")) + + By("uncompressed the BinaryData") + uncompressed := decompressCM(cms[0]) + Expect(uncompressed).Should(Equal(expectedYaml)) + }) + + It("partitionedConfigMaps() should return a single compressed ConfigMap for large yaml", func() { + largeYaml := "" + for i := 0; i < maxConfigMapSize; i = len([]byte(largeYaml)) { + largeYaml += testYaml + } + rp.FBCContent = largeYaml + + 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(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 i := 0; i < maxConfigMapSize*2; i = largeYamlBuilder.Len() { + largeYamlBuilder.WriteString(generateRandYaml()) + } + largeYaml := largeYamlBuilder.String() + rp.FBCContent = largeYaml + + expectedYaml := strings.TrimPrefix(strings.TrimSpace(largeYaml), "---\n") + expectedYaml = regexp.MustCompile(`\n\n+`).ReplaceAllString(expectedYaml, "\n") + + // debug + length := float64(len(expectedYaml)) + GinkgoWriter.Printf("expected yaml created; expected yaml length: %.0f bytes; %.2fkb; %.2fmb\n", length, length/1024, length/1024/1024) + // debug-end + + cms, err := rp.partitionedConfigMaps() + Expect(err).ShouldNot(HaveOccurred()) + + // debug + length = float64(len(cms[0].BinaryData["extraFBC"])) + float64(len(cms[1].BinaryData["extraFBC"])) + GinkgoWriter.Printf("CM was created; expected yaml length: %.0f bytes; %.2fkb; %.2fmb\n", length, length/1024, length/1024/1024) + // debug-end + + Expect(cms).Should(HaveLen(2)) + 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 compressed ConfigMap if it does not exist", func() { + cm := rp.makeBaseConfigMap() + cm.SetName("test-cm") + cm.BinaryData["test"] = compress("hello test world!") + + 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(testCm).Should(BeEquivalentTo(cm)) + }) + + It("createOrUpdateConfigMap() should update the compressed ConfigMap if it already exists", func() { + cm := rp.makeBaseConfigMap() + cm.SetName("test-cm") + cm.BinaryData["test"] = compress("hello test world!") + Expect(rp.cfg.Client.Create(context.TODO(), cm)).Should(Succeed()) + 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(testCm).Should(BeEquivalentTo(cm)) + }) + + 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) + Expect(err).ShouldNot(HaveOccurred()) + 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(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.BinaryData).Should(HaveKey("extraFBC")) + Expect(testCm.Data).Should(BeNil()) + uncompressed = decompressCM(testCm) + Expect(uncompressed).Should(Equal(expectedYaml)) + Expect(testCm.OwnerReferences).Should(HaveLen(1)) + }) }) }) }) +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 +489,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() +} diff --git a/website/content/en/docs/cli/operator-sdk_cleanup.md b/website/content/en/docs/cli/operator-sdk_cleanup.md index 98d4ba8fdf3..8eeae6493b8 100644 --- a/website/content/en/docs/cli/operator-sdk_cleanup.md +++ b/website/content/en/docs/cli/operator-sdk_cleanup.md @@ -19,6 +19,7 @@ operator-sdk cleanup [flags] --delete-all If set to true, all other delete options will be enabled (default true) --delete-crds If set to true, owned CRDs and CRs will be deleted --delete-operator-groups If set to true, operator groups will be deleted + --gzip-configmap If 'true', the configmap data will be compressed. Use if in case of getting the like "... ConfigMap ... is invalid: []: Too long: must have at most 1048576 bytes" -h, --help help for cleanup --kubeconfig string Path to the kubeconfig file to use for CLI requests. -n, --namespace string If present, namespace scope for this CLI request diff --git a/website/content/en/docs/cli/operator-sdk_run_bundle-upgrade.md b/website/content/en/docs/cli/operator-sdk_run_bundle-upgrade.md index f24f64d8628..50fda3fa6e8 100644 --- a/website/content/en/docs/cli/operator-sdk_run_bundle-upgrade.md +++ b/website/content/en/docs/cli/operator-sdk_run_bundle-upgrade.md @@ -20,6 +20,7 @@ operator-sdk run bundle-upgrade [flags] ``` --ca-secret-name string Name of a generic secret containing a PEM root certificate file required to pull bundle images. This secret *must* be in the namespace that this command is configured to run in, and the file *must* be encoded under the key "cert.pem" + --gzip-configmap If 'true', the configmap data will be compressed. Use if in case of getting the like "... ConfigMap ... is invalid: []: Too long: must have at most 1048576 bytes" -h, --help help for bundle-upgrade --kubeconfig string Path to the kubeconfig file to use for CLI requests. -n, --namespace string If present, namespace scope for this CLI request diff --git a/website/content/en/docs/cli/operator-sdk_run_bundle.md b/website/content/en/docs/cli/operator-sdk_run_bundle.md index 7619e183090..5a03c3bdc1c 100644 --- a/website/content/en/docs/cli/operator-sdk_run_bundle.md +++ b/website/content/en/docs/cli/operator-sdk_run_bundle.md @@ -29,6 +29,7 @@ operator-sdk run bundle [flags] ``` --ca-secret-name string Name of a generic secret containing a PEM root certificate file required to pull bundle images. This secret *must* be in the namespace that this command is configured to run in, and the file *must* be encoded under the key "cert.pem" + --gzip-configmap If 'true', the configmap data will be compressed. Use if in case of getting the like "... ConfigMap ... is invalid: []: Too long: must have at most 1048576 bytes" -h, --help help for bundle --index-image string index image in which to inject bundle (default "quay.io/operator-framework/opm:latest") --install-mode InstallModeValue install mode