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)
+}