From a780b3ddf4f385fc46f706f3568021df38f983f0 Mon Sep 17 00:00:00 2001 From: Artur Zych <5843875+azych@users.noreply.github.com> Date: Thu, 27 Feb 2025 11:48:23 +0100 Subject: [PATCH] Add command to create a new olmv1 catalog Signed-off-by: Artur Zych <5843875+azych@users.noreply.github.com> --- internal/cmd/internal/olmv1/catalog_create.go | 45 +++++++ internal/cmd/olmv1.go | 12 +- internal/pkg/v1/action/action_suite_test.go | 35 +++++- internal/pkg/v1/action/catalog_create.go | 85 +++++++++++++ internal/pkg/v1/action/catalog_create_test.go | 112 ++++++++++++++++++ internal/pkg/v1/action/helpers.go | 57 +++++++++ internal/pkg/v1/action/interfaces.go | 19 +++ internal/pkg/v1/action/operator_install.go | 2 +- internal/pkg/v1/action/operator_uninstall.go | 7 -- 9 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 internal/cmd/internal/olmv1/catalog_create.go create mode 100644 internal/pkg/v1/action/catalog_create.go create mode 100644 internal/pkg/v1/action/catalog_create_test.go create mode 100644 internal/pkg/v1/action/helpers.go create mode 100644 internal/pkg/v1/action/interfaces.go diff --git a/internal/cmd/internal/olmv1/catalog_create.go b/internal/cmd/internal/olmv1/catalog_create.go new file mode 100644 index 00000000..d27c01ea --- /dev/null +++ b/internal/cmd/internal/olmv1/catalog_create.go @@ -0,0 +1,45 @@ +package olmv1 + +import ( + "time" + + "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" +) + +// NewCatalogCreateCmd allows creating a new catalog +func NewCatalogCreateCmd(cfg *action.Configuration) *cobra.Command { + i := v1action.NewCatalogCreate(cfg.Client) + i.Logf = log.Printf + + cmd := &cobra.Command{ + Use: "catalog ", + Aliases: []string{"catalogs "}, + Args: cobra.ExactArgs(2), + Short: "Create a new catalog", + Run: func(cmd *cobra.Command, args []string) { + i.CatalogName = args[0] + i.ImageSourceRef = args[1] + + if err := i.Run(cmd.Context()); err != nil { + log.Fatalf("failed to create catalog %q: %v", i.CatalogName, err) + } + log.Printf("catalog %q created", i.CatalogName) + }, + } + bindCatalogCreateFlags(cmd.Flags(), i) + + return cmd +} + +func bindCatalogCreateFlags(fs *pflag.FlagSet, i *v1action.CatalogCreate) { + fs.Int32Var(&i.Priority, "priority", 0, "priority determines the likelihood of a catalog being selected in conflict scenarios") + fs.BoolVar(&i.Available, "available", true, "true means that the catalog should be active and serving data") + fs.IntVar(&i.PollIntervalMinutes, "source-poll-interval-minutes", 10, "catalog source polling interval [in minutes]") + fs.StringToStringVar(&i.Labels, "labels", map[string]string{}, "labels that will be added to the catalog") + fs.DurationVar(&i.CleanupTimeout, "cleanup-timeout", time.Minute, "the amount of time to wait before cancelling cleanup after a failed creation attempt") +} diff --git a/internal/cmd/olmv1.go b/internal/cmd/olmv1.go index e986d73a..738be075 100644 --- a/internal/cmd/olmv1.go +++ b/internal/cmd/olmv1.go @@ -16,18 +16,26 @@ func newOlmV1Cmd(cfg *action.Configuration) *cobra.Command { getCmd := &cobra.Command{ Use: "get", - Short: "Display one or many OLMv1-specific resource(s)", - Long: "Display one or many OLMv1-specific resource(s)", + Short: "Display one or many resource(s)", + Long: "Display one or many resource(s)", } getCmd.AddCommand( olmv1.NewOperatorInstalledGetCmd(cfg), olmv1.NewCatalogInstalledGetCmd(cfg), ) + createCmd := &cobra.Command{ + Use: "create", + Short: "Create a resource", + Long: "Create a resource", + } + createCmd.AddCommand(olmv1.NewCatalogCreateCmd(cfg)) + cmd.AddCommand( olmv1.NewOperatorInstallCmd(cfg), olmv1.NewOperatorUninstallCmd(cfg), getCmd, + createCmd, ) return cmd diff --git a/internal/pkg/v1/action/action_suite_test.go b/internal/pkg/v1/action/action_suite_test.go index 40957f3d..96abf84d 100644 --- a/internal/pkg/v1/action/action_suite_test.go +++ b/internal/pkg/v1/action/action_suite_test.go @@ -1,13 +1,46 @@ package action_test import ( + "context" "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + + "sigs.k8s.io/controller-runtime/pkg/client" ) func TestCommand(t *testing.T) { RegisterFailHandler(Fail) - RunSpecs(t, "Internal action Suite") + RunSpecs(t, "Internal v1 action Suite") +} + +type mockCreator struct { + createErr error + createCalled int +} + +func (mc *mockCreator) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + mc.createCalled++ + return mc.createErr +} + +type mockDeleter struct { + deleteErr error + deleteCalled int +} + +func (md *mockDeleter) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + md.deleteCalled++ + return md.deleteErr +} + +type mockGetter struct { + getErr error + getCalled int +} + +func (mg *mockGetter) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + mg.getCalled++ + return mg.getErr } diff --git a/internal/pkg/v1/action/catalog_create.go b/internal/pkg/v1/action/catalog_create.go new file mode 100644 index 00000000..580627ec --- /dev/null +++ b/internal/pkg/v1/action/catalog_create.go @@ -0,0 +1,85 @@ +package action + +import ( + "context" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + olmv1catalogd "github.com/operator-framework/catalogd/api/v1" +) + +type createClient interface { + creator + deleter + getter +} + +type CatalogCreate struct { + client createClient + CatalogName string + ImageSourceRef string + + Priority int32 + PollIntervalMinutes int + Labels map[string]string + Available bool + CleanupTimeout time.Duration + + Logf func(string, ...interface{}) +} + +func NewCatalogCreate(client createClient) *CatalogCreate { + return &CatalogCreate{ + client: client, + Logf: func(string, ...interface{}) {}, + } +} + +func (i *CatalogCreate) Run(ctx context.Context) error { + catalog := i.buildCatalog() + if err := i.client.Create(ctx, &catalog); err != nil { + return err + } + + var err error + if i.Available { + err = waitUntilCatalogStatusCondition(ctx, i.client, &catalog, olmv1catalogd.TypeServing, metav1.ConditionTrue) + } else { + err = waitUntilCatalogStatusCondition(ctx, i.client, &catalog, olmv1catalogd.TypeServing, metav1.ConditionFalse) + } + + if err != nil { + if cleanupErr := deleteWithTimeout(i.client, &catalog, i.CleanupTimeout); cleanupErr != nil { + i.Logf("cleaning up failed catalog: %v", cleanupErr) + } + return err + } + + return nil +} + +func (i *CatalogCreate) buildCatalog() olmv1catalogd.ClusterCatalog { + catalog := olmv1catalogd.ClusterCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: i.CatalogName, + Labels: i.Labels, + }, + Spec: olmv1catalogd.ClusterCatalogSpec{ + Source: olmv1catalogd.CatalogSource{ + Type: olmv1catalogd.SourceTypeImage, + Image: &olmv1catalogd.ImageSource{ + Ref: i.ImageSourceRef, + PollIntervalMinutes: &i.PollIntervalMinutes, + }, + }, + Priority: i.Priority, + AvailabilityMode: olmv1catalogd.AvailabilityModeAvailable, + }, + } + if !i.Available { + catalog.Spec.AvailabilityMode = olmv1catalogd.AvailabilityModeUnavailable + } + + return catalog +} diff --git a/internal/pkg/v1/action/catalog_create_test.go b/internal/pkg/v1/action/catalog_create_test.go new file mode 100644 index 00000000..cee912dd --- /dev/null +++ b/internal/pkg/v1/action/catalog_create_test.go @@ -0,0 +1,112 @@ +package action_test + +import ( + "context" + "errors" + + . "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" + + internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" +) + +type mockCreateClient struct { + *mockCreator + *mockGetter + *mockDeleter + createCatalog *olmv1catalogd.ClusterCatalog +} + +func (mcc *mockCreateClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + mcc.createCatalog = obj.(*olmv1catalogd.ClusterCatalog) + return mcc.mockCreator.Create(ctx, obj, opts...) +} + +var _ = Describe("CatalogCreate", func() { + pollInterval := 20 + expectedCatalog := olmv1catalogd.ClusterCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testcatalog", + Labels: map[string]string{"a": "b"}, + }, + Spec: olmv1catalogd.ClusterCatalogSpec{ + Source: olmv1catalogd.CatalogSource{ + Type: olmv1catalogd.SourceTypeImage, + Image: &olmv1catalogd.ImageSource{ + Ref: "testcatalog:latest", + PollIntervalMinutes: &pollInterval, + }, + }, + Priority: 77, + AvailabilityMode: olmv1catalogd.AvailabilityModeAvailable, + }, + } + + It("fails creating catalog", func() { + expectedErr := errors.New("create failed") + mockClient := &mockCreateClient{&mockCreator{createErr: expectedErr}, nil, nil, &expectedCatalog} + + creator := internalaction.NewCatalogCreate(mockClient) + creator.Available = true + creator.CatalogName = expectedCatalog.Name + creator.ImageSourceRef = expectedCatalog.Spec.Source.Image.Ref + creator.Priority = expectedCatalog.Spec.Priority + creator.Labels = expectedCatalog.Labels + creator.PollIntervalMinutes = *expectedCatalog.Spec.Source.Image.PollIntervalMinutes + err := creator.Run(context.TODO()) + + Expect(err).NotTo(BeNil()) + Expect(err).To(MatchError(expectedErr)) + Expect(mockClient.createCalled).To(Equal(1)) + + // there is no way of testing a happy path in unit tests because we have no way to + // set/mock the catalog status condition we're waiting for in waitUntilCatalogStatusCondition + // but we can still at least verify that CR would have been created with expected attribute values + validateCreateCatalog(mockClient.createCatalog, &expectedCatalog) + }) + + It("fails waiting for created catalog status, successfully cleans up", func() { + expectedErr := errors.New("get failed") + mockClient := &mockCreateClient{&mockCreator{}, &mockGetter{getErr: expectedErr}, &mockDeleter{}, nil} + + creator := internalaction.NewCatalogCreate(mockClient) + err := creator.Run(context.TODO()) + + Expect(err).NotTo(BeNil()) + Expect(err).To(MatchError(expectedErr)) + Expect(mockClient.createCalled).To(Equal(1)) + Expect(mockClient.getCalled).To(Equal(1)) + Expect(mockClient.deleteCalled).To(Equal(1)) + }) + + It("fails waiting for created catalog status, fails clean up", func() { + getErr := errors.New("get failed") + deleteErr := errors.New("delete failed") + mockClient := &mockCreateClient{&mockCreator{}, &mockGetter{getErr: getErr}, &mockDeleter{deleteErr: deleteErr}, nil} + + creator := internalaction.NewCatalogCreate(mockClient) + err := creator.Run(context.TODO()) + + Expect(err).NotTo(BeNil()) + Expect(err).To(MatchError(getErr)) + Expect(mockClient.createCalled).To(Equal(1)) + Expect(mockClient.getCalled).To(Equal(1)) + Expect(mockClient.deleteCalled).To(Equal(1)) + }) +}) + +func validateCreateCatalog(actual, expected *olmv1catalogd.ClusterCatalog) { + Expect(actual.Spec.Source.Image.Ref).To(Equal(expected.Spec.Source.Image.Ref)) + Expect(actual.Spec.Source.Image.PollIntervalMinutes).To(Equal(expected.Spec.Source.Image.PollIntervalMinutes)) + Expect(actual.Spec.AvailabilityMode).To(Equal(expected.Spec.AvailabilityMode)) + Expect(actual.Labels).To(HaveLen(len(expected.Labels))) + for k, v := range expected.Labels { + Expect(actual.Labels).To(HaveKeyWithValue(k, v)) + } + Expect(actual.Spec.Priority).To(Equal(expected.Spec.Priority)) +} diff --git a/internal/pkg/v1/action/helpers.go b/internal/pkg/v1/action/helpers.go new file mode 100644 index 00000000..a27ad405 --- /dev/null +++ b/internal/pkg/v1/action/helpers.go @@ -0,0 +1,57 @@ +package action + +import ( + "context" + "slices" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + + olmv1catalogd "github.com/operator-framework/catalogd/api/v1" +) + +const pollInterval = 250 * time.Millisecond + +func objectKeyForObject(obj client.Object) types.NamespacedName { + return types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + } +} + +func waitUntilCatalogStatusCondition( + ctx context.Context, + cl getter, + catalog *olmv1catalogd.ClusterCatalog, + conditionType string, + conditionStatus metav1.ConditionStatus, +) error { + opKey := objectKeyForObject(catalog) + return wait.PollUntilContextCancel(ctx, pollInterval, true, func(conditionCtx context.Context) (bool, error) { + if err := cl.Get(conditionCtx, opKey, catalog); err != nil { + return false, err + } + + if slices.ContainsFunc(catalog.Status.Conditions, func(cond metav1.Condition) bool { + return cond.Type == conditionType && cond.Status == conditionStatus + }) { + return true, nil + } + return false, nil + }) +} + +func deleteWithTimeout(cl deleter, obj client.Object, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if err := cl.Delete(ctx, obj); err != nil && !apierrors.IsNotFound(err) { + return err + } + + return nil +} diff --git a/internal/pkg/v1/action/interfaces.go b/internal/pkg/v1/action/interfaces.go new file mode 100644 index 00000000..cdc17ae0 --- /dev/null +++ b/internal/pkg/v1/action/interfaces.go @@ -0,0 +1,19 @@ +package action + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type creator interface { + Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error +} + +type deleter interface { + Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error +} + +type getter interface { + Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error +} diff --git a/internal/pkg/v1/action/operator_install.go b/internal/pkg/v1/action/operator_install.go index ac582f1e..9e6f381e 100644 --- a/internal/pkg/v1/action/operator_install.go +++ b/internal/pkg/v1/action/operator_install.go @@ -58,7 +58,7 @@ func (i *OperatorInstall) Run(ctx context.Context) (*olmv1.ClusterExtension, err // All Types will exist, so Ready may have a false Status. So, wait until // Type=Ready,Status=True happens - if err := wait.PollUntilContextCancel(ctx, pollTimeout, true, func(conditionCtx context.Context) (bool, error) { + if err := wait.PollUntilContextCancel(ctx, pollInterval, true, func(conditionCtx context.Context) (bool, error) { if err := i.config.Client.Get(conditionCtx, opKey, op); err != nil { return false, err } diff --git a/internal/pkg/v1/action/operator_uninstall.go b/internal/pkg/v1/action/operator_uninstall.go index bebf3a0f..16c24949 100644 --- a/internal/pkg/v1/action/operator_uninstall.go +++ b/internal/pkg/v1/action/operator_uninstall.go @@ -43,13 +43,6 @@ func (u *OperatorUninstall) Run(ctx context.Context) error { return waitForDeletion(ctx, u.config.Client, op) } -func objectKeyForObject(obj client.Object) types.NamespacedName { - return types.NamespacedName{ - Namespace: obj.GetNamespace(), - Name: obj.GetName(), - } -} - func waitForDeletion(ctx context.Context, cl client.Client, objs ...client.Object) error { for _, obj := range objs { obj := obj