github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/cli/shell.go (about)

     1  package cli
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"os/exec"
    10  	"syscall"
    11  
    12  	"github.com/spf13/cobra"
    13  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    14  	"k8s.io/apimachinery/pkg/types"
    15  	"k8s.io/cli-runtime/pkg/genericclioptions"
    16  	"sigs.k8s.io/controller-runtime/pkg/client"
    17  
    18  	"github.com/tilt-dev/tilt/internal/container"
    19  	"github.com/tilt-dev/tilt/internal/controllers/core/kubernetesdiscovery"
    20  	"github.com/tilt-dev/tilt/internal/k8s"
    21  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    22  	"github.com/tilt-dev/tilt/pkg/logger"
    23  	"github.com/tilt-dev/tilt/pkg/model"
    24  )
    25  
    26  type shellCmd struct {
    27  	streams   genericclioptions.IOStreams
    28  	container string
    29  	execer    ShellExecer
    30  }
    31  
    32  func newShellCmd(streams genericclioptions.IOStreams) *shellCmd {
    33  	return &shellCmd{
    34  		streams: streams,
    35  		execer:  realShellExecer{},
    36  	}
    37  }
    38  
    39  func (c *shellCmd) name() model.TiltSubcommand { return "shell" }
    40  
    41  func (c *shellCmd) register() *cobra.Command {
    42  	cmd := &cobra.Command{
    43  		Use:                   "shell [<resource-name>]",
    44  		DisableFlagsInUseLine: true,
    45  		Short:                 "Opens a shell into a container running in Tilt",
    46  		Long: `Opens a shell into a container running in Tilt.
    47  
    48  Given a resource name, finds the Kubernetes Pod in that resource,
    49  and opens an interactive shell.
    50  
    51  By default, in order, we'll try:
    52  - kubectl shell
    53  - kubectl exec -it <pod> -- bash
    54  - kubectl exec -it <pod> -- sh
    55  
    56  Currently only works on MacOS and Linux.`,
    57  		Args: cobra.ExactArgs(1),
    58  	}
    59  
    60  	addConnectServerFlags(cmd)
    61  	cmd.Flags().StringVarP(&c.container, "container", "c", "",
    62  		"Name of the container within the pod. Only required if there is more than 1 container.")
    63  
    64  	return cmd
    65  }
    66  
    67  func (c *shellCmd) run(ctx context.Context, args []string) error {
    68  	ctx = logger.WithLogger(ctx, logger.NewLogger(logger.Get(ctx).Level(), c.streams.ErrOut))
    69  
    70  	ctrlclient, err := newClient(ctx)
    71  	if err != nil {
    72  		return err
    73  	}
    74  
    75  	resourceName := args[0]
    76  	var uiResource v1alpha1.UIResource
    77  	err = ctrlclient.Get(ctx, types.NamespacedName{Name: resourceName}, &uiResource)
    78  	if err != nil {
    79  		if apierrors.IsNotFound(err) {
    80  			return fmt.Errorf("resource %s not found. To see available resources, run:\ntilt get uiresources", resourceName)
    81  		}
    82  		return fmt.Errorf("looking up resource %s: %v", resourceName, err)
    83  	}
    84  
    85  	// TODO(nicks): Add Docker Compose support.
    86  	if uiResource.Status.K8sResourceInfo == nil {
    87  		return fmt.Errorf("resource %s is not a Kubernetes resource. Only Kubernetes pods currently supported",
    88  			resourceName)
    89  	}
    90  
    91  	return c.runK8s(ctx, ctrlclient, resourceName)
    92  }
    93  
    94  func (c *shellCmd) runK8s(ctx context.Context, ctrlclient client.Client, resourceName string) error {
    95  	// TODO(nicks): Wait for the pod to become ready?
    96  	var k8sDisco v1alpha1.KubernetesDiscovery
    97  	err := ctrlclient.Get(ctx, types.NamespacedName{Name: resourceName}, &k8sDisco)
    98  	if err != nil {
    99  		return fmt.Errorf("looking up kubernetes status %s: %v", resourceName, err)
   100  	}
   101  
   102  	pod := kubernetesdiscovery.PickBestPortForwardPod(&k8sDisco)
   103  	if pod == nil {
   104  		return fmt.Errorf("no pod found for resource %s", resourceName)
   105  	}
   106  
   107  	co, err := c.selectContainer(pod)
   108  	if err != nil {
   109  		return err
   110  	}
   111  
   112  	kubectlBinary, err := c.execer.LookPath("kubectl")
   113  	if err != nil || kubectlBinary == "" {
   114  		return fmt.Errorf("could not find kubectl: %v", err)
   115  	}
   116  
   117  	// Fetch the kubeconfig that tilt manages, rather than using the kubeconfig of the current shell.
   118  	var cluster v1alpha1.Cluster
   119  	err = ctrlclient.Get(ctx, types.NamespacedName{Name: "default"}, &cluster)
   120  	if err != nil {
   121  		return fmt.Errorf("looking up cluster: %v", err)
   122  	}
   123  	if cluster.Status.Connection == nil || cluster.Status.Connection.Kubernetes == nil {
   124  		return fmt.Errorf("kubernetes cluster not connected: %v", cluster.Status.Error)
   125  	}
   126  
   127  	env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", cluster.Status.Connection.Kubernetes.ConfigPath))
   128  
   129  	hasKubectlShell, err := c.execer.LookPath("kubectl-shell")
   130  	if err == nil && hasKubectlShell != "" {
   131  		cmd := model.Cmd{Argv: []string{"kubectl", "shell", pod.Name, "-n", pod.Namespace, "-c", co.Name}}
   132  		logger.Get(ctx).Infof("Running: %v", cmd)
   133  		return c.execer.Exec(kubectlBinary, cmd.Argv, env)
   134  	}
   135  
   136  	k8sClient, err := wireK8sClient(ctx)
   137  	if err != nil {
   138  		return fmt.Errorf("initializing k8s client: %v", err)
   139  	}
   140  
   141  	err = k8sClient.Exec(ctx, k8s.PodID(pod.Name), container.Name(co.Name), k8s.Namespace(pod.Namespace),
   142  		[]string{"which", "bash"}, &bytes.Buffer{}, io.Discard, io.Discard)
   143  	if err == nil {
   144  		cmd := model.Cmd{Argv: []string{"kubectl", "exec", "-it", pod.Name, "-n", pod.Namespace, "-c", co.Name, "--", "bash"}}
   145  		logger.Get(ctx).Infof("Running: %v", cmd)
   146  		return c.execer.Exec(kubectlBinary, cmd.Argv, env)
   147  	}
   148  
   149  	err = k8sClient.Exec(ctx, k8s.PodID(pod.Name), container.Name(co.Name), k8s.Namespace(pod.Namespace),
   150  		[]string{"which", "sh"}, &bytes.Buffer{}, io.Discard, io.Discard)
   151  	if err == nil {
   152  		cmd := model.Cmd{Argv: []string{"kubectl", "exec", "-it", pod.Name, "-n", pod.Namespace, "-c", co.Name, "--", "sh"}}
   153  		logger.Get(ctx).Infof("Running: %v", cmd)
   154  		return c.execer.Exec(kubectlBinary, cmd.Argv, env)
   155  	}
   156  
   157  	return fmt.Errorf(`could not find bash or sh in container image: %s
   158  
   159  We're working on a new debugging tool for dynamically installing
   160  a shell in a minimal container image.
   161  
   162  https://hub.docker.com/extensions/docker/labs-k8s-toolkit-extension
   163  
   164  To try it out, install the Docker Desktop extension, and run 'tilt shell' again.`, co.Image)
   165  }
   166  
   167  func (c *shellCmd) selectContainer(pod *v1alpha1.Pod) (v1alpha1.Container, error) {
   168  	if len(pod.InitContainers) == 0 && len(pod.Containers) == 1 {
   169  		if c.container != "" && pod.Containers[0].Name != c.container {
   170  			return v1alpha1.Container{}, fmt.Errorf("container %s not found in pod %s", c.container, pod.Name)
   171  		}
   172  		return pod.Containers[0], nil
   173  	}
   174  
   175  	for _, co := range pod.InitContainers {
   176  		if co.Name == c.container {
   177  			return co, nil
   178  		}
   179  	}
   180  	for _, co := range pod.Containers {
   181  		if co.Name == c.container {
   182  			return co, nil
   183  		}
   184  	}
   185  	return v1alpha1.Container{}, fmt.Errorf("container %s not found in pod %s", c.container, pod.Name)
   186  }
   187  
   188  type ShellExecer interface {
   189  	// Checks if the given binary exists in the PATH.
   190  	LookPath(file string) (string, error)
   191  
   192  	// Replaces the current process with the given binary.
   193  	Exec(binary string, argv []string, env []string) error
   194  }
   195  
   196  type realShellExecer struct{}
   197  
   198  func (r realShellExecer) LookPath(file string) (string, error) {
   199  	return exec.LookPath(file)
   200  }
   201  func (r realShellExecer) Exec(binary string, argv []string, env []string) error {
   202  	return syscall.Exec(binary, argv, env)
   203  }