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 }