From 38d2a8afae143dc32490ea918769836008c9ea50 Mon Sep 17 00:00:00 2001 From: Rashmi Gottipati Date: Wed, 30 Apr 2025 13:40:36 -0400 Subject: [PATCH] Add command for updating olmv1 catalog Signed-off-by: Rashmi Gottipati --- internal/cmd/internal/olmv1/catalog_update.go | 50 ++++ internal/cmd/olmv1.go | 1 + internal/pkg/v1/action/action_suite_test.go | 63 ++++ internal/pkg/v1/action/catalog_update.go | 139 +++++++++ internal/pkg/v1/action/catalog_update_test.go | 269 ++++++++++++++++++ 5 files changed, 522 insertions(+) create mode 100644 internal/cmd/internal/olmv1/catalog_update.go create mode 100644 internal/pkg/v1/action/catalog_update.go create mode 100644 internal/pkg/v1/action/catalog_update_test.go diff --git a/internal/cmd/internal/olmv1/catalog_update.go b/internal/cmd/internal/olmv1/catalog_update.go new file mode 100644 index 00000000..93b65344 --- /dev/null +++ b/internal/cmd/internal/olmv1/catalog_update.go @@ -0,0 +1,50 @@ +package olmv1 + +import ( + "github.com/spf13/cobra" + + "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" + v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" + "github.com/operator-framework/kubectl-operator/pkg/action" +) + +// NewCatalogUpdateCmd allows updating a selected clustercatalog +func NewCatalogUpdateCmd(cfg *action.Configuration) *cobra.Command { + i := v1action.NewCatalogUpdate(cfg) + i.Logf = log.Printf + + var priority int32 + var pollInterval int + var labels map[string]string + + cmd := &cobra.Command{ + Use: "catalog ", + Short: "Update a catalog", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + i.CatalogName = args[0] + if cmd.Flags().Changed("priority") { + i.Priority = &priority + } + if cmd.Flags().Changed("source-poll-interval-minutes") { + i.PollIntervalMinutes = &pollInterval + } + if cmd.Flags().Changed("labels") { + i.Labels = labels + } + _, err := i.Run(cmd.Context()) + if err != nil { + log.Fatalf("failed to update catalog: %v", err) + } + log.Printf("catalog %q updated", i.CatalogName) + }, + } + cmd.Flags().Int32Var(&priority, "priority", 0, "priority determines the likelihood of a catalog being selected in conflict scenarios") + cmd.Flags().IntVar(&pollInterval, "source-poll-interval-minutes", 5, "catalog source polling interval [in minutes]. Set to 0 or -1 to remove the polling interval.") + cmd.Flags().StringToStringVar(&labels, "labels", map[string]string{}, "labels that will be added to the catalog") + cmd.Flags().StringVar(&i.AvailabilityMode, "availability-mode", "", "available means that the catalog should be active and serving data") + cmd.Flags().StringVar(&i.ImageRef, "image", "", "Image reference for the catalog source. Leave unset to retain the current image.") + cmd.Flags().BoolVar(&i.IgnoreUnset, "ignore-unset", true, "when enabled, any unset flag value will not be changed. Disabling means that for each unset value a default will be used instead") + + return cmd +} diff --git a/internal/cmd/olmv1.go b/internal/cmd/olmv1.go index fad8b763..0d53e7d3 100644 --- a/internal/cmd/olmv1.go +++ b/internal/cmd/olmv1.go @@ -48,6 +48,7 @@ func newOlmV1Cmd(cfg *action.Configuration) *cobra.Command { } updateCmd.AddCommand( olmv1.NewExtensionUpdateCmd(cfg), + olmv1.NewCatalogUpdateCmd(cfg), ) installCmd := &cobra.Command{ diff --git a/internal/pkg/v1/action/action_suite_test.go b/internal/pkg/v1/action/action_suite_test.go index d7e5e570..8fd62041 100644 --- a/internal/pkg/v1/action/action_suite_test.go +++ b/internal/pkg/v1/action/action_suite_test.go @@ -110,6 +110,8 @@ func newClusterCatalog(name string) *olmv1.ClusterCatalog { type extensionOpt func(*olmv1.ClusterExtension) +type catalogOpt func(*olmv1.ClusterCatalog) + func withVersion(version string) extensionOpt { return func(ext *olmv1.ClusterExtension) { ext.Spec.Source.Catalog.Version = version @@ -141,6 +143,48 @@ func withLabels(labels map[string]string) extensionOpt { } } +func withCatalogSourceType(sourceType olmv1.SourceType) catalogOpt { + return func(catalog *olmv1.ClusterCatalog) { + catalog.Spec.Source.Type = sourceType + } +} + +func withCatalogSourcePriority(priority *int32) catalogOpt { + return func(catalog *olmv1.ClusterCatalog) { + catalog.Spec.Priority = *priority + } +} + +func withCatalogPollInterval(pollInterval *int) catalogOpt { + return func(catalog *olmv1.ClusterCatalog) { + if catalog.Spec.Source.Image == nil { + catalog.Spec.Source.Image = &olmv1.ImageSource{} + } + catalog.Spec.Source.Image.PollIntervalMinutes = pollInterval + } +} + +func withCatalogImageRef(ref string) catalogOpt { + return func(catalog *olmv1.ClusterCatalog) { + if catalog.Spec.Source.Image == nil { + catalog.Spec.Source.Image = &olmv1.ImageSource{} + } + catalog.Spec.Source.Image.Ref = ref + } +} + +func withCatalogAvailabilityMode(mode olmv1.AvailabilityMode) catalogOpt { + return func(catalog *olmv1.ClusterCatalog) { + catalog.Spec.AvailabilityMode = mode + } +} + +func withCatalogLabels(labels map[string]string) catalogOpt { + return func(catalog *olmv1.ClusterCatalog) { + catalog.Labels = labels + } +} + func buildExtension(packageName string, opts ...extensionOpt) *olmv1.ClusterExtension { ext := &olmv1.ClusterExtension{ Spec: olmv1.ClusterExtensionSpec{ @@ -173,3 +217,22 @@ func updateExtensionConditionStatus(name string, cl client.Client, typ string, s return cl.Update(context.TODO(), &ext) } + +func buildCatalog(catalogName string, opts ...catalogOpt) *olmv1.ClusterCatalog { + catalog := &olmv1.ClusterCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: catalogName, + }, + Spec: olmv1.ClusterCatalogSpec{ + Source: olmv1.CatalogSource{ + Type: olmv1.SourceTypeImage, + }, + }, + } + catalog.SetName(catalogName) + for _, opt := range opts { + opt(catalog) + } + + return catalog +} diff --git a/internal/pkg/v1/action/catalog_update.go b/internal/pkg/v1/action/catalog_update.go new file mode 100644 index 00000000..7fa7c80e --- /dev/null +++ b/internal/pkg/v1/action/catalog_update.go @@ -0,0 +1,139 @@ +package action + +import ( + "context" + "fmt" + "regexp" + + "k8s.io/apimachinery/pkg/types" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" + + "github.com/operator-framework/kubectl-operator/pkg/action" +) + +type CatalogUpdate struct { + config *action.Configuration + CatalogName string + + Priority *int32 + PollIntervalMinutes *int + Labels map[string]string + AvailabilityMode string + ImageRef string + IgnoreUnset bool + + Logf func(string, ...interface{}) +} + +func NewCatalogUpdate(config *action.Configuration) *CatalogUpdate { + return &CatalogUpdate{ + config: config, + Logf: func(string, ...interface{}) {}, + } +} + +func (cu *CatalogUpdate) Run(ctx context.Context) (*olmv1.ClusterCatalog, error) { + var catalog olmv1.ClusterCatalog + var err error + + cuKey := types.NamespacedName{ + Name: cu.CatalogName, + Namespace: cu.config.Namespace, + } + if err = cu.config.Client.Get(ctx, cuKey, &catalog); err != nil { + return nil, err + } + + if catalog.Spec.Source.Type != olmv1.SourceTypeImage { + return nil, fmt.Errorf("unrecognized source type: %q", catalog.Spec.Source.Type) + } + + if cu.ImageRef != "" && !isValidImageRef(cu.ImageRef) { + return nil, fmt.Errorf("invalid image reference: %q, it must be a valid image reference format", cu.ImageRef) + } + + cu.setDefaults(&catalog) + + cu.setUpdatedCatalog(&catalog) + if err := cu.config.Client.Update(ctx, &catalog); err != nil { + return nil, err + } + + cu.Logf("Updating catalog %q in namespace %q", cu.CatalogName, cu.config.Namespace) + + return &catalog, nil +} + +func (cu *CatalogUpdate) setUpdatedCatalog(catalog *olmv1.ClusterCatalog) { + existingLabels := catalog.GetLabels() + if existingLabels == nil { + existingLabels = make(map[string]string) + } + if cu.Labels != nil { + for k, v := range cu.Labels { + if v == "" { + delete(existingLabels, k) + } else { + existingLabels[k] = v + } + } + catalog.SetLabels(existingLabels) + } + + if cu.Priority != nil { + catalog.Spec.Priority = *cu.Priority + } + + if catalog.Spec.Source.Image == nil { + catalog.Spec.Source.Image = &olmv1.ImageSource{} + } + + if cu.PollIntervalMinutes != nil { + if *cu.PollIntervalMinutes == 0 || *cu.PollIntervalMinutes == -1 { + catalog.Spec.Source.Image.PollIntervalMinutes = nil + } else { + catalog.Spec.Source.Image.PollIntervalMinutes = cu.PollIntervalMinutes + } + } + + if cu.ImageRef != "" { + catalog.Spec.Source.Image.Ref = cu.ImageRef + } + + if cu.AvailabilityMode != "" { + catalog.Spec.AvailabilityMode = olmv1.AvailabilityMode(cu.AvailabilityMode) + } +} + +func (cu *CatalogUpdate) setDefaults(catalog *olmv1.ClusterCatalog) { + if !cu.IgnoreUnset { + return + } + + catalogSrc := catalog.Spec.Source + + if cu.Priority == nil { + cu.Priority = &catalog.Spec.Priority + } + + if cu.PollIntervalMinutes == nil && catalogSrc.Image != nil && catalogSrc.Image.PollIntervalMinutes != nil { + cu.PollIntervalMinutes = catalogSrc.Image.PollIntervalMinutes + } + + if cu.ImageRef == "" && catalogSrc.Image != nil { + cu.ImageRef = catalogSrc.Image.Ref + } + if cu.AvailabilityMode == "" { + cu.AvailabilityMode = string(catalog.Spec.AvailabilityMode) + } + if len(cu.Labels) == 0 { + cu.Labels = catalog.Labels + } +} + +func isValidImageRef(imageRef string) bool { + var imageRefRegex = regexp.MustCompile(`^([a-z0-9]+(\.[a-z0-9]+)*(:[0-9]+)?/)?[a-z0-9-_]+(/[a-z0-9-_]+)*(:[a-zA-Z0-9_\.-]+)?(@sha256:[a-fA-F0-9]{64})?$`) + + return imageRefRegex.MatchString(imageRef) +} diff --git a/internal/pkg/v1/action/catalog_update_test.go b/internal/pkg/v1/action/catalog_update_test.go new file mode 100644 index 00000000..6f96d0a0 --- /dev/null +++ b/internal/pkg/v1/action/catalog_update_test.go @@ -0,0 +1,269 @@ +package action_test + +import ( + "context" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" + + internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" + "github.com/operator-framework/kubectl-operator/pkg/action" +) + +var _ = Describe("CatalogUpdate", func() { + setupEnv := func(catalogs ...client.Object) action.Configuration { + var cfg action.Configuration + + sch, err := action.NewScheme() + Expect(err).To(BeNil()) + + cl := fake.NewClientBuilder(). + WithObjects(catalogs...). + WithScheme(sch). + Build() + cfg.Scheme = sch + cfg.Client = cl + + return cfg + } + + It("fails finding existing catalog", func() { + cfg := setupEnv() + + updater := internalaction.NewCatalogUpdate(&cfg) + updater.CatalogName = "does-not-exist" + cat, err := updater.Run(context.TODO()) + + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("not found")) + Expect(cat).To(BeNil()) + }) + + It("fails to handle catalog with unknown source type", func() { + cfg := setupEnv(buildCatalog("test", withCatalogSourceType("invalid-type"))) + + updater := internalaction.NewCatalogUpdate(&cfg) + updater.CatalogName = "test" + _, err := updater.Run(context.TODO()) + + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("unrecognized source type")) + }) + + It("successfully updates catalog", func() { + testCatalog := buildCatalog( + "testCatalog", + withCatalogSourceType(olmv1.SourceTypeImage), + withCatalogPollInterval(pointerToInt(5)), + withCatalogSourcePriority(pointerToInt32(1)), + withCatalogImageRef("quay.io/myrepo/myimage"), + withCatalogAvailabilityMode(olmv1.AvailabilityModeAvailable), + withCatalogLabels(map[string]string{"foo": "bar"}), + ) + cfg := setupEnv(testCatalog) + + updater := internalaction.NewCatalogUpdate(&cfg) + updater.CatalogName = "testCatalog" + updater.Priority = pointerToInt32(1) + updater.Labels = map[string]string{"abc": "xyz"} + updater.AvailabilityMode = string(olmv1.AvailabilityModeAvailable) + updater.PollIntervalMinutes = pointerToInt(5) + catalog, err := updater.Run(context.TODO()) + + Expect(err).To(BeNil()) + Expect(testCatalog).NotTo(BeNil()) + Expect(catalog.Labels).To(HaveKeyWithValue("foo", "bar")) //existing + Expect(catalog.Labels).To(HaveKeyWithValue("abc", "xyz")) //newly added + Expect(catalog.Spec.Priority).To(Equal(*updater.Priority)) + Expect(catalog.Spec.Source.Image.PollIntervalMinutes).ToNot(BeNil()) + Expect(*catalog.Spec.Source.Image.PollIntervalMinutes).To(Equal(*updater.PollIntervalMinutes)) + Expect(catalog.Spec.AvailabilityMode).To(Equal(olmv1.AvailabilityMode(updater.AvailabilityMode))) + }) + + It("unsets the poll interval field when set to 0", func() { + testCatalog := buildCatalog( + "test", + withCatalogSourceType(olmv1.SourceTypeImage), + withCatalogPollInterval(pointerToInt(7)), + withCatalogImageRef("quay.io/myrepo/myimage"), + ) + cfg := setupEnv(testCatalog) + + updater := internalaction.NewCatalogUpdate(&cfg) + updater.CatalogName = "test" + updater.PollIntervalMinutes = pointerToInt(-1) + catalog, err := updater.Run(context.TODO()) + + Expect(err).NotTo(HaveOccurred()) + Expect(catalog.Spec.Source.Image.PollIntervalMinutes).To(BeNil()) + }) + + It("unsets the poll interval field when set to 0", func() { + testCatalog := buildCatalog( + "test", + withCatalogSourceType(olmv1.SourceTypeImage), + withCatalogPollInterval(pointerToInt(10)), + withCatalogImageRef("quay.io/myrepo/myimage"), + ) + cfg := setupEnv(testCatalog) + + updater := internalaction.NewCatalogUpdate(&cfg) + updater.CatalogName = "test" + updater.PollIntervalMinutes = pointerToInt(0) + + catalog, err := updater.Run(context.TODO()) + + Expect(err).NotTo(HaveOccurred()) + Expect(catalog.Spec.Source.Image.PollIntervalMinutes).To(BeNil()) + }) + + It("succeessfully updates catalog with a valid image reference", func() { + testCatalog := buildCatalog( + "test", + withCatalogSourceType(olmv1.SourceTypeImage), + withCatalogImageRef("quay.io/myrepo/myimage"), + withCatalogPollInterval(pointerToInt(10)), + withCatalogSourcePriority(pointerToInt32(5)), + withCatalogAvailabilityMode(olmv1.AvailabilityModeAvailable), + withCatalogLabels(map[string]string{"foo": "bar"}), + ) + cfg := setupEnv(testCatalog) + + updater := internalaction.NewCatalogUpdate(&cfg) + updater.CatalogName = "test" + updater.ImageRef = "quay.io/myrepo/mynewimage" + catalog, err := updater.Run(context.TODO()) + + Expect(err).NotTo(HaveOccurred()) + Expect(catalog.Spec.Source.Image.Ref).To(Equal(updater.ImageRef)) + }) + + It("fails catalog update with an invalid image reference", func() { + testCatalog := buildCatalog( + "test", + withCatalogSourceType(olmv1.SourceTypeImage), + withCatalogImageRef("quay.io/valid/image"), + ) + cfg := setupEnv(testCatalog) + + updater := internalaction.NewCatalogUpdate(&cfg) + updater.CatalogName = "test" + updater.ImageRef = "invalid//image!!" + + _, err := updater.Run(context.TODO()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid image reference")) + }) + + It("removes labels with empty values and merges the rest", func() { + initial := map[string]string{"foo": "bar", "remove": "yes"} + testCatalog := buildCatalog( + "test", + withCatalogSourceType(olmv1.SourceTypeImage), + withCatalogLabels(initial), + ) + cfg := setupEnv(testCatalog) + + updater := internalaction.NewCatalogUpdate(&cfg) + updater.CatalogName = "test" + updater.Labels = map[string]string{ + "remove": "", + "new": "label", + } + catalog, err := updater.Run(context.TODO()) + Expect(err).NotTo(HaveOccurred()) + + Expect(catalog.Labels).To(Equal(map[string]string{ + "foo": "bar", + "new": "label", + })) + }) + + It("preserves labels when Labels field is nil", func() { + testCatalog := buildCatalog( + "test", + withCatalogSourceType(olmv1.SourceTypeImage), + withCatalogLabels(map[string]string{"retain": "this"}), + ) + cfg := setupEnv(testCatalog) + + updater := internalaction.NewCatalogUpdate(&cfg) + updater.CatalogName = "test" + updater.Labels = nil + + catalog, err := updater.Run(context.TODO()) + Expect(err).NotTo(HaveOccurred()) + Expect(catalog.Labels).To(Equal(map[string]string{"retain": "this"})) + }) + + It("preserves priority and poll interval when ignoreUnset flag is true and flags not explicitly set", func() { + testCatalog := buildCatalog( + "test", + withCatalogSourceType(olmv1.SourceTypeImage), + withCatalogPollInterval(pointerToInt(10)), + withCatalogSourcePriority(pointerToInt32(3)), + withCatalogImageRef("quay.io/myrepo/image"), + withCatalogLabels(map[string]string{"foo": "bar"}), + ) + + cfg := setupEnv(testCatalog) + + updater := internalaction.NewCatalogUpdate(&cfg) + updater.CatalogName = "test" + updater.IgnoreUnset = true + + catalog, err := updater.Run(context.TODO()) + Expect(err).NotTo(HaveOccurred()) + Expect(catalog).NotTo(BeNil()) + + Expect(catalog.Spec.Priority).To(Equal(int32(3))) + Expect(catalog.Spec.Source.Image).NotTo(BeNil()) + Expect(catalog.Labels).To(Equal(map[string]string{"foo": "bar"})) + Expect(catalog.Spec.Source.Image.PollIntervalMinutes).NotTo(BeNil()) + Expect(*catalog.Spec.Source.Image.PollIntervalMinutes).To(Equal(10)) + + }) + + It("resets priority and poll interval when ignoreUnset is false and flags are nil", func() { + testCatalog := buildCatalog( + "test", + withCatalogSourceType(olmv1.SourceTypeImage), + withCatalogPollInterval(pointerToInt(10)), + withCatalogSourcePriority(pointerToInt32(3)), + withCatalogImageRef("quay.io/myrepo/image"), + ) + + cfg := setupEnv(testCatalog) + + updater := internalaction.NewCatalogUpdate(&cfg) + updater.CatalogName = "test" + updater.IgnoreUnset = false + updater.Priority = nil + updater.PollIntervalMinutes = nil + updater.ImageRef = "" + updater.AvailabilityMode = "" + + catalog, err := updater.Run(context.TODO()) + Expect(err).NotTo(HaveOccurred()) + + Expect(catalog.Spec.Priority).To(Equal(int32(3))) + Expect(catalog.Spec.Source.Image.Ref).To(Equal("quay.io/myrepo/image")) + Expect(string(catalog.Spec.AvailabilityMode)).To(BeEmpty()) + Expect(catalog.Spec.Source.Image.PollIntervalMinutes).ToNot(BeNil()) + Expect(*catalog.Spec.Source.Image.PollIntervalMinutes).To(Equal(10)) + + }) +}) + +func pointerToInt32(i int32) *int32 { + return &i +} + +func pointerToInt(i int) *int { + return &i +}