From 27efe6e9dc6575bec060e84ee5fa14cefe1ed961 Mon Sep 17 00:00:00 2001
From: Artur Zych <5843875+azych@users.noreply.github.com>
Date: Wed, 26 Feb 2025 11:33:58 +0100
Subject: [PATCH] Add command to delete single/all existing olmv1 catalog(s)

Signed-off-by: Artur Zych <5843875+azych@users.noreply.github.com>
---
 internal/cmd/internal/olmv1/catalog_delete.go |  50 ++++++++
 internal/cmd/olmv1.go                         |   8 ++
 internal/pkg/v1/action/action_suite_test.go   |  19 +++
 internal/pkg/v1/action/catalog_delete.go      |  69 +++++++++++
 internal/pkg/v1/action/catalog_delete_test.go | 110 ++++++++++++++++++
 .../v1/action/catalog_installed_get_test.go   |  17 ---
 internal/pkg/v1/action/errors.go              |   8 ++
 internal/pkg/v1/action/helpers.go             |  17 +++
 internal/pkg/v1/action/operator.go            |   9 --
 internal/pkg/v1/action/operator_uninstall.go  |  21 ----
 10 files changed, 281 insertions(+), 47 deletions(-)
 create mode 100644 internal/cmd/internal/olmv1/catalog_delete.go
 create mode 100644 internal/pkg/v1/action/catalog_delete.go
 create mode 100644 internal/pkg/v1/action/catalog_delete_test.go
 create mode 100644 internal/pkg/v1/action/errors.go
 delete mode 100644 internal/pkg/v1/action/operator.go

diff --git a/internal/cmd/internal/olmv1/catalog_delete.go b/internal/cmd/internal/olmv1/catalog_delete.go
new file mode 100644
index 00000000..c7206228
--- /dev/null
+++ b/internal/cmd/internal/olmv1/catalog_delete.go
@@ -0,0 +1,50 @@
+package olmv1
+
+import (
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+
+	"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"
+)
+
+// NewCatalogDeleteCmd allows deleting either a single or all
+// existing catalogs
+func NewCatalogDeleteCmd(cfg *action.Configuration) *cobra.Command {
+	d := v1action.NewCatalogDelete(cfg)
+	d.Logf = log.Printf
+
+	cmd := &cobra.Command{
+		Use:     "catalog [catalog_name]",
+		Aliases: []string{"catalogs [catalog_name]"},
+		Args:    cobra.RangeArgs(0, 1),
+		Short:   "Delete either a single or all of the existing catalogs",
+		Run: func(cmd *cobra.Command, args []string) {
+			if len(args) == 0 {
+				catalogs, err := d.Run(cmd.Context())
+				if err != nil {
+					log.Fatalf("failed deleting catalogs: %v", err)
+				}
+				for _, catalog := range catalogs {
+					log.Printf("catalog %q deleted", catalog)
+				}
+
+				return
+			}
+
+			d.CatalogName = args[0]
+			if _, err := d.Run(cmd.Context()); err != nil {
+				log.Fatalf("failed to delete catalog %q: %v", d.CatalogName, err)
+			}
+			log.Printf("catalog %q deleted", d.CatalogName)
+		},
+	}
+	bindCatalogDeleteFlags(cmd.Flags(), d)
+
+	return cmd
+}
+
+func bindCatalogDeleteFlags(fs *pflag.FlagSet, d *v1action.CatalogDelete) {
+	fs.BoolVar(&d.DeleteAll, "all", false, "delete all catalogs")
+}
diff --git a/internal/cmd/olmv1.go b/internal/cmd/olmv1.go
index 738be075..8e7c39d3 100644
--- a/internal/cmd/olmv1.go
+++ b/internal/cmd/olmv1.go
@@ -31,11 +31,19 @@ func newOlmV1Cmd(cfg *action.Configuration) *cobra.Command {
 	}
 	createCmd.AddCommand(olmv1.NewCatalogCreateCmd(cfg))
 
+	deleteCmd := &cobra.Command{
+		Use:   "delete",
+		Short: "Delete a resource",
+		Long:  "Delete a resource",
+	}
+	deleteCmd.AddCommand(olmv1.NewCatalogDeleteCmd(cfg))
+
 	cmd.AddCommand(
 		olmv1.NewOperatorInstallCmd(cfg),
 		olmv1.NewOperatorUninstallCmd(cfg),
 		getCmd,
 		createCmd,
+		deleteCmd,
 	)
 
 	return cmd
diff --git a/internal/pkg/v1/action/action_suite_test.go b/internal/pkg/v1/action/action_suite_test.go
index 96abf84d..b33fdd4b 100644
--- a/internal/pkg/v1/action/action_suite_test.go
+++ b/internal/pkg/v1/action/action_suite_test.go
@@ -2,12 +2,16 @@ package action_test
 
 import (
 	"context"
+	"fmt"
 	"testing"
 
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
 
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	olmv1catalogd "github.com/operator-framework/catalogd/api/v1"
 )
 
 func TestCommand(t *testing.T) {
@@ -44,3 +48,18 @@ func (mg *mockGetter) Get(ctx context.Context, key client.ObjectKey, obj client.
 	mg.getCalled++
 	return mg.getErr
 }
+
+func setupTestCatalogs(n int) []client.Object {
+	var result []client.Object
+	for i := 1; i <= n; i++ {
+		result = append(result, newClusterCatalog(fmt.Sprintf("cat%d", i)))
+	}
+
+	return result
+}
+
+func newClusterCatalog(name string) *olmv1catalogd.ClusterCatalog {
+	return &olmv1catalogd.ClusterCatalog{
+		ObjectMeta: metav1.ObjectMeta{Name: name},
+	}
+}
diff --git a/internal/pkg/v1/action/catalog_delete.go b/internal/pkg/v1/action/catalog_delete.go
new file mode 100644
index 00000000..160016ff
--- /dev/null
+++ b/internal/pkg/v1/action/catalog_delete.go
@@ -0,0 +1,69 @@
+package action
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	olmv1catalogd "github.com/operator-framework/catalogd/api/v1"
+
+	"github.com/operator-framework/kubectl-operator/pkg/action"
+)
+
+type CatalogDelete struct {
+	config      *action.Configuration
+	CatalogName string
+	DeleteAll   bool
+
+	Logf func(string, ...interface{})
+}
+
+func NewCatalogDelete(cfg *action.Configuration) *CatalogDelete {
+	return &CatalogDelete{
+		config: cfg,
+		Logf:   func(string, ...interface{}) {},
+	}
+}
+
+func (cd *CatalogDelete) Run(ctx context.Context) ([]string, error) {
+	// validate
+	if cd.DeleteAll && cd.CatalogName != "" {
+		return nil, errNameAndSelector
+	}
+
+	// delete single, specified catalog
+	if !cd.DeleteAll {
+		return nil, cd.deleteCatalog(ctx, cd.CatalogName)
+	}
+
+	// delete all existing catalogs
+	var catatalogList olmv1catalogd.ClusterCatalogList
+	if err := cd.config.Client.List(ctx, &catatalogList); err != nil {
+		return nil, err
+	}
+	if len(catatalogList.Items) == 0 {
+		return nil, errNoResourcesFound
+	}
+
+	errs := make([]error, 0, len(catatalogList.Items))
+	names := make([]string, 0, len(catatalogList.Items))
+	for _, catalog := range catatalogList.Items {
+		names = append(names, catalog.Name)
+		if err := cd.deleteCatalog(ctx, catalog.Name); err != nil {
+			errs = append(errs, fmt.Errorf("failed deleting catalog %q: %w", catalog.Name, err))
+		}
+	}
+
+	return names, errors.Join(errs...)
+}
+
+func (cd *CatalogDelete) deleteCatalog(ctx context.Context, name string) error {
+	op := &olmv1catalogd.ClusterCatalog{}
+	op.SetName(name)
+
+	if err := cd.config.Client.Delete(ctx, op); err != nil {
+		return err
+	}
+
+	return waitForDeletion(ctx, cd.config.Client, op)
+}
diff --git a/internal/pkg/v1/action/catalog_delete_test.go b/internal/pkg/v1/action/catalog_delete_test.go
new file mode 100644
index 00000000..9410c958
--- /dev/null
+++ b/internal/pkg/v1/action/catalog_delete_test.go
@@ -0,0 +1,110 @@
+package action_test
+
+import (
+	"context"
+	"slices"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	olmv1catalogd "github.com/operator-framework/catalogd/api/v1"
+
+	internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action"
+	"github.com/operator-framework/kubectl-operator/pkg/action"
+)
+
+var _ = Describe("CatalogDelete", 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 because of both resource name and --all specifier being present", func() {
+		cfg := setupEnv(setupTestCatalogs(2)...)
+
+		deleter := internalaction.NewCatalogDelete(&cfg)
+		deleter.CatalogName = "name"
+		deleter.DeleteAll = true
+		catNames, err := deleter.Run(context.TODO())
+		Expect(err).NotTo(BeNil())
+		Expect(catNames).To(BeEmpty())
+
+		validateExistingCatalogs(cfg.Client, []string{"cat1", "cat2"})
+	})
+
+	It("fails deleting a non-existing catalog", func() {
+		cfg := setupEnv(setupTestCatalogs(2)...)
+
+		deleter := internalaction.NewCatalogDelete(&cfg)
+		deleter.CatalogName = "does-not-exist"
+		catNames, err := deleter.Run(context.TODO())
+		Expect(err).NotTo(BeNil())
+		Expect(catNames).To(BeEmpty())
+
+		validateExistingCatalogs(cfg.Client, []string{"cat1", "cat2"})
+	})
+
+	It("successfully deletes an existing catalog", func() {
+		cfg := setupEnv(setupTestCatalogs(3)...)
+
+		deleter := internalaction.NewCatalogDelete(&cfg)
+		deleter.CatalogName = "cat2"
+		catNames, err := deleter.Run(context.TODO())
+		Expect(err).To(BeNil())
+		Expect(catNames).To(BeEmpty())
+
+		validateExistingCatalogs(cfg.Client, []string{"cat1", "cat3"})
+	})
+
+	It("fails deleting catalogs because there are none", func() {
+		cfg := setupEnv()
+
+		deleter := internalaction.NewCatalogDelete(&cfg)
+		deleter.DeleteAll = true
+		catNames, err := deleter.Run(context.TODO())
+		Expect(err).NotTo(BeNil())
+		Expect(catNames).To(BeEmpty())
+
+		validateExistingCatalogs(cfg.Client, []string{})
+	})
+
+	It("successfully deletes all catalogs", func() {
+		cfg := setupEnv(setupTestCatalogs(3)...)
+
+		deleter := internalaction.NewCatalogDelete(&cfg)
+		deleter.DeleteAll = true
+		catNames, err := deleter.Run(context.TODO())
+		Expect(err).To(BeNil())
+		Expect(catNames).To(ContainElements([]string{"cat1", "cat2", "cat3"}))
+
+		validateExistingCatalogs(cfg.Client, []string{})
+	})
+})
+
+func validateExistingCatalogs(c client.Client, wantedNames []string) {
+	var catalogsList olmv1catalogd.ClusterCatalogList
+	err := c.List(context.TODO(), &catalogsList)
+	Expect(err).To(BeNil())
+
+	catalogs := catalogsList.Items
+	Expect(catalogs).To(HaveLen(len(wantedNames)))
+	for _, wantedName := range wantedNames {
+		Expect(slices.ContainsFunc(catalogs, func(cat olmv1catalogd.ClusterCatalog) bool {
+			return cat.Name == wantedName
+		})).To(BeTrue())
+	}
+}
diff --git a/internal/pkg/v1/action/catalog_installed_get_test.go b/internal/pkg/v1/action/catalog_installed_get_test.go
index b9a77f1d..77a83448 100644
--- a/internal/pkg/v1/action/catalog_installed_get_test.go
+++ b/internal/pkg/v1/action/catalog_installed_get_test.go
@@ -2,13 +2,11 @@ package action_test
 
 import (
 	"context"
-	"fmt"
 	"slices"
 
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
 
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
 
@@ -82,18 +80,3 @@ var _ = Describe("CatalogInstalledGet", func() {
 		Expect(operators).To(BeEmpty())
 	})
 })
-
-func setupTestCatalogs(n int) []client.Object {
-	var result []client.Object
-	for i := 1; i <= n; i++ {
-		result = append(result, newClusterCatalog(fmt.Sprintf("cat%d", i)))
-	}
-
-	return result
-}
-
-func newClusterCatalog(name string) *olmv1catalogd.ClusterCatalog {
-	return &olmv1catalogd.ClusterCatalog{
-		ObjectMeta: metav1.ObjectMeta{Name: name},
-	}
-}
diff --git a/internal/pkg/v1/action/errors.go b/internal/pkg/v1/action/errors.go
new file mode 100644
index 00000000..d8cea85c
--- /dev/null
+++ b/internal/pkg/v1/action/errors.go
@@ -0,0 +1,8 @@
+package action
+
+import "errors"
+
+var (
+	errNoResourcesFound = errors.New("no resources found")
+	errNameAndSelector  = errors.New("name cannot be provided when a selector is specified")
+)
diff --git a/internal/pkg/v1/action/helpers.go b/internal/pkg/v1/action/helpers.go
index a27ad405..66069b50 100644
--- a/internal/pkg/v1/action/helpers.go
+++ b/internal/pkg/v1/action/helpers.go
@@ -2,6 +2,7 @@ package action
 
 import (
 	"context"
+	"fmt"
 	"slices"
 	"time"
 
@@ -45,6 +46,22 @@ func waitUntilCatalogStatusCondition(
 	})
 }
 
+func waitForDeletion(ctx context.Context, cl client.Client, obj client.Object) error {
+	key := objectKeyForObject(obj)
+	if err := wait.PollUntilContextCancel(ctx, pollInterval, true, func(conditionCtx context.Context) (bool, error) {
+		if err := cl.Get(conditionCtx, key, obj); apierrors.IsNotFound(err) {
+			return true, nil
+		} else if err != nil {
+			return false, err
+		}
+		return false, nil
+	}); err != nil {
+		return fmt.Errorf("waiting for deletion: %w", err)
+	}
+
+	return nil
+}
+
 func deleteWithTimeout(cl deleter, obj client.Object, timeout time.Duration) error {
 	ctx, cancel := context.WithTimeout(context.Background(), timeout)
 	defer cancel()
diff --git a/internal/pkg/v1/action/operator.go b/internal/pkg/v1/action/operator.go
deleted file mode 100644
index 37204bf9..00000000
--- a/internal/pkg/v1/action/operator.go
+++ /dev/null
@@ -1,9 +0,0 @@
-package action
-
-import (
-	"time"
-)
-
-const (
-	pollTimeout = 250 * time.Millisecond
-)
diff --git a/internal/pkg/v1/action/operator_uninstall.go b/internal/pkg/v1/action/operator_uninstall.go
index 16c24949..a6d504c4 100644
--- a/internal/pkg/v1/action/operator_uninstall.go
+++ b/internal/pkg/v1/action/operator_uninstall.go
@@ -7,8 +7,6 @@ import (
 
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/types"
-	"k8s.io/apimachinery/pkg/util/wait"
-	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	olmv1 "github.com/operator-framework/operator-controller/api/v1"
 
@@ -42,22 +40,3 @@ func (u *OperatorUninstall) Run(ctx context.Context) error {
 	}
 	return waitForDeletion(ctx, u.config.Client, op)
 }
-
-func waitForDeletion(ctx context.Context, cl client.Client, objs ...client.Object) error {
-	for _, obj := range objs {
-		obj := obj
-		lowerKind := strings.ToLower(obj.GetObjectKind().GroupVersionKind().Kind)
-		key := objectKeyForObject(obj)
-		if err := wait.PollUntilContextCancel(ctx, pollTimeout, true, func(conditionCtx context.Context) (bool, error) {
-			if err := cl.Get(conditionCtx, key, obj); apierrors.IsNotFound(err) {
-				return true, nil
-			} else if err != nil {
-				return false, err
-			}
-			return false, nil
-		}); err != nil {
-			return fmt.Errorf("wait for %s %q deleted: %v", lowerKind, key.Name, err)
-		}
-	}
-	return nil
-}