From f7f37c58eeb05289e6c0dccd5db522372a48be31 Mon Sep 17 00:00:00 2001 From: Francisc Munteanu Date: Tue, 20 Aug 2024 14:37:05 +0200 Subject: [PATCH] feature: init new ksctl adm install-operators command (#48) * add new ksctl adm install-operators command --------- Co-authored-by: Matous Jobanek --- pkg/client/client.go | 22 +++ pkg/client/client_test.go | 31 +++- pkg/cmd/adm/adm.go | 1 + pkg/cmd/adm/install_operator.go | 266 +++++++++++++++++++++++++++ pkg/cmd/adm/install_operator_test.go | 241 ++++++++++++++++++++++++ pkg/context/command_context.go | 14 ++ 6 files changed, 573 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/adm/install_operator.go create mode 100644 pkg/cmd/adm/install_operator_test.go diff --git a/pkg/client/client.go b/pkg/client/client.go index ea37bf5..2242af4 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -13,6 +13,7 @@ import ( "github.com/kubesaw/ksctl/pkg/configuration" clicontext "github.com/kubesaw/ksctl/pkg/context" "github.com/kubesaw/ksctl/pkg/ioutils" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "github.com/ghodss/yaml" configv1 "github.com/openshift/api/config/v1" @@ -89,6 +90,27 @@ func NewClientFromRestConfig(cfg *rest.Config) (runtimeclient.Client, error) { return cl, nil } +// NewKubeClientFromKubeConfig initializes a runtime client starting from a KubeConfig file path. +func NewKubeClientFromKubeConfig(kubeConfigPath string) (cl runtimeclient.Client, err error) { + var kubeConfig *clientcmdapi.Config + var clientConfig *rest.Config + + kubeConfig, err = clientcmd.LoadFromFile(kubeConfigPath) + if err != nil { + return + } + clientConfig, err = clientcmd.NewDefaultClientConfig(*kubeConfig, nil).ClientConfig() + if err != nil { + return + } + cl, err = NewClientFromRestConfig(clientConfig) + if err != nil { + return + } + + return +} + func newTlsVerifySkippingTransport() http.RoundTripper { return &http.Transport{ TLSClientConfig: &tls.Config{ diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 017589e..d7c5ecc 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -8,11 +8,10 @@ import ( toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" "github.com/codeready-toolchain/toolchain-common/pkg/states" commontest "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/h2non/gock" "github.com/kubesaw/ksctl/pkg/client" clicontext "github.com/kubesaw/ksctl/pkg/context" . "github.com/kubesaw/ksctl/pkg/test" - - "github.com/h2non/gock" routev1 "github.com/openshift/api/route/v1" olmv1 "github.com/operator-framework/api/pkg/operators/v1" olmv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" @@ -546,6 +545,34 @@ func TestGetRoute(t *testing.T) { }) } +func TestNewKubeClientFromKubeConfig(t *testing.T) { + // given + t.Cleanup(gock.OffAll) + gock.New("https://cool-server.com"). + Get("api"). + Persist(). + Reply(200). + BodyString("{}") + + t.Run("success", func(j *testing.T) { + // when + cl, err := client.NewKubeClientFromKubeConfig(PersistKubeConfigFile(t, HostKubeConfig())) + + // then + require.NoError(t, err) + assert.NotNil(t, cl) + }) + + t.Run("error", func(j *testing.T) { + // when + cl, err := client.NewKubeClientFromKubeConfig("/invalid/kube/config") + + // then + require.Error(t, err) + assert.Nil(t, cl) + }) +} + func newSubscription(pkg, channel string) *olmv1alpha1.Subscription { return &olmv1alpha1.Subscription{ TypeMeta: metav1.TypeMeta{ diff --git a/pkg/cmd/adm/adm.go b/pkg/cmd/adm/adm.go index fe0cf95..4034d37 100644 --- a/pkg/cmd/adm/adm.go +++ b/pkg/cmd/adm/adm.go @@ -24,6 +24,7 @@ func registerCommands(admCommand *cobra.Command) { admCommand.AddCommand(NewRestartCmd()) admCommand.AddCommand(NewUnregisterMemberCmd()) admCommand.AddCommand(NewMustGatherNamespaceCmd()) + admCommand.AddCommand(NewInstallOperatorCmd()) // commands running external script admCommand.AddCommand(NewRegisterMemberCmd()) diff --git a/pkg/cmd/adm/install_operator.go b/pkg/cmd/adm/install_operator.go new file mode 100644 index 0000000..6326bd5 --- /dev/null +++ b/pkg/cmd/adm/install_operator.go @@ -0,0 +1,266 @@ +package adm + +import ( + "fmt" + "time" + + commonclient "github.com/codeready-toolchain/toolchain-common/pkg/client" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/cmd/flags" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + olmv1 "github.com/operator-framework/api/pkg/operators/v1" + olmv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/json" + "k8s.io/apimachinery/pkg/util/wait" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/spf13/cobra" +) + +type installArgs struct { + kubeConfig string + namespace string +} + +func NewInstallOperatorCmd() *cobra.Command { + commandArgs := installArgs{} + cmd := &cobra.Command{ + Use: "install-operator --kubeconfig --namespace ", + Short: "install kubesaw operator (host|member)", + Long: `This command installs the latest stable versions of the kubesaw operator using OLM`, + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + kubeClient, err := client.NewKubeClientFromKubeConfig(commandArgs.kubeConfig) + if err != nil { + return err + } + + cl := commonclient.NewApplyClient(kubeClient) + ctx := clicontext.NewTerminalContext(term) + return installOperator(ctx, commandArgs, args[0], time.Second*60, cl) + }, + } + + cmd.Flags().StringVar(&commandArgs.kubeConfig, "kubeconfig", "", "Path to the kubeconfig file to use.") + flags.MustMarkRequired(cmd, "kubeconfig") + cmd.Flags().StringVar(&commandArgs.namespace, "namespace", "", "The namespace where the operator will be installed. Host and Member should be installed in separate namespaces. If the namespace is not provided the standard namespace names are used: toolchain-host|member-operator.") + return cmd +} + +func installOperator(ctx *clicontext.TerminalContext, args installArgs, operator string, timeout time.Duration, applyClient *commonclient.ApplyClient) error { + // validate cluster type + if operator != string(configuration.Host) && operator != string(configuration.Member) { + return fmt.Errorf("invalid operator type provided: %s. Valid ones are %s|%s", operator, configuration.Host, configuration.Member) + } + + // assume "standard" namespace if not provided + namespace := args.namespace + if args.namespace == "" { + namespace = fmt.Sprintf("toolchain-%s-operator", operator) + } + + if !ctx.AskForConfirmation( + ioutils.WithMessagef("install %s in namespace '%s'", operator, namespace)) { + return nil + } + + // check if namespace exists + // otherwise create it + if err := createNamespaceIfNotFound(ctx, applyClient, namespace); err != nil { + return err + } + + // check that we don't install both host and member in the same namespace + if err := checkOneOperatorPerNamespace(ctx, applyClient, namespace, operator); err != nil { + return err + } + + // install the catalog source + namespacedName := types.NamespacedName{Name: operatorResourceName(operator), Namespace: namespace} + catalogSource := newCatalogSource(namespacedName, operator) + ctx.Println(fmt.Sprintf("Creating CatalogSource %s in namespace %s.", catalogSource.Name, catalogSource.Namespace)) + if _, err := applyClient.ApplyObject(ctx.Context, catalogSource, commonclient.SaveConfiguration(false)); err != nil { + return err + } + ctx.Println(fmt.Sprintf("CatalogSource %s created.", catalogSource.Name)) + if err := waitUntilCatalogSourceIsReady(ctx, applyClient, namespacedName, timeout); err != nil { + return err + } + ctx.Printlnf("CatalogSource %s is ready", namespacedName) + + // check if operator group is already there + ogs := olmv1.OperatorGroupList{} + if err := applyClient.List(ctx, &ogs, runtimeclient.InNamespace(namespace)); err != nil { + return err + } + if len(ogs.Items) > 0 { + ctx.Println(fmt.Sprintf("OperatorGroup %s already present in namespace %s. Skipping creation of new operator group.", ogs.Items[0].GetName(), namespace)) + } else { + // install operator group + operatorGroup := newOperatorGroup(namespacedName) + ctx.Println(fmt.Sprintf("Creating new operator group %s in namespace %s.", operatorGroup.Name, operatorGroup.Namespace)) + if _, err := applyClient.ApplyObject(ctx, operatorGroup, commonclient.SaveConfiguration(false)); err != nil { + return err + } + ctx.Println(fmt.Sprintf("OperatorGroup %s created.", operatorGroup.Name)) + } + + // install subscription + operatorName := getOperatorName(operator) + subscription := newSubscription(namespacedName, operatorName, namespacedName.Name) + ctx.Println(fmt.Sprintf("Creating Subscription %s in namespace %s.", subscription.Name, subscription.Namespace)) + if _, err := applyClient.ApplyObject(ctx, subscription, commonclient.SaveConfiguration(false)); err != nil { + return err + } + ctx.Println(fmt.Sprintf("Subcription %s created.", subscription.Name)) + if err := waitUntilInstallPlanIsComplete(ctx, applyClient, operatorName, namespace, timeout); err != nil { + return err + } + ctx.Println(fmt.Sprintf("InstallPlan for the %s operator has been completed", operator)) + ctx.Println("") + ctx.Println(fmt.Sprintf("The %s operator has been successfully installed in the %s namespace", operator, namespace)) + return nil +} + +func getOperatorName(operator string) string { + return fmt.Sprintf("toolchain-%s-operator", operator) +} + +func createNamespaceIfNotFound(ctx *clicontext.TerminalContext, applyClient *commonclient.ApplyClient, namespace string) error { + ns := &v1.Namespace{} + if err := applyClient.Get(ctx.Context, types.NamespacedName{Name: namespace}, ns); err != nil { + if errors.IsNotFound(err) { + ctx.Println(fmt.Sprintf("Creating namespace %s.", namespace)) + ns.Name = namespace + if errNs := applyClient.Create(ctx.Context, ns); errNs != nil { + return errNs + } + } else { + return err + } + } + ctx.Println(fmt.Sprintf("Namespace %s created.", namespace)) + return nil +} + +func operatorResourceName(operator string) string { + return fmt.Sprintf("%s-operator", operator) +} + +func newCatalogSource(name types.NamespacedName, operator string) *olmv1alpha1.CatalogSource { + return &olmv1alpha1.CatalogSource{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: name.Namespace, + Name: name.Name, + }, + Spec: olmv1alpha1.CatalogSourceSpec{ + SourceType: olmv1alpha1.SourceTypeGrpc, + Image: fmt.Sprintf("quay.io/codeready-toolchain/%s-operator-index:latest", operator), + DisplayName: fmt.Sprintf("KubeSaw %s Operator", operator), + Publisher: "Red Hat", + UpdateStrategy: &olmv1alpha1.UpdateStrategy{ + RegistryPoll: &olmv1alpha1.RegistryPoll{ + Interval: &metav1.Duration{ + Duration: 5 * time.Minute, + }, + }, + }, + }, + } +} + +func newOperatorGroup(name types.NamespacedName) *olmv1.OperatorGroup { + return &olmv1.OperatorGroup{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: name.Namespace, + Name: name.Name, + }, + Spec: olmv1.OperatorGroupSpec{ + TargetNamespaces: []string{ + name.Namespace, + }, + }, + } +} + +func newSubscription(name types.NamespacedName, operatorName, catalogSourceName string) *olmv1alpha1.Subscription { + return &olmv1alpha1.Subscription{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: name.Namespace, + Name: name.Name, + }, + Spec: &olmv1alpha1.SubscriptionSpec{ + Channel: "staging", + InstallPlanApproval: olmv1alpha1.ApprovalAutomatic, + Package: operatorName, + CatalogSource: catalogSourceName, + CatalogSourceNamespace: name.Namespace, + }, + } +} + +func waitUntilCatalogSourceIsReady(ctx *clicontext.TerminalContext, applyClient *commonclient.ApplyClient, catalogSourceKey runtimeclient.ObjectKey, waitForReadyTimeout time.Duration) error { + cs := &olmv1alpha1.CatalogSource{} + if err := wait.PollImmediate(2*time.Second, waitForReadyTimeout, func() (bool, error) { + ctx.Printlnf("waiting for CatalogSource %s to become ready", catalogSourceKey) + cs = &olmv1alpha1.CatalogSource{} + if err := applyClient.Get(ctx, catalogSourceKey, cs); err != nil { + return false, err + } + + return cs.Status.GRPCConnectionState != nil && cs.Status.GRPCConnectionState.LastObservedState == "READY", nil + }); err != nil { + csString, _ := json.Marshal(cs) + return fmt.Errorf("failed waiting for catalog source to be ready.\n CatalogSource found: %v \n\t", string(csString)) + } + return nil +} + +func waitUntilInstallPlanIsComplete(ctx *clicontext.TerminalContext, cl runtimeclient.Client, operator, namespace string, waitForReadyTimeout time.Duration) error { + plans := &olmv1alpha1.InstallPlanList{} + if err := wait.PollImmediate(2*time.Second, waitForReadyTimeout, func() (bool, error) { + ctx.Printlnf("waiting for InstallPlans in namespace %s to complete", namespace) + plans = &olmv1alpha1.InstallPlanList{} + if err := cl.List(ctx, plans, runtimeclient.InNamespace(namespace), + runtimeclient.MatchingLabels{fmt.Sprintf("operators.coreos.com/%s.%s", operator, namespace): ""}, + ); err != nil { + return false, err + } + + for _, ip := range plans.Items { + if ip.Status.Phase != olmv1alpha1.InstallPlanPhaseComplete { + return false, nil + } + } + + return len(plans.Items) > 0, nil + }); err != nil { + plansString, _ := json.Marshal(plans) + return fmt.Errorf("failed waiting for install plan to be complete.\n InstallPlans found: %s \n\t", string(plansString)) + } + return nil +} + +// checkOneOperatorPerNamespace returns an error in case the namespace contains the other operator installed. +// So for host namespace member operator should not be installed in there and vice-versa. +func checkOneOperatorPerNamespace(ctx *clicontext.TerminalContext, applyClient *commonclient.ApplyClient, namespace, operator string) error { + namespacedName := types.NamespacedName{ + Namespace: namespace, + Name: configuration.ClusterType(operator).TheOtherType().String(), + } + subscription := olmv1alpha1.Subscription{} + if err := applyClient.Get(ctx.Context, namespacedName, &subscription); err != nil { + if errors.IsNotFound(err) { + return nil + } + + return err + } + return fmt.Errorf("found already installed subscription %s in namespace %s - it's not allowed to have host and member in the same namespace", subscription.GetName(), subscription.GetNamespace()) +} diff --git a/pkg/cmd/adm/install_operator_test.go b/pkg/cmd/adm/install_operator_test.go new file mode 100644 index 0000000..8c63d9f --- /dev/null +++ b/pkg/cmd/adm/install_operator_test.go @@ -0,0 +1,241 @@ +package adm + +import ( + "context" + "fmt" + "testing" + "time" + + commonclient "github.com/codeready-toolchain/toolchain-common/pkg/client" + corev1 "k8s.io/api/core/v1" + + "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + v1 "github.com/operator-framework/api/pkg/operators/v1" + olmv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/types" +) + +func TestInstallOperator(t *testing.T) { + // given + require.NoError(t, client.AddToScheme()) + SetFileConfig(t, Host(), Member()) + + for _, operator := range []string{"host", "member"} { + + kubeconfig, namespace := "", "" + if operator == "host" { + kubeconfig = PersistKubeConfigFile(t, HostKubeConfig()) + namespace = "toolchain-host-operator" + } else { + kubeconfig = PersistKubeConfigFile(t, MemberKubeConfig()) + namespace = "toolchain-member-operator" + } + installPlan := olmv1alpha1.InstallPlan{ + ObjectMeta: metav1.ObjectMeta{ + Name: operator + "-ip", + Namespace: namespace, + Labels: map[string]string{ + fmt.Sprintf("operators.coreos.com/%s.%s", getOperatorName(operator), namespace): "", + }, + }, + Status: olmv1alpha1.InstallPlanStatus{Phase: olmv1alpha1.InstallPlanPhaseComplete}, + } + timeout := 1 * time.Second + args := installArgs{ + kubeConfig: kubeconfig, + namespace: namespace, + } + + t.Run("install "+operator+" operator is successful", func(t *testing.T) { + // given + fakeClient := test.NewFakeClient(t, &installPlan) + fakeClientWithReadyCatalogSource(fakeClient) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewTerminalContext(term) + + // when + err := installOperator(ctx, args, operator, timeout, commonclient.NewApplyClient(fakeClient)) + + // then + require.NoError(t, err) + ns := &corev1.Namespace{} + require.NoError(t, fakeClient.Get(context.TODO(), types.NamespacedName{Name: namespace}, ns)) // check that the namespace was created as well + AssertCatalogSourceExists(t, fakeClient, types.NamespacedName{Name: fmt.Sprintf("%s-operator", operator), Namespace: namespace}) + AssertCatalogSourceHasSpec(t, fakeClient, types.NamespacedName{Name: fmt.Sprintf("%s-operator", operator), Namespace: namespace}, + olmv1alpha1.CatalogSourceSpec{ + SourceType: olmv1alpha1.SourceTypeGrpc, + Image: fmt.Sprintf("quay.io/codeready-toolchain/%s-operator-index:latest", operator), + DisplayName: fmt.Sprintf("KubeSaw %s Operator", operator), + Publisher: "Red Hat", + UpdateStrategy: &olmv1alpha1.UpdateStrategy{ + RegistryPoll: &olmv1alpha1.RegistryPoll{ + Interval: &metav1.Duration{ + Duration: 5 * time.Minute, + }, + }, + }, + }, + ) + AssertOperatorGroupExists(t, fakeClient, types.NamespacedName{Name: fmt.Sprintf("%s-operator", operator), Namespace: namespace}) + AssertSubscriptionExists(t, fakeClient, types.NamespacedName{Name: fmt.Sprintf("%s-operator", operator), Namespace: namespace}) + assert.Contains(t, term.Output(), fmt.Sprintf("The %s operator has been successfully installed in the %s namespace", operator, namespace)) + }) + + t.Run("install "+operator+" operator fails if CatalogSource is not ready", func(t *testing.T) { + // given + fakeClient := test.NewFakeClient(t) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewTerminalContext(term) + + // when + err := installOperator(ctx, args, operator, timeout, commonclient.NewApplyClient(fakeClient)) + + // then + require.ErrorContains(t, err, "failed waiting for catalog source to be ready.") + AssertOperatorGroupDoesNotExist(t, fakeClient, types.NamespacedName{Name: fmt.Sprintf("%s-operator", operator), Namespace: namespace}) + AssertSubscriptionDoesNotExist(t, fakeClient, types.NamespacedName{Name: fmt.Sprintf("%s-operator", operator), Namespace: namespace}) + assert.NotContains(t, term.Output(), fmt.Sprintf("The %s operator has been successfully installed in the %s namespace", operator, namespace)) + }) + + t.Run("install "+operator+" operators fails if InstallPlan is not ready", func(t *testing.T) { + // given + // InstallPlan is pre provisioned but not ready + notReadyIP := installPlan.DeepCopy() + notReadyIP.Status = olmv1alpha1.InstallPlanStatus{Phase: olmv1alpha1.InstallPlanFailed} + fakeClient := test.NewFakeClient(t, notReadyIP) + fakeClientWithReadyCatalogSource(fakeClient) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewTerminalContext(term) + + // when + err := installOperator(ctx, args, operator, timeout, commonclient.NewApplyClient(fakeClient)) + + // then + require.ErrorContains(t, err, "failed waiting for install plan to be complete.") + assert.NotContains(t, term.Output(), fmt.Sprintf("The %s operator has been successfully installed in the %s namespace", operator, namespace)) + }) + + t.Run(operator+" fails to install if the other operator is installed", func(t *testing.T) { + // given + operatorAlreadyInstalled := configuration.ClusterType(operator).TheOtherType().String() + existingSubscription := olmv1alpha1.Subscription{ + ObjectMeta: metav1.ObjectMeta{Name: operatorAlreadyInstalled, Namespace: namespace}, + } + fakeClient := test.NewFakeClient(t, &existingSubscription) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewTerminalContext(term) + + // when + err := installOperator(ctx, installArgs{namespace: namespace}, + operator, + 1*time.Second, + commonclient.NewApplyClient(fakeClient), + ) + + // then + require.EqualError(t, err, fmt.Sprintf("found already installed subscription %s in namespace %s - it's not allowed to have host and member in the same namespace", operatorAlreadyInstalled, namespace)) + }) + + t.Run("skip creation of operator group if already present", func(t *testing.T) { + // given + existingOperatorGroup := v1.OperatorGroup{ + ObjectMeta: metav1.ObjectMeta{Name: operatorResourceName(operator), Namespace: namespace}, + } + fakeClient := test.NewFakeClient(t, &existingOperatorGroup, &installPlan) + fakeClientWithReadyCatalogSource(fakeClient) + term := NewFakeTerminalWithResponse("y") + ctx := clicontext.NewTerminalContext(term) + + // when + err := installOperator(ctx, args, operator, timeout, commonclient.NewApplyClient(fakeClient)) + + // then + require.NoError(t, err) + assert.Contains(t, term.Output(), fmt.Sprintf("OperatorGroup %s already present in namespace %s. Skipping creation of new operator group.", operatorResourceName(operator), namespace)) + assert.NotContains(t, term.Output(), fmt.Sprintf("Creating new operator group %s in namespace %s.", operatorResourceName(operator), namespace)) + assert.Contains(t, term.Output(), fmt.Sprintf("The %s operator has been successfully installed in the %s namespace", operator, namespace)) + }) + + t.Run("namespace is computed if not provided", func(t *testing.T) { + // given + fakeClient := test.NewFakeClient(t, &installPlan) + fakeClientWithReadyCatalogSource(fakeClient) + term := NewFakeTerminalWithResponse("y") + ctx := clicontext.NewTerminalContext(term) + + // when + err := installOperator(ctx, installArgs{namespace: "", kubeConfig: kubeconfig}, // we provide no namespace + operator, + timeout, + commonclient.NewApplyClient(fakeClient), + ) + // then + require.NoError(t, err) + assert.Contains(t, term.Output(), fmt.Sprintf("The %s operator has been successfully installed in the %s namespace", operator, namespace)) // and it's installed in the expected namespace + }) + } + + t.Run("fails if operator name is invalid", func(t *testing.T) { + // given + fakeClient := test.NewFakeClient(t) + fakeClientWithReadyCatalogSource(fakeClient) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewTerminalContext(term) + + // when + err := installOperator(ctx, installArgs{}, + "INVALIDOPERATOR", + 1*time.Second, + commonclient.NewApplyClient(fakeClient), + ) + + // then + require.EqualError(t, err, "invalid operator type provided: INVALIDOPERATOR. Valid ones are host|member") + }) + + t.Run("doesn't install operator if response is no", func(t *testing.T) { + // given + fakeClient := test.NewFakeClient(t) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewTerminalContext(term) + + // when + operator := "host" + err := installOperator(ctx, installArgs{namespace: "toolchain-host-operator"}, + operator, + 1*time.Second, + commonclient.NewApplyClient(fakeClient), + ) + + // then + require.NoError(t, err) + assert.Contains(t, term.Output(), fmt.Sprintf("Are you sure that you want to install %s in namespace", operator)) + assert.NotContains(t, term.Output(), fmt.Sprintf("The %s operator has been successfully installed in the toolchain-host-operator namespace", operator)) + }) +} + +func fakeClientWithReadyCatalogSource(fakeClient *test.FakeClient) { + fakeClient.MockCreate = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.CreateOption) error { + switch objT := obj.(type) { + case *olmv1alpha1.CatalogSource: + // let's set the status of the CS to be able to test the "happy path" + objT.Status = olmv1alpha1.CatalogSourceStatus{ + GRPCConnectionState: &olmv1alpha1.GRPCConnectionState{ + LastObservedState: "READY", + }, + } + return fakeClient.Client.Create(ctx, objT) + default: + return fakeClient.Client.Create(ctx, objT) + } + } +} diff --git a/pkg/context/command_context.go b/pkg/context/command_context.go index e8c71cd..fafa817 100644 --- a/pkg/context/command_context.go +++ b/pkg/context/command_context.go @@ -26,3 +26,17 @@ func NewCommandContext(term ioutils.Terminal, newClient NewClientFunc) *CommandC NewClient: newClient, } } + +// TerminalContext the context terminal utilities and KubeClient +type TerminalContext struct { + context.Context + ioutils.Terminal +} + +// NewTerminalContext returns the context with the terminal utilities and the kubeClient to be used by the CLI command. +func NewTerminalContext(term ioutils.Terminal) *TerminalContext { + return &TerminalContext{ + Context: context.Background(), + Terminal: term, + } +}