Skip to content

✨ Add command to create a new olmv1 catalog #220

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions internal/cmd/internal/olmv1/catalog_create.go
Original file line number Diff line number Diff line change
@@ -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 <catalog_name> <image_source_ref>",
Aliases: []string{"catalogs <catalog_name> <image_source_ref>"},
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")
}
12 changes: 10 additions & 2 deletions internal/cmd/olmv1.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion internal/pkg/v1/action/action_suite_test.go
Original file line number Diff line number Diff line change
@@ -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
}
85 changes: 85 additions & 0 deletions internal/pkg/v1/action/catalog_create.go
Original file line number Diff line number Diff line change
@@ -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
}
112 changes: 112 additions & 0 deletions internal/pkg/v1/action/catalog_create_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
57 changes: 57 additions & 0 deletions internal/pkg/v1/action/helpers.go
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions internal/pkg/v1/action/interfaces.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading