Skip to content

Commit

Permalink
feature: init new ksctl adm install-operators command (#48)
Browse files Browse the repository at this point in the history
* add new ksctl adm install-operators command
---------

Co-authored-by: Matous Jobanek <[email protected]>
  • Loading branch information
mfrancisc and MatousJobanek authored Aug 20, 2024
1 parent 2cf7e16 commit f7f37c5
Show file tree
Hide file tree
Showing 6 changed files with 573 additions and 2 deletions.
22 changes: 22 additions & 0 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down
31 changes: 29 additions & 2 deletions pkg/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/adm/adm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
266 changes: 266 additions & 0 deletions pkg/cmd/adm/install_operator.go
Original file line number Diff line number Diff line change
@@ -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 <host|member> --kubeconfig <path/to/kubeconfig> --namespace <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())
}
Loading

0 comments on commit f7f37c5

Please sign in to comment.