github.com/1aal/kubeblocks@v0.0.0-20231107070852-e1c03e598921/pkg/cli/cmd/cluster/connect.go (about)

     1  /*
     2  Copyright (C) 2022-2023 ApeCloud Co., Ltd
     3  
     4  This file is part of KubeBlocks project
     5  
     6  This program is free software: you can redistribute it and/or modify
     7  it under the terms of the GNU Affero General Public License as published by
     8  the Free Software Foundation, either version 3 of the License, or
     9  (at your option) any later version.
    10  
    11  This program is distributed in the hope that it will be useful
    12  but WITHOUT ANY WARRANTY; without even the implied warranty of
    13  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    14  GNU Affero General Public License for more details.
    15  
    16  You should have received a copy of the GNU Affero General Public License
    17  along with this program.  If not, see <http://www.gnu.org/licenses/>.
    18  */
    19  
    20  package cluster
    21  
    22  import (
    23  	"context"
    24  	"fmt"
    25  	"os"
    26  	"strings"
    27  
    28  	"github.com/spf13/cobra"
    29  	"golang.org/x/crypto/ssh/terminal"
    30  	corev1 "k8s.io/api/core/v1"
    31  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    32  	"k8s.io/cli-runtime/pkg/genericiooptions"
    33  	"k8s.io/klog/v2"
    34  	cmdutil "k8s.io/kubectl/pkg/cmd/util"
    35  	"k8s.io/kubectl/pkg/util/templates"
    36  
    37  	appsv1alpha1 "github.com/1aal/kubeblocks/apis/apps/v1alpha1"
    38  	"github.com/1aal/kubeblocks/pkg/cli/cluster"
    39  	"github.com/1aal/kubeblocks/pkg/cli/exec"
    40  	"github.com/1aal/kubeblocks/pkg/cli/types"
    41  	"github.com/1aal/kubeblocks/pkg/cli/util"
    42  	"github.com/1aal/kubeblocks/pkg/cli/util/flags"
    43  	"github.com/1aal/kubeblocks/pkg/constant"
    44  	"github.com/1aal/kubeblocks/pkg/lorry/engines"
    45  	"github.com/1aal/kubeblocks/pkg/lorry/engines/models"
    46  	"github.com/1aal/kubeblocks/pkg/lorry/engines/register"
    47  )
    48  
    49  var connectExample = templates.Examples(`
    50  		# connect to a specified cluster, default connect to the leader/primary instance
    51  		kbcli cluster connect mycluster
    52  
    53  		# connect to cluster as user
    54  		kbcli cluster connect mycluster --as-user myuser
    55  
    56  		# connect to a specified instance
    57  		kbcli cluster connect -i mycluster-instance-0
    58  
    59  		# connect to a specified component
    60  		kbcli cluster connect mycluster --component mycomponent
    61  
    62  		# show cli connection example with password mask
    63  		kbcli cluster connect mycluster --show-example --client=cli
    64  
    65  		# show java connection example with password mask
    66  		kbcli cluster connect mycluster --show-example --client=java
    67  
    68  		# show all connection examples with password mask
    69  		kbcli cluster connect mycluster --show-example 
    70  
    71  		# show cli connection examples with real password 
    72  		kbcli cluster connect mycluster --show-example --client=cli --show-password`)
    73  
    74  const passwordMask = "******"
    75  
    76  type ConnectOptions struct {
    77  	clusterName   string
    78  	componentName string
    79  
    80  	clientType   string
    81  	showExample  bool
    82  	showPassword bool
    83  	engine       engines.ClusterCommands
    84  
    85  	privateEndPoint bool
    86  	svc             *corev1.Service
    87  
    88  	component        *appsv1alpha1.ClusterComponentSpec
    89  	componentDef     *appsv1alpha1.ClusterComponentDefinition
    90  	targetCluster    *appsv1alpha1.Cluster
    91  	targetClusterDef *appsv1alpha1.ClusterDefinition
    92  
    93  	userName   string
    94  	userPasswd string
    95  
    96  	*exec.ExecOptions
    97  }
    98  
    99  // NewConnectCmd returns the cmd of connecting to a cluster
   100  func NewConnectCmd(f cmdutil.Factory, streams genericiooptions.IOStreams) *cobra.Command {
   101  	o := &ConnectOptions{ExecOptions: exec.NewExecOptions(f, streams)}
   102  	cmd := &cobra.Command{
   103  		Use:               "connect (NAME | -i INSTANCE-NAME)",
   104  		Short:             "Connect to a cluster or instance.",
   105  		Example:           connectExample,
   106  		ValidArgsFunction: util.ResourceNameCompletionFunc(f, types.ClusterGVR()),
   107  		Run: func(cmd *cobra.Command, args []string) {
   108  			util.CheckErr(o.validate(args))
   109  			util.CheckErr(o.complete())
   110  			if o.showExample {
   111  				util.CheckErr(o.runShowExample())
   112  			} else {
   113  				util.CheckErr(o.connect())
   114  			}
   115  		},
   116  	}
   117  	cmd.Flags().StringVarP(&o.PodName, "instance", "i", "", "The instance name to connect.")
   118  	flags.AddComponentFlag(f, cmd, &o.componentName, "The component to connect. If not specified, pick up the first one.")
   119  	cmd.Flags().BoolVar(&o.showExample, "show-example", false, "Show how to connect to cluster/instance from different clients.")
   120  	cmd.Flags().BoolVar(&o.showPassword, "show-password", false, "Show password in example.")
   121  
   122  	cmd.Flags().StringVar(&o.clientType, "client", "", "Which client connection example should be output, only valid if --show-example is true.")
   123  
   124  	cmd.Flags().StringVar(&o.userName, "as-user", "", "Connect to cluster as user")
   125  
   126  	util.CheckErr(cmd.RegisterFlagCompletionFunc("client", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
   127  		var types []string
   128  		for _, t := range models.ClientTypes() {
   129  			if strings.HasPrefix(t, toComplete) {
   130  				types = append(types, t)
   131  			}
   132  		}
   133  		return types, cobra.ShellCompDirectiveNoFileComp
   134  	}))
   135  	return cmd
   136  }
   137  
   138  func (o *ConnectOptions) runShowExample() error {
   139  	// get connection info
   140  	info, err := o.getConnectionInfo()
   141  	if err != nil {
   142  		return err
   143  	}
   144  	// make sure engine is initialized
   145  	if o.engine == nil {
   146  		return fmt.Errorf("engine is not initialized yet")
   147  	}
   148  
   149  	// if cluster does not have public endpoints, prompts to use port-forward command and
   150  	// connect cluster from localhost
   151  	if o.privateEndPoint {
   152  		fmt.Fprintf(o.Out, "# cluster %s does not have public endpoints, you can run following command and connect cluster from localhost\n"+
   153  			"kubectl port-forward service/%s %s:%s\n\n", o.clusterName, o.svc.Name, info.Port, info.Port)
   154  		info.Host = "127.0.0.1"
   155  	}
   156  
   157  	fmt.Fprint(o.Out, o.engine.ConnectExample(info, o.clientType))
   158  	return nil
   159  }
   160  
   161  func (o *ConnectOptions) validate(args []string) error {
   162  	if len(args) > 1 {
   163  		return fmt.Errorf("only support to connect one cluster")
   164  	}
   165  
   166  	// cluster name and pod instance are mutual exclusive
   167  	if len(o.PodName) > 0 {
   168  		if len(args) > 0 {
   169  			return fmt.Errorf("specify either cluster name or instance name, they are exclusive")
   170  		}
   171  		if len(o.componentName) > 0 {
   172  			return fmt.Errorf("component name is valid only when cluster name is specified")
   173  		}
   174  	} else if len(args) == 0 {
   175  		return fmt.Errorf("either cluster name or instance name should be specified")
   176  	}
   177  
   178  	// set custer name
   179  	if len(args) > 0 {
   180  		o.clusterName = args[0]
   181  	}
   182  
   183  	// validate user name and password
   184  	if len(o.userName) > 0 {
   185  		// read password from stdin
   186  		fmt.Print("Password: ")
   187  		if bytePassword, err := terminal.ReadPassword(int(os.Stdin.Fd())); err != nil {
   188  			return err
   189  		} else {
   190  			o.userPasswd = string(bytePassword)
   191  		}
   192  	}
   193  	return nil
   194  }
   195  
   196  func (o *ConnectOptions) complete() error {
   197  	var err error
   198  	if err = o.ExecOptions.Complete(); err != nil {
   199  		return err
   200  	}
   201  	// opt 1. specified pod name
   202  	// 1.1 get pod by name
   203  	if len(o.PodName) > 0 {
   204  		if o.Pod, err = o.Client.CoreV1().Pods(o.Namespace).Get(context.Background(), o.PodName, metav1.GetOptions{}); err != nil {
   205  			return err
   206  		}
   207  		o.clusterName = cluster.GetPodClusterName(o.Pod)
   208  		o.componentName = cluster.GetPodComponentName(o.Pod)
   209  	}
   210  
   211  	// cannot infer characterType from pod directly (neither from pod annotation nor pod label)
   212  	// so we have to get cluster definition first to get characterType
   213  	// opt 2. specified cluster name
   214  	// 2.1 get cluster by name
   215  	if o.targetCluster, err = cluster.GetClusterByName(o.Dynamic, o.clusterName, o.Namespace); err != nil {
   216  		return err
   217  	}
   218  	// get cluster def
   219  	if o.targetClusterDef, err = cluster.GetClusterDefByName(o.Dynamic, o.targetCluster.Spec.ClusterDefRef); err != nil {
   220  		return err
   221  	}
   222  
   223  	// 2.2 fill component name, use the first component by default
   224  	if len(o.componentName) == 0 {
   225  		o.component = &o.targetCluster.Spec.ComponentSpecs[0]
   226  		o.componentName = o.component.Name
   227  	} else {
   228  		// verify component
   229  		if o.component = o.targetCluster.Spec.GetComponentByName(o.componentName); o.component == nil {
   230  			return fmt.Errorf("failed to get component %s. Check the list of components use: \n\tkbcli cluster list-components %s -n %s", o.componentName, o.clusterName, o.Namespace)
   231  		}
   232  	}
   233  
   234  	// 2.3 get character type
   235  	if o.componentDef = o.targetClusterDef.GetComponentDefByName(o.component.ComponentDefRef); o.componentDef == nil {
   236  		return fmt.Errorf("failed to get component def :%s", o.component.ComponentDefRef)
   237  	}
   238  
   239  	// 2.4. get pod to connect, make sure o.clusterName, o.componentName are set before this step
   240  	if len(o.PodName) == 0 {
   241  		if err = o.getTargetPod(); err != nil {
   242  			return err
   243  		}
   244  		if o.Pod, err = o.Client.CoreV1().Pods(o.Namespace).Get(context.TODO(), o.PodName, metav1.GetOptions{}); err != nil {
   245  			return err
   246  		}
   247  	}
   248  	return nil
   249  }
   250  
   251  // connect creates connection string and connects to cluster
   252  func (o *ConnectOptions) connect() error {
   253  	if o.componentDef == nil {
   254  		return fmt.Errorf("component def is not initialized")
   255  	}
   256  
   257  	var err error
   258  
   259  	if o.engine, err = register.NewClusterCommands(o.componentDef.CharacterType); err != nil {
   260  		return err
   261  	}
   262  
   263  	var authInfo *engines.AuthInfo
   264  	if len(o.userName) > 0 {
   265  		authInfo = &engines.AuthInfo{}
   266  		authInfo.UserName = o.userName
   267  		authInfo.UserPasswd = o.userPasswd
   268  	} else if authInfo, err = o.getAuthInfo(); err != nil {
   269  		return err
   270  	}
   271  
   272  	o.ExecOptions.ContainerName = o.engine.Container()
   273  	o.ExecOptions.Command = o.engine.ConnectCommand(authInfo)
   274  	if klog.V(1).Enabled() {
   275  		fmt.Fprintf(o.Out, "connect with cmd: %s", o.ExecOptions.Command)
   276  	}
   277  	return o.ExecOptions.Run()
   278  }
   279  
   280  func (o *ConnectOptions) getAuthInfo() (*engines.AuthInfo, error) {
   281  	// select secrets by labels, admin account is preferred
   282  	labels := fmt.Sprintf("%s=%s,%s=%s,%s=%s",
   283  		constant.AppInstanceLabelKey, o.clusterName,
   284  		constant.KBAppComponentLabelKey, o.componentName,
   285  		constant.ClusterAccountLabelKey, (string)(appsv1alpha1.AdminAccount),
   286  	)
   287  
   288  	secrets, err := o.Client.CoreV1().Secrets(o.Namespace).List(context.Background(), metav1.ListOptions{LabelSelector: labels})
   289  	if err != nil {
   290  		return nil, fmt.Errorf("failed to list secrets for cluster %s, component %s, err %v", o.clusterName, o.componentName, err)
   291  	}
   292  	if len(secrets.Items) == 0 {
   293  		return nil, nil
   294  	}
   295  	return &engines.AuthInfo{
   296  		UserName:   string(secrets.Items[0].Data["username"]),
   297  		UserPasswd: string(secrets.Items[0].Data["password"]),
   298  	}, nil
   299  }
   300  
   301  func (o *ConnectOptions) getTargetPod() error {
   302  	// make sure cluster name and component name are set
   303  	if len(o.clusterName) == 0 {
   304  		return fmt.Errorf("cluster name is not set yet")
   305  	}
   306  	if len(o.componentName) == 0 {
   307  		return fmt.Errorf("component name is not set yet")
   308  	}
   309  
   310  	// get instances for given cluster name and component name
   311  	infos := cluster.GetSimpleInstanceInfosForComponent(o.Dynamic, o.clusterName, o.componentName, o.Namespace)
   312  	if len(infos) == 0 || infos[0].Name == constant.ComponentStatusDefaultPodName {
   313  		return fmt.Errorf("failed to find the instance to connect, please check cluster status")
   314  	}
   315  
   316  	o.PodName = infos[0].Name
   317  
   318  	// print instance info that we connect
   319  	if len(infos) == 1 {
   320  		fmt.Fprintf(o.Out, "Connect to instance %s\n", o.PodName)
   321  		return nil
   322  	}
   323  
   324  	// output all instance infos
   325  	var nameRoles = make([]string, len(infos))
   326  	for i, info := range infos {
   327  		if len(info.Role) == 0 {
   328  			nameRoles[i] = info.Name
   329  		} else {
   330  			nameRoles[i] = fmt.Sprintf("%s(%s)", info.Name, info.Role)
   331  		}
   332  	}
   333  	fmt.Fprintf(o.Out, "Connect to instance %s: out of %s\n", o.PodName, strings.Join(nameRoles, ", "))
   334  	return nil
   335  }
   336  
   337  func (o *ConnectOptions) getConnectionInfo() (*engines.ConnectionInfo, error) {
   338  	// make sure component and componentDef are set before this step
   339  	if o.component == nil || o.componentDef == nil {
   340  		return nil, fmt.Errorf("failed to get component or component def")
   341  	}
   342  
   343  	info := &engines.ConnectionInfo{}
   344  	getter := cluster.ObjectsGetter{
   345  		Client:    o.Client,
   346  		Dynamic:   o.Dynamic,
   347  		Name:      o.clusterName,
   348  		Namespace: o.Namespace,
   349  		GetOptions: cluster.GetOptions{
   350  			WithClusterDef: true,
   351  			WithService:    true,
   352  			WithSecret:     true,
   353  		},
   354  	}
   355  
   356  	objs, err := getter.Get()
   357  	if err != nil {
   358  		return nil, err
   359  	}
   360  
   361  	info.ClusterName = o.clusterName
   362  	info.ComponentName = o.componentName
   363  	info.HeadlessEndpoint = getOneHeadlessEndpoint(objs.ClusterDef, objs.Secrets)
   364  	// get username and password
   365  	if info.User, info.Password, err = getUserAndPassword(objs.ClusterDef, objs.Secrets); err != nil {
   366  		return nil, err
   367  	}
   368  	if !o.showPassword {
   369  		info.Password = passwordMask
   370  	}
   371  	// get host and port, use external endpoints first, if external endpoints are empty,
   372  	// use internal endpoints
   373  
   374  	// TODO: now the primary component is the first component, that may not be correct,
   375  	// maybe show all components connection info in the future.
   376  	internalSvcs, externalSvcs := cluster.GetComponentServices(objs.Services, o.component)
   377  	switch {
   378  	case len(externalSvcs) > 0:
   379  		// cluster has public endpoints
   380  		o.svc = externalSvcs[0]
   381  		info.Host = cluster.GetExternalAddr(o.svc)
   382  		info.Port = fmt.Sprintf("%d", o.svc.Spec.Ports[0].Port)
   383  	case len(internalSvcs) > 0:
   384  		// cluster does not have public endpoints
   385  		o.svc = internalSvcs[0]
   386  		info.Host = o.svc.Spec.ClusterIP
   387  		info.Port = fmt.Sprintf("%d", o.svc.Spec.Ports[0].Port)
   388  		o.privateEndPoint = true
   389  	default:
   390  		// find no endpoints
   391  		return nil, fmt.Errorf("failed to find any cluster endpoints")
   392  	}
   393  
   394  	if o.engine, err = register.NewClusterCommands(o.componentDef.CharacterType); err != nil {
   395  		return nil, err
   396  	}
   397  
   398  	return info, nil
   399  }
   400  
   401  // getUserAndPassword gets cluster user and password from secrets
   402  func getUserAndPassword(clusterDef *appsv1alpha1.ClusterDefinition, secrets *corev1.SecretList) (string, string, error) {
   403  	var (
   404  		user, password = "", ""
   405  		err            error
   406  	)
   407  
   408  	if len(secrets.Items) == 0 {
   409  		return user, password, fmt.Errorf("failed to find the cluster username and password")
   410  	}
   411  
   412  	getPasswordKey := func(connectionCredential map[string]string) string {
   413  		for k := range connectionCredential {
   414  			if strings.Contains(k, "password") {
   415  				return k
   416  			}
   417  		}
   418  		return "password"
   419  	}
   420  
   421  	getSecretVal := func(secret *corev1.Secret, key string) (string, error) {
   422  		val, ok := secret.Data[key]
   423  		if !ok {
   424  			return "", fmt.Errorf("failed to find the cluster %s", key)
   425  		}
   426  		return string(val), nil
   427  	}
   428  
   429  	// now, we only use the first secret
   430  	var secret corev1.Secret
   431  	for i, s := range secrets.Items {
   432  		if strings.Contains(s.Name, "conn-credential") {
   433  			secret = secrets.Items[i]
   434  			break
   435  		}
   436  	}
   437  	user, err = getSecretVal(&secret, "username")
   438  	if err != nil {
   439  		return user, password, err
   440  	}
   441  
   442  	passwordKey := getPasswordKey(clusterDef.Spec.ConnectionCredential)
   443  	password, err = getSecretVal(&secret, passwordKey)
   444  	return user, password, err
   445  }
   446  
   447  // getOneHeadlessEndpoint gets cluster headlessEndpoint from secrets
   448  func getOneHeadlessEndpoint(clusterDef *appsv1alpha1.ClusterDefinition, secrets *corev1.SecretList) string {
   449  	if len(secrets.Items) == 0 {
   450  		return ""
   451  	}
   452  	val, ok := secrets.Items[0].Data["headlessEndpoint"]
   453  	if !ok {
   454  		return ""
   455  	}
   456  	return string(val)
   457  }