Skip to content

Commit df08ec1

Browse files
committed
Add command to create a new olmv1 catalog
Signed-off-by: Artur Zych <[email protected]>
1 parent c788f7c commit df08ec1

9 files changed

+363
-11
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package olmv1
2+
3+
import (
4+
"time"
5+
6+
"github.com/spf13/cobra"
7+
"github.com/spf13/pflag"
8+
9+
"github.com/operator-framework/kubectl-operator/internal/cmd/internal/log"
10+
v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action"
11+
"github.com/operator-framework/kubectl-operator/pkg/action"
12+
)
13+
14+
// NewCatalogCreateCmd allows creating a new catalog
15+
func NewCatalogCreateCmd(cfg *action.Configuration) *cobra.Command {
16+
i := v1action.NewCatalogCreate(cfg.Client)
17+
i.Logf = log.Printf
18+
19+
cmd := &cobra.Command{
20+
Use: "catalog <catalog_name> <image_source_ref>",
21+
Aliases: []string{"catalogs <catalog_name> <image_source_ref>"},
22+
Args: cobra.ExactArgs(2),
23+
Short: "Create a new catalog",
24+
Run: func(cmd *cobra.Command, args []string) {
25+
i.CatalogName = args[0]
26+
i.ImageSourceRef = args[1]
27+
28+
if err := i.Run(cmd.Context()); err != nil {
29+
log.Fatalf("failed to create catalog %q: %v", i.CatalogName, err)
30+
}
31+
log.Printf("catalog %q created", i.CatalogName)
32+
},
33+
}
34+
bindCatalogCreateFlags(cmd.Flags(), i)
35+
36+
return cmd
37+
}
38+
39+
func bindCatalogCreateFlags(fs *pflag.FlagSet, i *v1action.CatalogCreate) {
40+
fs.Int32Var(&i.Priority, "priority", 0, "priority determines the likelihood of a catalog being picked up in conflict scenarios")
41+
fs.BoolVar(&i.Available, "available", true, "true means that the catalog should active and serving data")
42+
fs.IntVar(&i.PollIntervalMinutes, "source-poll-interval-minutes", 10, "catalog source polling interval [in minutes]")
43+
fs.StringToStringVar(&i.Labels, "labels", map[string]string{}, "labels that will be added to the catalog")
44+
fs.DurationVar(&i.CleanupTimeout, "cleanup-timeout", time.Minute, "the amount of time to wait before cancelling cleanup after a failed creation attempt")
45+
}

internal/cmd/olmv1.go

+10-2
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,26 @@ func newOlmV1Cmd(cfg *action.Configuration) *cobra.Command {
1616

1717
getCmd := &cobra.Command{
1818
Use: "get",
19-
Short: "Display one or many OLMv1-specific resource(s)",
20-
Long: "Display one or many OLMv1-specific resource(s)",
19+
Short: "Display one or many resource(s)",
20+
Long: "Display one or many resource(s)",
2121
}
2222
getCmd.AddCommand(
2323
olmv1.NewOperatorInstalledGetCmd(cfg),
2424
olmv1.NewCatalogInstalledGetCmd(cfg),
2525
)
2626

27+
createCmd := &cobra.Command{
28+
Use: "create",
29+
Short: "Create a resource",
30+
Long: "Create a resource",
31+
}
32+
createCmd.AddCommand(olmv1.NewCatalogCreateCmd(cfg))
33+
2734
cmd.AddCommand(
2835
olmv1.NewOperatorInstallCmd(cfg),
2936
olmv1.NewOperatorUninstallCmd(cfg),
3037
getCmd,
38+
createCmd,
3139
)
3240

3341
return cmd
+34-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,46 @@
11
package action_test
22

33
import (
4+
"context"
45
"testing"
56

67
. "github.com/onsi/ginkgo"
78
. "github.com/onsi/gomega"
9+
10+
"sigs.k8s.io/controller-runtime/pkg/client"
811
)
912

1013
func TestCommand(t *testing.T) {
1114
RegisterFailHandler(Fail)
12-
RunSpecs(t, "Internal action Suite")
15+
RunSpecs(t, "Internal v1 action Suite")
16+
}
17+
18+
type mockCreator struct {
19+
createErr error
20+
createCalled int
21+
}
22+
23+
func (mc *mockCreator) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
24+
mc.createCalled++
25+
return mc.createErr
26+
}
27+
28+
type mockDeleter struct {
29+
deleteErr error
30+
deleteCalled int
31+
}
32+
33+
func (md *mockDeleter) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
34+
md.deleteCalled++
35+
return md.deleteErr
36+
}
37+
38+
type mockGetter struct {
39+
getErr error
40+
getCalled int
41+
}
42+
43+
func (mg *mockGetter) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
44+
mg.getCalled++
45+
return mg.getErr
1346
}
+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package action
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
9+
olmv1catalogd "github.com/operator-framework/catalogd/api/v1"
10+
)
11+
12+
type createClient interface {
13+
Creator
14+
Deleter
15+
Getter
16+
}
17+
18+
type CatalogCreate struct {
19+
client createClient
20+
CatalogName string
21+
ImageSourceRef string
22+
23+
Priority int32
24+
PollIntervalMinutes int
25+
Labels map[string]string
26+
Available bool
27+
CleanupTimeout time.Duration
28+
29+
Logf func(string, ...interface{})
30+
}
31+
32+
func NewCatalogCreate(client createClient) *CatalogCreate {
33+
return &CatalogCreate{
34+
client: client,
35+
Logf: func(string, ...interface{}) {},
36+
}
37+
}
38+
39+
func (i *CatalogCreate) Run(ctx context.Context) error {
40+
catalog := i.buildCatalog()
41+
if err := i.client.Create(ctx, &catalog); err != nil {
42+
return err
43+
}
44+
45+
var err error
46+
if i.Available {
47+
err = waitUntilCatalogStatusCondition(ctx, i.client, &catalog, olmv1catalogd.TypeServing, metav1.ConditionTrue)
48+
} else {
49+
err = waitUntilCatalogStatusCondition(ctx, i.client, &catalog, olmv1catalogd.TypeServing, metav1.ConditionFalse)
50+
}
51+
52+
if err != nil {
53+
if cleanupErr := deleteWithTimeout(i.client, &catalog, i.CleanupTimeout); cleanupErr != nil {
54+
i.Logf("cleaning up failed catalog: %v", cleanupErr)
55+
}
56+
return err
57+
}
58+
59+
return nil
60+
}
61+
62+
func (i *CatalogCreate) buildCatalog() olmv1catalogd.ClusterCatalog {
63+
catalog := olmv1catalogd.ClusterCatalog{
64+
ObjectMeta: metav1.ObjectMeta{
65+
Name: i.CatalogName,
66+
Labels: i.Labels,
67+
},
68+
Spec: olmv1catalogd.ClusterCatalogSpec{
69+
Source: olmv1catalogd.CatalogSource{
70+
Type: olmv1catalogd.SourceTypeImage,
71+
Image: &olmv1catalogd.ImageSource{
72+
Ref: i.ImageSourceRef,
73+
PollIntervalMinutes: &i.PollIntervalMinutes,
74+
},
75+
},
76+
Priority: i.Priority,
77+
AvailabilityMode: olmv1catalogd.AvailabilityModeAvailable,
78+
},
79+
}
80+
if !i.Available {
81+
catalog.Spec.AvailabilityMode = olmv1catalogd.AvailabilityModeUnavailable
82+
}
83+
84+
return catalog
85+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package action_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
7+
. "github.com/onsi/ginkgo"
8+
. "github.com/onsi/gomega"
9+
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"sigs.k8s.io/controller-runtime/pkg/client"
12+
13+
olmv1catalogd "github.com/operator-framework/catalogd/api/v1"
14+
15+
internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action"
16+
)
17+
18+
type mockCreateClient struct {
19+
*mockCreator
20+
*mockGetter
21+
*mockDeleter
22+
createCatalog *olmv1catalogd.ClusterCatalog
23+
}
24+
25+
func (mcc *mockCreateClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
26+
mcc.createCatalog = obj.(*olmv1catalogd.ClusterCatalog)
27+
return mcc.mockCreator.Create(ctx, obj, opts...)
28+
}
29+
30+
var _ = Describe("CatalogCreate", func() {
31+
pollInterval := 20
32+
expectedCatalog := olmv1catalogd.ClusterCatalog{
33+
ObjectMeta: metav1.ObjectMeta{
34+
Name: "testcatalog",
35+
Labels: map[string]string{"a": "b"},
36+
},
37+
Spec: olmv1catalogd.ClusterCatalogSpec{
38+
Source: olmv1catalogd.CatalogSource{
39+
Type: olmv1catalogd.SourceTypeImage,
40+
Image: &olmv1catalogd.ImageSource{
41+
Ref: "testcatalog:latest",
42+
PollIntervalMinutes: &pollInterval,
43+
},
44+
},
45+
Priority: 77,
46+
AvailabilityMode: olmv1catalogd.AvailabilityModeAvailable,
47+
},
48+
}
49+
50+
It("fails creating catalog", func() {
51+
expectedErr := errors.New("create failed")
52+
mockClient := &mockCreateClient{&mockCreator{createErr: expectedErr}, nil, nil, &expectedCatalog}
53+
54+
creator := internalaction.NewCatalogCreate(mockClient)
55+
creator.Available = true
56+
creator.CatalogName = expectedCatalog.Name
57+
creator.ImageSourceRef = expectedCatalog.Spec.Source.Image.Ref
58+
creator.Priority = expectedCatalog.Spec.Priority
59+
creator.Labels = expectedCatalog.Labels
60+
creator.PollIntervalMinutes = *expectedCatalog.Spec.Source.Image.PollIntervalMinutes
61+
err := creator.Run(context.TODO())
62+
63+
Expect(err).NotTo(BeNil())
64+
Expect(err).To(MatchError(expectedErr))
65+
Expect(mockClient.createCalled).To(Equal(1))
66+
67+
// there is no way of testing a happy path in unit tests because we have no way to
68+
// set/mock the catalog status condition we're waiting for in waitUntilCatalogStatusCondition
69+
// but we can still at least verify that CR would have been created with expected attribute values
70+
validateCreateCatalog(mockClient.createCatalog, &expectedCatalog)
71+
})
72+
73+
It("fails waiting for created catalog status, successfully cleans up", func() {
74+
expectedErr := errors.New("get failed")
75+
mockClient := &mockCreateClient{&mockCreator{}, &mockGetter{getErr: expectedErr}, &mockDeleter{}, nil}
76+
77+
creator := internalaction.NewCatalogCreate(mockClient)
78+
err := creator.Run(context.TODO())
79+
80+
Expect(err).NotTo(BeNil())
81+
Expect(err).To(MatchError(expectedErr))
82+
Expect(mockClient.createCalled).To(Equal(1))
83+
Expect(mockClient.getCalled).To(Equal(1))
84+
Expect(mockClient.deleteCalled).To(Equal(1))
85+
})
86+
87+
It("fails waiting for created catalog status, fails clean up", func() {
88+
getErr := errors.New("get failed")
89+
deleteErr := errors.New("delete failed")
90+
mockClient := &mockCreateClient{&mockCreator{}, &mockGetter{getErr: getErr}, &mockDeleter{deleteErr: deleteErr}, nil}
91+
92+
creator := internalaction.NewCatalogCreate(mockClient)
93+
err := creator.Run(context.TODO())
94+
95+
Expect(err).NotTo(BeNil())
96+
Expect(err).To(MatchError(getErr))
97+
Expect(mockClient.createCalled).To(Equal(1))
98+
Expect(mockClient.getCalled).To(Equal(1))
99+
Expect(mockClient.deleteCalled).To(Equal(1))
100+
})
101+
})
102+
103+
func validateCreateCatalog(actual, expected *olmv1catalogd.ClusterCatalog) {
104+
Expect(actual.Spec.Source.Image.Ref).To(Equal(expected.Spec.Source.Image.Ref))
105+
Expect(actual.Spec.Source.Image.PollIntervalMinutes).To(Equal(expected.Spec.Source.Image.PollIntervalMinutes))
106+
Expect(actual.Spec.AvailabilityMode).To(Equal(expected.Spec.AvailabilityMode))
107+
Expect(actual.Labels).To(HaveLen(len(expected.Labels)))
108+
for k, v := range expected.Labels {
109+
Expect(actual.Labels).To(HaveKeyWithValue(k, v))
110+
}
111+
Expect(actual.Spec.Priority).To(Equal(expected.Spec.Priority))
112+
}

internal/pkg/v1/action/helpers.go

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package action
2+
3+
import (
4+
"context"
5+
"slices"
6+
"time"
7+
8+
apierrors "k8s.io/apimachinery/pkg/api/errors"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/apimachinery/pkg/types"
11+
"k8s.io/apimachinery/pkg/util/wait"
12+
"sigs.k8s.io/controller-runtime/pkg/client"
13+
14+
olmv1catalogd "github.com/operator-framework/catalogd/api/v1"
15+
)
16+
17+
const pollInterval = 250 * time.Millisecond
18+
19+
func objectKeyForObject(obj client.Object) types.NamespacedName {
20+
return types.NamespacedName{
21+
Namespace: obj.GetNamespace(),
22+
Name: obj.GetName(),
23+
}
24+
}
25+
26+
func waitUntilCatalogStatusCondition(
27+
ctx context.Context,
28+
cl Getter,
29+
catalog *olmv1catalogd.ClusterCatalog,
30+
conditionType string,
31+
conditionStatus metav1.ConditionStatus,
32+
) error {
33+
opKey := objectKeyForObject(catalog)
34+
return wait.PollUntilContextCancel(ctx, pollInterval, true, func(conditionCtx context.Context) (bool, error) {
35+
if err := cl.Get(conditionCtx, opKey, catalog); err != nil {
36+
return false, err
37+
}
38+
39+
if slices.ContainsFunc(catalog.Status.Conditions, func(cond metav1.Condition) bool {
40+
return cond.Type == conditionType && cond.Status == conditionStatus
41+
}) {
42+
return true, nil
43+
}
44+
return false, nil
45+
})
46+
}
47+
48+
func deleteWithTimeout(cl Deleter, obj client.Object, timeout time.Duration) error {
49+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
50+
defer cancel()
51+
52+
if err := cl.Delete(ctx, obj); err != nil && !apierrors.IsNotFound(err) {
53+
return err
54+
}
55+
56+
return nil
57+
}

internal/pkg/v1/action/interfaces.go

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package action
2+
3+
import (
4+
"context"
5+
6+
"sigs.k8s.io/controller-runtime/pkg/client"
7+
)
8+
9+
type Creator interface {
10+
Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error
11+
}
12+
13+
type Deleter interface {
14+
Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error
15+
}
16+
17+
type Getter interface {
18+
Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error
19+
}

0 commit comments

Comments
 (0)