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

     1  package cli
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"strings"
     9  	"time"
    10  
    11  	"github.com/fatih/color"
    12  	"github.com/pkg/errors"
    13  	"github.com/spf13/cobra"
    14  
    15  	"github.com/tilt-dev/go-get"
    16  
    17  	"github.com/tilt-dev/tilt/internal/analytics"
    18  	"github.com/tilt-dev/tilt/internal/cli/demo"
    19  	"github.com/tilt-dev/tilt/pkg/logger"
    20  	"github.com/tilt-dev/tilt/pkg/model"
    21  )
    22  
    23  const demoResourcesPrefix = "tilt-demo-"
    24  const sampleProjPackage = "github.com/tilt-dev/tilt-avatars"
    25  
    26  type demoCmd struct {
    27  	// legacy disables the web UI (this is only used for integration tests)
    28  	legacy bool
    29  	// teardown will clean up any leftover `tilt demo` clusters and exit
    30  	teardown bool
    31  	// tmpdir for cloned `tilt-avatars` resources
    32  	tmpdir string
    33  	// skipCreateCluster uses default kubeconfig context instead of creating
    34  	// an ephemeral cluster
    35  	skipCreateCluster bool
    36  	// projPackage is the `go get` style URL for the demo project
    37  	projPackage string
    38  	// tiltfilePath is a path to a Tiltfile to launch instead of cloning and
    39  	// running the `tilt-avatars` project
    40  	tiltfilePath string
    41  }
    42  
    43  func (c *demoCmd) name() model.TiltSubcommand { return "demo" }
    44  
    45  func (c *demoCmd) register() *cobra.Command {
    46  	cmd := &cobra.Command{
    47  		Use:   "demo [flags]",
    48  		Short: "Creates a local, temporary Kubernetes cluster and runs a Tilt sample project",
    49  		Long: fmt.Sprintf(`Test out Tilt using an isolated, ephemeral local Kubernetes setup.
    50  
    51  Tilt will create a temporary, local Kubernetes development cluster running in Docker.
    52  The cluster will be removed when Tilt is exited with Ctrl-C.
    53  
    54  A sample project (%s) will be cloned locally to a temporary directory using Git and launched.
    55  `, sampleProjPackage),
    56  	}
    57  
    58  	cmd.Flags().BoolVarP(&c.teardown, "teardown", "", false,
    59  		"Removes any leftover tilt-demo Kubernetes clusters and exits")
    60  
    61  	// --legacy flag only exists for integration tests to disable web console
    62  	cmd.Flags().BoolVar(&c.legacy, "legacy", false, "If true, tilt will open in legacy HUD mode.")
    63  	cmd.Flags().Lookup("legacy").Hidden = true
    64  
    65  	// --tmpdir exists so that integration tests can inspect the output / use the Tiltfile
    66  	cmd.Flags().StringVarP(&c.tmpdir, "tmpdir", "", "",
    67  		"Temporary directory to clone sample project to")
    68  	cmd.Flags().Lookup("tmpdir").Hidden = true
    69  
    70  	cmd.Flags().BoolVar(&c.skipCreateCluster, "no-cluster", false,
    71  		"Skip ephemeral cluster creation (requires local K8s cluster to already be configured)")
    72  
    73  	cmd.Flags().StringVarP(&c.projPackage, "repo", "r", sampleProjPackage,
    74  		"Path to custom repo to use instead of Tiltfile")
    75  
    76  	// we don't use the `addTiltfileFlag()` because the default here should be empty
    77  	cmd.Flags().StringVarP(&c.tiltfilePath, "file", "f", "",
    78  		"Path to custom Tiltfile to use instead of sample project")
    79  
    80  	addStartServerFlags(cmd)
    81  	addDevServerFlags(cmd)
    82  
    83  	return cmd
    84  }
    85  
    86  func (c *demoCmd) run(ctx context.Context, args []string) error {
    87  	a := analytics.Get(ctx)
    88  	a.Incr("cmd.demo", map[string]string{})
    89  	defer a.Flush(time.Second)
    90  
    91  	client, err := wireDockerLocalClient(ctx)
    92  	if err != nil {
    93  		return errors.Wrap(err, "Failed to init Docker client")
    94  	}
    95  	k3dCli := demo.NewK3dClient(client)
    96  
    97  	if c.teardown {
    98  		return c.cleanupClusters(ctx, k3dCli)
    99  	}
   100  
   101  	if c.projPackage != sampleProjPackage && c.tiltfilePath != "" {
   102  		return fmt.Errorf("cannot specify both a custom repo and Tiltfile path")
   103  	}
   104  
   105  	//
   106  	// 0. Prepare environment
   107  	//
   108  	logger.Get(ctx).Infof("\nHang tight while Tilt prepares your demo environment!")
   109  	c.tmpdir, err = os.MkdirTemp(c.tmpdir, demoResourcesPrefix)
   110  	if err != nil {
   111  		return fmt.Errorf("could not create temporary directory: %v", err)
   112  	}
   113  
   114  	if !c.skipCreateCluster {
   115  		err = client.CheckConnected()
   116  		if err != nil {
   117  			return fmt.Errorf("tilt demo requires Docker to be installed and running: %v", err)
   118  		}
   119  		if !isLocalDockerHost(client.Env().DaemonHost()) {
   120  			// properly supporting remote Docker connections is very tricky - either:
   121  			//
   122  			// the remote host will need more ports accessible (for K8s API + registry API) and we have to ensure
   123  			// that everything both listens on the public interface and references it in configs
   124  			// (such as "local-registry-hosting" ConfigMap)
   125  			// 	OR
   126  			// we need to tunnel everything (perhaps using Docker - this is the approach ctlptl takes!)
   127  			//
   128  			// for now, it's not supported as it's a pretty advanced setup to begin with, so we're not really targeting
   129  			// it with the `tilt demo` functionality
   130  			return fmt.Errorf("tilt demo requires a local Docker daemon to create a temporary Kubernetes cluster (current Docker host: %s)", client.Env().DaemonHost())
   131  		}
   132  
   133  		//
   134  		// 1. Create a cluster that will be torn down in the background on exit (Ctrl-C)
   135  		//
   136  		clusterName := filepath.Base(c.tmpdir)
   137  		logger.Get(ctx).Infof("\tCreating %q local Kubernetes cluster...", clusterName)
   138  		if err := k3dCli.CreateCluster(ctx, clusterName); err != nil {
   139  			return fmt.Errorf("failed to create Kubernetes cluster: %v", err)
   140  		}
   141  		defer func() {
   142  			// N.B. use background context because the main context has already been canceled due to Ctrl-C
   143  			// 	but also don't block on execution (just fire request to Docker API and forget) because at this
   144  			// 	point we have < 2 secs before the signal handler forcibly exits the process
   145  			ctx := logger.WithLogger(context.Background(), logger.Get(ctx))
   146  			logger.Get(ctx).Infof("\nDeleting %q local Kubernetes cluster...", clusterName)
   147  			if err = k3dCli.DeleteCluster(ctx, clusterName, false); err != nil {
   148  				logger.Get(ctx).Warnf("\tFailed to delete cluster %q: %v", clusterName, err)
   149  			}
   150  		}()
   151  
   152  		//
   153  		// 2. Use the new cluster's kubeconfig for this Tilt process
   154  		//
   155  		if kubeconfig, err := k3dCli.GenerateKubeconfig(ctx, clusterName); err != nil {
   156  			return fmt.Errorf("failed to generate kubeconfig: %v", err)
   157  		} else {
   158  			kubeconfigPath := filepath.Join(c.tmpdir, "kubeconfig")
   159  			if err := os.WriteFile(kubeconfigPath, kubeconfig, 0666); err != nil {
   160  				return fmt.Errorf("failed to write kubeconfig file: %v", err)
   161  			}
   162  			err = os.Setenv("KUBECONFIG", kubeconfigPath)
   163  			if err != nil {
   164  				return fmt.Errorf("failed to set KUBECONFIG env var: %v", err)
   165  			}
   166  		}
   167  	}
   168  
   169  	//
   170  	// 3. Download the sample project to a tmpdir
   171  	//
   172  	var projPath string
   173  	if c.tiltfilePath == "" {
   174  		logger.Get(ctx).Infof("\tFetching %q project...", c.projPackage)
   175  		dlr := get.NewDownloader(c.tmpdir)
   176  		projPath, err = dlr.Download(c.projPackage)
   177  		if err != nil {
   178  			return fmt.Errorf("failed to download sample project: %v", err)
   179  		}
   180  		c.tiltfilePath = filepath.Join(projPath, "Tiltfile")
   181  	}
   182  
   183  	logger.Get(ctx).Infof("\tDone!")
   184  	if projPath != "" {
   185  		logger.Get(ctx).Infof(
   186  			`
   187  -----------------------------------------------------
   188  Open the project directory in your preferred editor:
   189    %s
   190  -----------------------------------------------------
   191  `, color.BlueString("%s", projPath))
   192  	}
   193  
   194  	//
   195  	// 4. Launch the `tilt up` command with the sample project
   196  	// 		(it will implicitly use our kubeconfig)
   197  	//
   198  	up := upCmd{
   199  		fileName: c.tiltfilePath,
   200  		legacy:   c.legacy,
   201  		stream:   false,
   202  	}
   203  	return up.run(ctx, args)
   204  }
   205  
   206  func (c *demoCmd) cleanupClusters(ctx context.Context, k3dCli *demo.K3dClient) error {
   207  	clusterNames, err := k3dCli.ListClusters(ctx)
   208  	if err != nil {
   209  		return err
   210  	}
   211  
   212  	failed := false
   213  	for _, clusterName := range clusterNames {
   214  		if strings.HasPrefix(clusterName, demoResourcesPrefix) {
   215  			logger.Get(ctx).Infof("Removing cluster %q...", clusterName)
   216  			if err := k3dCli.DeleteCluster(ctx, clusterName, true); err != nil {
   217  				failed = true
   218  				logger.Get(ctx).Errorf("Failed to remove %q cluster: %v", clusterName, err)
   219  			}
   220  		}
   221  	}
   222  	if failed {
   223  		return errors.New("could not remove one or more tilt-demo K8s clusters")
   224  	}
   225  	return nil
   226  }
   227  
   228  // TODO(milas): this is copy-pasted from ctlptl, use it from a common place
   229  func isLocalDockerHost(dockerHost string) bool {
   230  	return dockerHost == "" ||
   231  
   232  		// Check all the "standard" docker localhosts.
   233  		// https://github.com/docker/cli/blob/a32cd16160f1b41c1c4ae7bee4dac929d1484e59/opts/hosts.go#L22
   234  		strings.HasPrefix(dockerHost, "tcp://localhost:") ||
   235  		strings.HasPrefix(dockerHost, "tcp://127.0.0.1:") ||
   236  
   237  		// https://github.com/moby/moby/blob/master/client/client_windows.go#L4
   238  		strings.HasPrefix(dockerHost, "npipe:") ||
   239  
   240  		// https://github.com/moby/moby/blob/master/client/client_unix.go#L6
   241  		strings.HasPrefix(dockerHost, "unix:")
   242  }