github.com/tilt-dev/tilt@v0.36.0/internal/docker/env.go (about)

     1  package docker
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"os"
     7  	"sort"
     8  	"strings"
     9  
    10  	"github.com/blang/semver"
    11  	"github.com/docker/cli/cli/command"
    12  	cliflags "github.com/docker/cli/cli/flags"
    13  	"github.com/docker/cli/opts"
    14  	"github.com/docker/docker/client"
    15  	"github.com/pkg/errors"
    16  	"github.com/spf13/pflag"
    17  
    18  	"github.com/tilt-dev/clusterid"
    19  
    20  	"github.com/tilt-dev/tilt/internal/container"
    21  	"github.com/tilt-dev/tilt/internal/k8s"
    22  	"github.com/tilt-dev/tilt/pkg/logger"
    23  )
    24  
    25  // We didn't pick minikube v1.8.0 for any particular reason, it's just what Nick
    26  // had on his machine and could verify with. It's hard to tell from the upstream
    27  // issue when this was fixed.
    28  //
    29  // We can move it earlier if someone asks for it, though minikube is pretty good
    30  // about nudging people to upgrade.
    31  var minMinikubeVersionBuildkit = semver.MustParse("1.8.0")
    32  
    33  type DaemonClient interface {
    34  	DaemonHost() string
    35  }
    36  
    37  // See notes on CreateClientOpts. These environment variables are standard docker env configs.
    38  type Env struct {
    39  	// The Docker API client.
    40  	//
    41  	// The Docker API builders are very complex, with lots of different interfaces
    42  	// and concrete types, even though, underneath, they're all the same *client.Client.
    43  	// We use DaemonClient here because that's what we need to compare envs.
    44  	Client DaemonClient
    45  
    46  	// Environment variables to inject into any subshell that uses this client.
    47  	Environ []string
    48  
    49  	// Minikube's docker client has a bug where it can't use buildkit. See:
    50  	// https://github.com/kubernetes/minikube/issues/4143
    51  	IsOldMinikube bool
    52  
    53  	// Some Kubernetes contexts have a Docker daemon that they use directly
    54  	// as their container runtime. Any images built on that daemon will
    55  	// show up automatically in the runtime.
    56  	//
    57  	// We used to store this as a property of the k8s env but now store it as a field of the Docker Env, because this
    58  	// really affects how we interact with the Docker Env (rather than
    59  	// how we interact with the K8s Env).
    60  	//
    61  	// In theory, you can have more than one, but in practice,
    62  	// this is very difficult to set up.
    63  	BuildToKubeContexts []string
    64  
    65  	// If the env failed to load for some reason, propagate that error
    66  	// so that we can report it when the user tries to do a docker_build.
    67  	Error error
    68  }
    69  
    70  // Determines if this docker client can build images directly to the given cluster.
    71  func (e Env) WillBuildToKubeContext(kctx k8s.KubeContext) bool {
    72  	for _, current := range e.BuildToKubeContexts {
    73  		if string(kctx) == current {
    74  			return true
    75  		}
    76  	}
    77  	return false
    78  }
    79  
    80  // Serializes this back to environment variables for os.Environ
    81  func (e Env) AsEnviron() []string {
    82  	return append([]string{}, e.Environ...)
    83  }
    84  
    85  func (e Env) DaemonHost() string {
    86  	if e.Client == nil {
    87  		return ""
    88  	}
    89  	return e.Client.DaemonHost()
    90  }
    91  
    92  type ClientCreator interface {
    93  	FromCLI(ctx context.Context) (DaemonClient, error)
    94  	FromEnvMap(envMap map[string]string) (DaemonClient, error)
    95  }
    96  
    97  type RealClientCreator struct{}
    98  
    99  func (RealClientCreator) FromEnvMap(envMap map[string]string) (DaemonClient, error) {
   100  	opts, err := CreateClientOpts(envMap)
   101  	if err != nil {
   102  		return nil, fmt.Errorf("configuring docker client: %v", err)
   103  	}
   104  	return client.NewClientWithOpts(opts...)
   105  }
   106  
   107  func (RealClientCreator) FromCLI(ctx context.Context) (DaemonClient, error) {
   108  	cli, err := newDockerCli(ctx)
   109  	if err != nil {
   110  		return nil, err
   111  	}
   112  
   113  	client, ok := cli.Client().(*client.Client)
   114  	if !ok {
   115  		return nil, fmt.Errorf("unexpected docker client: %T", cli.Client())
   116  	}
   117  	return client, nil
   118  }
   119  
   120  // Creating a DockerCli is really the only way to get a DOCKER_CONTEXT-aware
   121  // docker client.
   122  func newDockerCli(ctx context.Context) (*command.DockerCli, error) {
   123  	out := logger.Get(ctx).Writer(logger.InfoLvl)
   124  	dockerCli, err := command.NewDockerCli(
   125  		command.WithCombinedStreams(out))
   126  	if err != nil {
   127  		return nil, fmt.Errorf("creating docker client: %v", err)
   128  	}
   129  
   130  	opts := cliflags.NewClientOptions()
   131  	flagSet := pflag.NewFlagSet("docker", pflag.ContinueOnError)
   132  	opts.InstallFlags(flagSet)
   133  	opts.SetDefaultOptions(flagSet)
   134  	err = dockerCli.Initialize(opts)
   135  	if err != nil {
   136  		return nil, fmt.Errorf("initializing docker client: %v", err)
   137  	}
   138  
   139  	// A hack to see if initialization failed.
   140  	// https://github.com/docker/cli/issues/4489
   141  	endpoint := dockerCli.DockerEndpoint()
   142  	if endpoint.Host == "" {
   143  		return nil, fmt.Errorf("initializing docker client: no valid endpoint")
   144  	}
   145  	return dockerCli, nil
   146  }
   147  
   148  // Tell wire to create two docker envs: one for the local CLI and one for the in-cluster CLI.
   149  type ClusterEnv Env
   150  type LocalEnv Env
   151  
   152  func ProvideLocalEnv(
   153  	ctx context.Context,
   154  	creator ClientCreator,
   155  	kubeContext k8s.KubeContext,
   156  	product clusterid.Product,
   157  	clusterEnv ClusterEnv,
   158  ) LocalEnv {
   159  	result := Env{}
   160  	client, err := creator.FromCLI(ctx)
   161  	result.Client = client
   162  	if err != nil {
   163  		result.Error = err
   164  	}
   165  
   166  	// if the ClusterEnv host is the same, use it to infer some properties
   167  	if Env(clusterEnv).DaemonHost() == result.DaemonHost() {
   168  		result.IsOldMinikube = clusterEnv.IsOldMinikube
   169  		result.BuildToKubeContexts = clusterEnv.BuildToKubeContexts
   170  		result.Environ = clusterEnv.Environ
   171  	}
   172  
   173  	// TODO(milas): I'm fairly certain we're adding the `docker-desktop`
   174  	//  kubecontext twice - the logic above should already have copied it
   175  	// 	from the cluster env (this is harmless though because
   176  	// 	Env::WillBuildToKubeContext still works fine)
   177  	if product == clusterid.ProductDockerDesktop && isDefaultHost(result) {
   178  		result.BuildToKubeContexts = append(result.BuildToKubeContexts, string(kubeContext))
   179  	}
   180  
   181  	return LocalEnv(result)
   182  }
   183  
   184  func ProvideClusterEnv(
   185  	ctx context.Context,
   186  	creator ClientCreator,
   187  	kubeContext k8s.KubeContext,
   188  	product clusterid.Product,
   189  	kClient k8s.Client,
   190  	minikubeClient k8s.MinikubeClient,
   191  ) ClusterEnv {
   192  	// start with an empty env, then populate with cluster-specific values if
   193  	// available, and then potentially throw that all away if there are OS env
   194  	// vars overriding those
   195  	//
   196  	// example: microk8s w/ Docker & no OS overrides -> use microk8s Docker socket
   197  	//
   198  	// example: minikube w/ Docker & DOCKER_HOST set via OS -> ignore result of
   199  	// 	`minikube docker-env` and use OS values
   200  	//
   201  	// from a user standpoint, the behavior is:
   202  	// 	- no values set at OS -> attempt to use cluster provided config
   203  	//		this happens if you've done no extra config after cluster setup
   204  	//	- values set at OS that differ from cluster config -> use OS values
   205  	//		this is probably not as common, but an advanced setup could use
   206  	//		e.g. a more powerful Docker host for builds but then run the images
   207  	// 		on the local cluster
   208  	// 	- values set at OS that match cluster config -> they're the same!
   209  	//		this happens if you've run `eval (minikube docker-env)` in your
   210  	// 		shell/profile so that you can use `docker` CLI with it too
   211  	env := Env{}
   212  
   213  	hostOverride := os.Getenv("DOCKER_HOST")
   214  	if hostOverride != "" {
   215  		var err error
   216  		hostOverride, err = opts.ParseHost(true, hostOverride)
   217  		if err != nil {
   218  			return ClusterEnv(Env{Error: errors.Wrap(err, "connecting to docker")})
   219  		}
   220  	}
   221  
   222  	if product == clusterid.ProductMinikube && kClient.ContainerRuntime(ctx) == container.RuntimeDocker {
   223  		// If we're running Minikube with a docker runtime, talk to Minikube's docker socket.
   224  		envMap, ok, err := minikubeClient.DockerEnv(ctx)
   225  		if err != nil {
   226  			return ClusterEnv{Error: err}
   227  		}
   228  
   229  		if ok {
   230  			d, err := creator.FromEnvMap(envMap)
   231  			if err != nil {
   232  				return ClusterEnv{Error: fmt.Errorf("connecting to minikube: %v", err)}
   233  			}
   234  
   235  			// Handle the case where people manually set DOCKER_HOST to minikube.
   236  			if hostOverride == "" || hostOverride == d.DaemonHost() {
   237  				env.IsOldMinikube = isOldMinikube(ctx, minikubeClient)
   238  				env.BuildToKubeContexts = append(env.BuildToKubeContexts, string(kubeContext))
   239  				env.Client = d
   240  				for k, v := range envMap {
   241  					env.Environ = append(env.Environ, fmt.Sprintf("%s=%s", k, v))
   242  				}
   243  				sort.Strings(env.Environ)
   244  			}
   245  
   246  		}
   247  	}
   248  
   249  	if env.Client == nil {
   250  		client, err := creator.FromCLI(ctx)
   251  		env.Client = client
   252  		if err != nil {
   253  			env.Error = err
   254  		}
   255  	}
   256  
   257  	// some local Docker-based solutions expose their socket so we can build
   258  	// direct to the K8s container runtime (eliminating the need for pushing
   259  	// images)
   260  	//
   261  	// currently, we handle this by inspecting the Docker + K8s configs to see
   262  	// if they're matched up, but we don't override the environmental Docker config
   263  	if willBuildToKubeContext(ctx, product, kubeContext, env) &&
   264  		kClient.ContainerRuntime(ctx) == container.RuntimeDocker {
   265  		env.BuildToKubeContexts = append(env.BuildToKubeContexts, string(kubeContext))
   266  	}
   267  
   268  	return ClusterEnv(env)
   269  }
   270  
   271  func isOldMinikube(ctx context.Context, minikubeClient k8s.MinikubeClient) bool {
   272  	v, err := minikubeClient.Version(ctx)
   273  	if err != nil {
   274  		logger.Get(ctx).Debugf("%v", err)
   275  		return false
   276  	}
   277  
   278  	vParsed, err := semver.ParseTolerant(v)
   279  	if err != nil {
   280  		logger.Get(ctx).Debugf("Parsing minikube version: %v", err)
   281  		return false
   282  	}
   283  
   284  	return minMinikubeVersionBuildkit.GTE(vParsed)
   285  }
   286  
   287  func isDefaultHost(e Env) bool {
   288  	host := e.DaemonHost()
   289  	isStandardHost :=
   290  		// Check all the "standard" docker localhosts.
   291  		host == "" ||
   292  
   293  			// https://github.com/docker/cli/blob/a32cd16160f1b41c1c4ae7bee4dac929d1484e59/opts/hosts.go#L22
   294  			host == "tcp://localhost:2375" ||
   295  			host == "tcp://localhost:2376" ||
   296  			host == "tcp://127.0.0.1:2375" ||
   297  			host == "tcp://127.0.0.1:2376" ||
   298  
   299  			// https://github.com/moby/moby/blob/master/client/client_windows.go#L4
   300  			host == "npipe:////./pipe/docker_engine" ||
   301  
   302  			// https://github.com/moby/moby/blob/master/client/client_unix.go#L6
   303  			host == "unix:///var/run/docker.sock" ||
   304  
   305  			// Docker Desktop for Linux - socket is in ~/.docker/desktop/docker.sock
   306  			(strings.HasPrefix(host, "unix://") && strings.HasSuffix(host, "/.docker/desktop/docker.sock")) ||
   307  
   308  			// Docker Desktop for Mac 4.13+ - socket is in ~/.docker/run/docker.sock
   309  			(strings.HasPrefix(host, "unix://") && strings.HasSuffix(host, "/.docker/run/docker.sock")) ||
   310  
   311  			// Docker Desktop for Windows 4.31+
   312  			strings.HasPrefix(host, "npipe:////./pipe/dockerDesktop") ||
   313  
   314  			// Rancher Desktop without admin access on Linux/Mac is in ~/.rd/docker.sock
   315  			(strings.HasPrefix(host, "unix://") && strings.HasSuffix(host, "/.rd/docker.sock"))
   316  
   317  	if isStandardHost {
   318  		return true
   319  	}
   320  
   321  	defaultParseHost, err := opts.ParseHost(true, "")
   322  	if err != nil {
   323  		return false
   324  	}
   325  
   326  	return host == defaultParseHost
   327  
   328  }
   329  
   330  func willBuildToKubeContext(ctx context.Context, product clusterid.Product, kubeContext k8s.KubeContext, env Env) bool {
   331  	switch product {
   332  	case clusterid.ProductDockerDesktop:
   333  		return isDefaultHost(env)
   334  	case clusterid.ProductRancherDesktop:
   335  		// N.B. Rancher Desktop creates a Docker socket at /var/run/docker.sock
   336  		// (the same as Docker Desktop)
   337  		return isDefaultHost(env)
   338  	case clusterid.ProductColima:
   339  		// Socket is stored in a directory named `.colima[-$profile]`
   340  		// For example:
   341  		//    colima default profile < 0.4 -> ~/.colima/docker.sock -> kubecontext colima
   342  		//    colima "test" profile < 0.4 -> ~/.colima-test/docker.sock -> kubecontext colima-test
   343  		//    colima default profile >= 0.4 -> ~/.colima/default/docker.sock -> kubecontext colima
   344  		//    colima "test" profile >= 0.4 -> ~/.colima/test/docker.sock -> kubecontext colima-test
   345  		//    colima linux profile >= 0.4 -> ~/.config/colima/default/docker.sock -> kubecontext colima
   346  		//    colima linux "test" profile >= 0.4 -> ~/.config/colima/test/docker.sock -> kubecontext colima-test
   347  		//
   348  		// NOTE: ~ is used for legibility here; in practice, the paths MUST
   349  		// be fully qualified!
   350  		//
   351  		// We look for the existence of the `/` in the path after the dir
   352  		// to prevent mismatching Colima profiles: e.g. a KubeContext of
   353  		// `colima-test` and `DOCKER_HOST=unix://~/.colima/docker.sock`
   354  		// should NOT be considered as building to the context, as these
   355  		// are two distinct Colima VMs/profiles. (This would almost always
   356  		// be indicative of user error, but we respect the Docker + K8s
   357  		// configs as provided to Tilt as-is. We emit a warning upon
   358  		// detecting a likely misconfiguration here.)
   359  		dockerColimaProfile := ""
   360  		parts := strings.Split(env.DaemonHost(), "/.colima")
   361  		if len(parts) == 1 {
   362  			parts = strings.Split(env.DaemonHost(), "/.config/colima")
   363  		}
   364  		if len(parts) >= 2 {
   365  			lastPart := parts[len(parts)-1]
   366  			if strings.HasPrefix(lastPart, "-") {
   367  				dockerColimaProfile = strings.Split(strings.TrimPrefix(lastPart, "-"), "/")[0]
   368  			} else {
   369  				moreParts := strings.Split(lastPart, "/")
   370  				if len(moreParts) < 3 {
   371  					dockerColimaProfile = "default"
   372  				} else {
   373  					dockerColimaProfile = moreParts[1]
   374  				}
   375  			}
   376  		}
   377  
   378  		if dockerColimaProfile == "" {
   379  			logger.Get(ctx).Warnf("connected to Kubernetes running on Colima, but building on a non-Colima Docker socket")
   380  			return false
   381  		}
   382  
   383  		kubeColimaProfile := ""
   384  		if string(kubeContext) == "colima" {
   385  			kubeColimaProfile = "default"
   386  		} else {
   387  			kubeColimaProfile = strings.TrimPrefix(string(kubeContext), "colima-")
   388  		}
   389  		if dockerColimaProfile != kubeColimaProfile {
   390  			logger.Get(ctx).Warnf("connected to Kubernetes on Colima profile %s, but building on Docker on Colima profile %s",
   391  				kubeColimaProfile, dockerColimaProfile)
   392  			return false
   393  		}
   394  		return true
   395  	case clusterid.ProductOrbstack:
   396  		// Orbstack docker socket is $HOME/.orbstack/run/docker.sock
   397  		host := env.DaemonHost()
   398  		if strings.HasPrefix(host, "unix://") && strings.HasSuffix(host, "/.orbstack/run/docker.sock") {
   399  			return true
   400  		}
   401  		logger.Get(ctx).Warnf("connected to Kubernetes running on Orbstack, but building on a non-Orbstack Docker socket")
   402  		return false
   403  	}
   404  	return false
   405  }