github.com/bhojpur/cache@v0.0.4/cmd/client/root.go (about)

     1  package cmd
     2  
     3  // Copyright (c) 2018 Bhojpur Consulting Private Limited, India. All rights reserved.
     4  
     5  // Permission is hereby granted, free of charge, to any person obtaining a copy
     6  // of this software and associated documentation files (the "Software"), to deal
     7  // in the Software without restriction, including without limitation the rights
     8  // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
     9  // copies of the Software, and to permit persons to whom the Software is
    10  // furnished to do so, subject to the following conditions:
    11  
    12  // The above copyright notice and this permission notice shall be included in
    13  // all copies or substantial portions of the Software.
    14  
    15  // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    16  // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    17  // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    18  // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    19  // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    20  // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    21  // THE SOFTWARE.
    22  
    23  import (
    24  	"bytes"
    25  	"context"
    26  	"fmt"
    27  	"io"
    28  	"net"
    29  	"net/http"
    30  	"net/url"
    31  	"os"
    32  	"path/filepath"
    33  	"strings"
    34  	"sync"
    35  
    36  	log "github.com/sirupsen/logrus"
    37  	"github.com/spf13/cobra"
    38  	"google.golang.org/grpc"
    39  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    40  	"k8s.io/client-go/kubernetes"
    41  	"k8s.io/client-go/rest"
    42  	"k8s.io/client-go/tools/clientcmd"
    43  	"k8s.io/client-go/tools/portforward"
    44  	"k8s.io/client-go/transport/spdy"
    45  )
    46  
    47  var (
    48  	verbose bool
    49  	host    string
    50  )
    51  
    52  var rootCmdOpts struct {
    53  	Verbose          bool
    54  	Host             string
    55  	Kubeconfig       string
    56  	K8sNamespace     string
    57  	K8sLabelSelector string
    58  	K8sPodPort       string
    59  	DialMode         string
    60  }
    61  
    62  // rootCmd represents the base command when called without any subcommands
    63  var rootCmd = &cobra.Command{
    64  	Use:   "cachectl",
    65  	Short: "Bhojpur Cachectl is a command & control client engine for distributed cache engine",
    66  	PersistentPreRun: func(cmd *cobra.Command, args []string) {
    67  		if verbose {
    68  			log.SetLevel(log.DebugLevel)
    69  			log.Debug("verbose logging enabled")
    70  		}
    71  	},
    72  }
    73  
    74  // Execute adds all child commands to the root command and sets flags appropriately.
    75  // This is called by main.main(). It only needs to happen once to the rootCmd.
    76  func Execute() {
    77  	if err := rootCmd.Execute(); err != nil {
    78  		fmt.Println(err)
    79  		os.Exit(1)
    80  	}
    81  }
    82  
    83  type dialMode string
    84  
    85  const (
    86  	dialModeHost       = "host"
    87  	dialModeKubernetes = "kubernetes"
    88  )
    89  
    90  func init() {
    91  	cacheHost := os.Getenv("CACHE_HOST")
    92  	if cacheHost == "" {
    93  		cacheHost = "localhost:7777"
    94  	}
    95  	cacheKubeconfig := os.Getenv("KUBECONFIG")
    96  	if cacheKubeconfig == "" {
    97  		home, err := os.UserHomeDir()
    98  		if err != nil {
    99  			log.WithError(err).Warn("cannot determine user's home directory")
   100  		} else {
   101  			cacheKubeconfig = filepath.Join(home, ".kube", "config")
   102  		}
   103  	}
   104  	cacheNamespace := os.Getenv("CACHE_K8S_NAMESPACE")
   105  	cacheLabelSelector := os.Getenv("CACHE_K8S_LABEL")
   106  	if cacheLabelSelector == "" {
   107  		cacheLabelSelector = "app.kubernetes.io/name=cache"
   108  	}
   109  	cachePodPort := os.Getenv("CACHE_K8S_POD_PORT")
   110  	if cachePodPort == "" {
   111  		cachePodPort = "7777"
   112  	}
   113  	dialMode := os.Getenv("CACHE_DIAL_MODE")
   114  	if dialMode == "" {
   115  		dialMode = string(dialModeHost)
   116  	}
   117  
   118  	rootCmd.PersistentFlags().BoolVar(&rootCmdOpts.Verbose, "verbose", false, "en/disable verbose logging")
   119  	rootCmd.PersistentFlags().StringVar(&rootCmdOpts.DialMode, "dial-mode", dialMode, "dial mode that determines how we connect to Bhojpur Cache. Valid values are \"host\" or \"kubernetes\" (defaults to CACHE_DIAL_MODE env var).")
   120  	rootCmd.PersistentFlags().StringVar(&rootCmdOpts.Host, "host", cacheHost, "[host dial mode] Bhojpur Cache host to talk to (defaults to CACHE_HOST env var)")
   121  	rootCmd.PersistentFlags().StringVar(&rootCmdOpts.Kubeconfig, "kubeconfig", cacheKubeconfig, "[kubernetes dial mode] kubeconfig file to use (defaults to KUEBCONFIG env var)")
   122  	rootCmd.PersistentFlags().StringVar(&rootCmdOpts.K8sNamespace, "k8s-namespace", cacheNamespace, "[kubernetes dial mode] Kubernetes namespace in which to look for the Bhojpur Cache pods (defaults to CACHE_K8S_NAMESPACE env var, or configured kube context namespace)")
   123  	// The following are such specific flags that really only matters if one doesn't use the stock helm charts.
   124  	// They can still be set using an env var, but there's no need to clutter the CLI with them.
   125  	rootCmdOpts.K8sLabelSelector = cacheLabelSelector
   126  	rootCmdOpts.K8sPodPort = cachePodPort
   127  }
   128  
   129  type closableGrpcClientConnInterface interface {
   130  	grpc.ClientConnInterface
   131  	io.Closer
   132  }
   133  
   134  func dial() (res closableGrpcClientConnInterface) {
   135  	var err error
   136  	switch rootCmdOpts.DialMode {
   137  	case dialModeHost:
   138  		res, err = grpc.Dial(rootCmdOpts.Host, grpc.WithInsecure())
   139  	case dialModeKubernetes:
   140  		res, err = dialKubernetes()
   141  	default:
   142  		log.Fatalf("unknown dial mode: %s", rootCmdOpts.DialMode)
   143  	}
   144  
   145  	if err != nil {
   146  		log.WithError(err).Fatal("cannot connect to Bhojpur Cache server")
   147  	}
   148  	return
   149  }
   150  
   151  func dialKubernetes() (closableGrpcClientConnInterface, error) {
   152  	kubecfg, namespace, err := getKubeconfig(rootCmdOpts.Kubeconfig)
   153  	if err != nil {
   154  		return nil, fmt.Errorf("cannot load kubeconfig %s: %w", rootCmdOpts.Kubeconfig, err)
   155  	}
   156  	if rootCmdOpts.K8sNamespace != "" {
   157  		namespace = rootCmdOpts.K8sNamespace
   158  	}
   159  
   160  	clientSet, err := kubernetes.NewForConfig(kubecfg)
   161  	if err != nil {
   162  		return nil, err
   163  	}
   164  
   165  	pod, err := findCachePod(clientSet, namespace, rootCmdOpts.K8sLabelSelector)
   166  	if err != nil {
   167  		return nil, fmt.Errorf("cannot find Bhojpur Cache pod: %w", err)
   168  	}
   169  
   170  	localPort, err := findFreeLocalPort()
   171  
   172  	ctx, cancel := context.WithCancel(context.Background())
   173  	readychan, errchan := forwardPort(ctx, kubecfg, namespace, pod, fmt.Sprintf("%d:%s", localPort, rootCmdOpts.K8sPodPort))
   174  	select {
   175  	case err := <-errchan:
   176  		cancel()
   177  		return nil, err
   178  	case <-readychan:
   179  	}
   180  
   181  	res, err := grpc.Dial(fmt.Sprintf("localhost:%d", localPort), grpc.WithInsecure())
   182  	if err != nil {
   183  		cancel()
   184  		return nil, fmt.Errorf("cannot dial forwarded connection: %w", err)
   185  	}
   186  
   187  	return closableConn{
   188  		ClientConnInterface: res,
   189  		Closer:              func() error { cancel(); return nil },
   190  	}, nil
   191  }
   192  
   193  type closableConn struct {
   194  	grpc.ClientConnInterface
   195  	Closer func() error
   196  }
   197  
   198  func (c closableConn) Close() error {
   199  	return c.Closer()
   200  }
   201  
   202  func findFreeLocalPort() (int, error) {
   203  	const (
   204  		start = 30000
   205  		end   = 60000
   206  	)
   207  	for p := start; p <= end; p++ {
   208  		l, err := net.Listen("tcp", fmt.Sprintf(":%d", p))
   209  		if err == nil {
   210  			l.Close()
   211  			return p, nil
   212  		}
   213  	}
   214  	return 0, fmt.Errorf("no free local port found")
   215  }
   216  
   217  // GetKubeconfig loads kubernetes connection config from a kubeconfig file
   218  func getKubeconfig(kubeconfig string) (res *rest.Config, namespace string, err error) {
   219  	cfg := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
   220  		&clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfig},
   221  		&clientcmd.ConfigOverrides{},
   222  	)
   223  	namespace, _, err = cfg.Namespace()
   224  	if err != nil {
   225  		return nil, "", err
   226  	}
   227  
   228  	res, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
   229  	if err != nil {
   230  		return nil, namespace, err
   231  	}
   232  
   233  	return res, namespace, nil
   234  }
   235  
   236  // findCachePod returns the first pod we found for a particular component
   237  func findCachePod(clientSet kubernetes.Interface, namespace, selector string) (podName string, err error) {
   238  	pods, err := clientSet.CoreV1().Pods(namespace).List(context.Background(), metav1.ListOptions{
   239  		LabelSelector: selector,
   240  	})
   241  	if err != nil {
   242  		return "", err
   243  	}
   244  	if len(pods.Items) == 0 {
   245  		return "", fmt.Errorf("no pod in %s with label component=%s", namespace, selector)
   246  	}
   247  	return pods.Items[0].Name, nil
   248  }
   249  
   250  // ForwardPort establishes a TCP port forwarding to a Kubernetes pod
   251  func forwardPort(ctx context.Context, config *rest.Config, namespace, pod, port string) (readychan chan struct{}, errchan chan error) {
   252  	errchan = make(chan error, 1)
   253  	readychan = make(chan struct{}, 1)
   254  
   255  	roundTripper, upgrader, err := spdy.RoundTripperFor(config)
   256  	if err != nil {
   257  		errchan <- err
   258  		return
   259  	}
   260  
   261  	path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/portforward", namespace, pod)
   262  	hostIP := strings.TrimLeft(config.Host, "https://")
   263  	serverURL := url.URL{Scheme: "https", Path: path, Host: hostIP}
   264  	dialer := spdy.NewDialer(upgrader, &http.Client{Transport: roundTripper}, http.MethodPost, &serverURL)
   265  
   266  	stopChan := make(chan struct{}, 1)
   267  	fwdReadyChan := make(chan struct{}, 1)
   268  	out, errOut := new(bytes.Buffer), new(bytes.Buffer)
   269  	forwarder, err := portforward.New(dialer, []string{port}, stopChan, fwdReadyChan, out, errOut)
   270  	if err != nil {
   271  		panic(err)
   272  	}
   273  
   274  	var once sync.Once
   275  	go func() {
   276  		err := forwarder.ForwardPorts()
   277  		if err != nil {
   278  			errchan <- err
   279  		}
   280  		once.Do(func() { close(readychan) })
   281  	}()
   282  
   283  	go func() {
   284  		select {
   285  		case <-readychan:
   286  			// we're out of here
   287  		case <-ctx.Done():
   288  			close(stopChan)
   289  		}
   290  	}()
   291  
   292  	go func() {
   293  		for range fwdReadyChan {
   294  		}
   295  
   296  		if errOut.Len() != 0 {
   297  			errchan <- fmt.Errorf(errOut.String())
   298  			return
   299  		}
   300  
   301  		once.Do(func() { close(readychan) })
   302  	}()
   303  
   304  	return
   305  }