diff --git a/changelog/fragments/6419.yaml b/changelog/fragments/6419.yaml new file mode 100644 index 00000000000..3ec2e3e769e --- /dev/null +++ b/changelog/fragments/6419.yaml @@ -0,0 +1,38 @@ +# entries is a list of entries to include in +# release notes and/or the migration guide +entries: + - description: > + Add ability to ignore bundle updates on `generate bundle` command if createdAt timestamp is the only change. + The flag to use is `--ignore-if-only-createdAt`. + + # kind is one of: + # - addition + # - change + # - deprecation + # - removal + # - bugfix + kind: "addition" + + # Is this a breaking change? + breaking: false + + # NOTE: ONLY USE `pull_request_override` WHEN ADDING THIS + # FILE FOR A PREVIOUSLY MERGED PULL_REQUEST! + # + # The generator auto-detects the PR number from the commit + # message in which this file was originally added. + # + # What is the pull request number (without the "#")? + # pull_request_override: 0 + + + # Migration can be defined to automatically add a section to + # the migration guide. This is required for breaking changes. + # migration: + # header: Header text for the migration section + # body: | + # Body of the migration section. This should be formatted as markdown and can + # span multiple lines. + + # Using the YAML string '|' operator means that newlines in this string will + # be honored and interpretted as newlines in the rendered markdown. diff --git a/internal/cmd/operator-sdk/generate/bundle/bundle.go b/internal/cmd/operator-sdk/generate/bundle/bundle.go index cd772f25bae..be987f86e93 100644 --- a/internal/cmd/operator-sdk/generate/bundle/bundle.go +++ b/internal/cmd/operator-sdk/generate/bundle/bundle.go @@ -201,6 +201,10 @@ func (c bundleCmd) runManifests() (err error) { opts = append(opts, gencsv.WithWriter(stdout)) } else { opts = append(opts, gencsv.WithBundleWriter(c.outputDir)) + if c.ignoreIfOnlyCreatedAt && genutil.IsExist(c.outputDir) { + opts = append(opts, gencsv.WithBundleReader(c.outputDir)) + opts = append(opts, gencsv.WithIgnoreIfOnlyCreatedAt()) + } } csvGen := gencsv.Generator{ diff --git a/internal/cmd/operator-sdk/generate/bundle/cmd.go b/internal/cmd/operator-sdk/generate/bundle/cmd.go index e4803a962b5..e1006cdf073 100644 --- a/internal/cmd/operator-sdk/generate/bundle/cmd.go +++ b/internal/cmd/operator-sdk/generate/bundle/cmd.go @@ -42,9 +42,10 @@ type bundleCmd struct { extraServiceAccounts []string // Metadata options. - channels string - defaultChannel string - overwrite bool + channels string + defaultChannel string + overwrite bool + ignoreIfOnlyCreatedAt bool // These are set if a PROJECT config is not present. layout string @@ -138,6 +139,7 @@ func (c *bundleCmd) addFlagsTo(fs *pflag.FlagSet) { "Names of service accounts, outside of the operator's Deployment account, "+ "that have bindings to {Cluster}Roles that should be added to the CSV") fs.BoolVar(&c.overwrite, "overwrite", true, "Overwrite the bundle's metadata and Dockerfile if they exist") + fs.BoolVar(&c.ignoreIfOnlyCreatedAt, "ignore-if-only-createdAt", false, "Ignore if only createdAt is changed") fs.BoolVarP(&c.quiet, "quiet", "q", false, "Run in quiet mode") fs.BoolVar(&c.stdout, "stdout", false, "Write bundle manifest to stdout") diff --git a/internal/generate/clusterserviceversion/clusterserviceversion.go b/internal/generate/clusterserviceversion/clusterserviceversion.go index 6e66aaa0f8c..fc635a081ee 100644 --- a/internal/generate/clusterserviceversion/clusterserviceversion.go +++ b/internal/generate/clusterserviceversion/clusterserviceversion.go @@ -18,6 +18,7 @@ import ( "fmt" "io" "path/filepath" + "reflect" "strings" "github.com/blang/semver/v4" @@ -61,6 +62,10 @@ type Generator struct { // Func that returns the writer the generated CSV's bytes are written to. getWriter func() (io.Writer, error) + // Func that returns the reader the previous CSV's bytes are read from. + getReader func() (io.Reader, error) + + ignoreIfOnlyCreatedAt bool } // Option is a function that modifies a Generator. @@ -88,6 +93,22 @@ func WithBundleWriter(dir string) Option { } } +// WithBundleGetter sets a Generator's getter to a bundle CSV file under +// /manifests. +func WithBundleReader(dir string) Option { + return func(g *Generator) error { + fileName := makeCSVFileName(g.OperatorName) + g.getReader = func() (io.Reader, error) { + return bundleReader(dir, fileName) + } + return nil + } +} + +func bundleReader(dir, fileName string) (io.Reader, error) { + return genutil.Open(filepath.Join(dir, bundle.ManifestsDir), fileName) +} + // WithPackageWriter sets a Generator's writer to a package CSV file under // /. func WithPackageWriter(dir string) Option { @@ -100,6 +121,13 @@ func WithPackageWriter(dir string) Option { } } +func WithIgnoreIfOnlyCreatedAt() Option { + return func(g *Generator) error { + g.ignoreIfOnlyCreatedAt = true + return nil + } +} + // Generate configures the generator with col and opts then runs it. func (g *Generator) Generate(opts ...Option) (err error) { for _, opt := range opts { @@ -119,7 +147,33 @@ func (g *Generator) Generate(opts ...Option) (err error) { // Add extra annotations to csv g.setAnnotations(csv) - + // If a reader is set, and there is a flag to not update createdAt, then + // set the CSV's createdAt to the previous CSV's createdAt if its the only change. + if g.ignoreIfOnlyCreatedAt && g.getReader != nil { + r, err := g.getReader() + if err != nil { + return err + } + var prevCSV operatorsv1alpha1.ClusterServiceVersion + err = genutil.ReadObject(r, &prevCSV) + if err != nil { + return err + } + if prevCSV.ObjectMeta.Annotations != nil && prevCSV.ObjectMeta.Annotations["createdAt"] != "" { + csvWithoutCreatedAtChange := csv.DeepCopy() + // Set WebhookDefinitions if nil to avoid diffing on it + if prevCSV.Spec.WebhookDefinitions == nil { + prevCSV.Spec.WebhookDefinitions = []operatorsv1alpha1.WebhookDescription{} + } + if csvWithoutCreatedAtChange.ObjectMeta.Annotations == nil { + csvWithoutCreatedAtChange.ObjectMeta.Annotations = map[string]string{} + } + csvWithoutCreatedAtChange.ObjectMeta.Annotations["createdAt"] = prevCSV.ObjectMeta.Annotations["createdAt"] + if reflect.DeepEqual(csvWithoutCreatedAtChange, &prevCSV) { + csv = csvWithoutCreatedAtChange + } + } + } w, err := g.getWriter() if err != nil { return err diff --git a/internal/generate/clusterserviceversion/clusterserviceversion_test.go b/internal/generate/clusterserviceversion/clusterserviceversion_test.go index 91b435f5edf..beddef9ea6b 100644 --- a/internal/generate/clusterserviceversion/clusterserviceversion_test.go +++ b/internal/generate/clusterserviceversion/clusterserviceversion_test.go @@ -27,6 +27,7 @@ import ( "github.com/onsi/gomega/format" operatorversion "github.com/operator-framework/api/pkg/lib/version" "github.com/operator-framework/api/pkg/operators/v1alpha1" + operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" "github.com/operator-framework/operator-registry/pkg/lib/bundle" appsv1 "k8s.io/api/apps/v1" "sigs.k8s.io/yaml" @@ -140,6 +141,56 @@ var _ = Describe("Testing CRDs with single version", func() { Expect(outputFile).To(BeAnExistingFile()) Expect(readFileHelper(outputFile)).To(MatchYAML(newCSVUIMetaStr)) }) + It("should not update createdAt to ClusterServiceVersion manifest to a bundle file if it's the only change", func() { + g = Generator{ + OperatorName: operatorName, + Version: zeroZeroOne, + Collector: col, + } + opts := []Option{ + WithBundleWriter(tmp), + } + Expect(g.Generate(opts...)).ToNot(HaveOccurred()) + outputFile := filepath.Join(tmp, bundle.ManifestsDir, makeCSVFileName(operatorName)) + Expect(outputFile).To(BeAnExistingFile()) + Expect(readFileHelper(outputFile)).To(MatchYAML(newCSVUIMetaStr)) + var initiallyWrittenCSV operatorsv1alpha1.ClusterServiceVersion + r, err := bundleReader(tmp, makeCSVFileName(operatorName)) + Expect(err).ToNot(HaveOccurred()) + err = genutil.ReadObject(r, &initiallyWrittenCSV) + Expect(err).ToNot(HaveOccurred()) + Expect(initiallyWrittenCSV.ObjectMeta.Annotations).ToNot(BeNil()) + Expect(initiallyWrittenCSV.ObjectMeta.Annotations["createdAt"]).ToNot(Equal("")) + g = Generator{ + OperatorName: operatorName, + Version: zeroZeroOne, + Collector: col, + } + opts = []Option{ + WithBundleWriter(tmp), + WithBundleReader(tmp), + WithIgnoreIfOnlyCreatedAt(), + } + time.Sleep(1*time.Second + 1*time.Millisecond) // sleep to ensure createdAt is different if not for ignore option + Expect(g.Generate(opts...)).ToNot(HaveOccurred()) + Expect(outputFile).To(BeAnExistingFile()) + // This should fail if createdAt changed. + Expect(readFileHelper(outputFile)).To(MatchYAML(newCSVUIMetaStr)) + // now try without ignore option + g = Generator{ + OperatorName: operatorName, + Version: zeroZeroOne, + Collector: col, + } + opts = []Option{ + WithBundleWriter(tmp), + WithBundleReader(tmp), + } + Expect(g.Generate(opts...)).ToNot(HaveOccurred()) + Expect(outputFile).To(BeAnExistingFile()) + // This should fail if createdAt changed. + Expect(readFileHelper(outputFile)).ToNot(MatchYAML(newCSVUIMetaStr)) + }) It("should write a ClusterServiceVersion manifest to a package file", func() { g = Generator{ OperatorName: operatorName, diff --git a/internal/generate/internal/genutil.go b/internal/generate/internal/genutil.go index 630fece9b9b..f23b7765218 100644 --- a/internal/generate/internal/genutil.go +++ b/internal/generate/internal/genutil.go @@ -22,6 +22,7 @@ import ( "os" "path/filepath" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" "github.com/operator-framework/operator-sdk/internal/util/k8sutil" @@ -111,3 +112,11 @@ func IsNotExist(path string) bool { _, err := os.Stat(path) return err != nil && errors.Is(err, os.ErrNotExist) } + +func ReadObject(r io.Reader, obj client.Object) error { + var buf bytes.Buffer + if _, err := buf.ReadFrom(r); err != nil { + return err + } + return k8sutil.GetObjectFromBytes(buf.Bytes(), obj) +} diff --git a/internal/util/k8sutil/object.go b/internal/util/k8sutil/object.go index 9f7d23c2aca..23b38b1a439 100644 --- a/internal/util/k8sutil/object.go +++ b/internal/util/k8sutil/object.go @@ -16,6 +16,7 @@ package k8sutil import ( "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" ) type MarshalFunc func(interface{}) ([]byte, error) @@ -53,3 +54,11 @@ func deleteKeyFromUnstructured(u map[string]interface{}, key string) { } } } + +func GetObjectFromBytes(b []byte, obj interface{}) error { + var u map[string]interface{} + if err := yaml.Unmarshal(b, &u); err != nil { + return err + } + return runtime.DefaultUnstructuredConverter.FromUnstructured(u, obj) +}