github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/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 product == clusterid.ProductMicroK8s && kClient.ContainerRuntime(ctx) == container.RuntimeDocker {
   250  		// If we're running Microk8s with a docker runtime, talk to Microk8s's docker socket.
   251  		d, err := creator.FromEnvMap(map[string]string{"DOCKER_HOST": microK8sDockerHost})
   252  		if err != nil {
   253  			return ClusterEnv{Error: fmt.Errorf("connecting to microk8s: %v", err)}
   254  		}
   255  
   256  		// Handle the case where people manually set DOCKER_HOST to microk8s.
   257  		if hostOverride == "" || hostOverride == d.DaemonHost() {
   258  			env.Client = d
   259  			env.Environ = append(env.Environ, fmt.Sprintf("DOCKER_HOST=%s", microK8sDockerHost))
   260  			env.BuildToKubeContexts = append(env.BuildToKubeContexts, string(kubeContext))
   261  		}
   262  	}
   263  
   264  	if env.Client == nil {
   265  		client, err := creator.FromCLI(ctx)
   266  		env.Client = client
   267  		if err != nil {
   268  			env.Error = err
   269  		}
   270  	}
   271  
   272  	// some local Docker-based solutions expose their socket so we can build
   273  	// direct to the K8s container runtime (eliminating the need for pushing
   274  	// images)
   275  	//
   276  	// currently, we handle this by inspecting the Docker + K8s configs to see
   277  	// if they're matched up, but with the exception of microk8s (handled above),
   278  	// we don't override the environmental Docker config
   279  	if willBuildToKubeContext(ctx, product, kubeContext, env) &&
   280  		kClient.ContainerRuntime(ctx) == container.RuntimeDocker {
   281  		env.BuildToKubeContexts = append(env.BuildToKubeContexts, string(kubeContext))
   282  	}
   283  
   284  	return ClusterEnv(env)
   285  }
   286  
   287  func isOldMinikube(ctx context.Context, minikubeClient k8s.MinikubeClient) bool {
   288  	v, err := minikubeClient.Version(ctx)
   289  	if err != nil {
   290  		logger.Get(ctx).Debugf("%v", err)
   291  		return false
   292  	}
   293  
   294  	vParsed, err := semver.ParseTolerant(v)
   295  	if err != nil {
   296  		logger.Get(ctx).Debugf("Parsing minikube version: %v", err)
   297  		return false
   298  	}
   299  
   300  	return minMinikubeVersionBuildkit.GTE(vParsed)
   301  }
   302  
   303  func isDefaultHost(e Env) bool {
   304  	host := e.DaemonHost()
   305  	isStandardHost :=
   306  		// Check all the "standard" docker localhosts.
   307  		host == "" ||
   308  
   309  			// https://github.com/docker/cli/blob/a32cd16160f1b41c1c4ae7bee4dac929d1484e59/opts/hosts.go#L22
   310  			host == "tcp://localhost:2375" ||
   311  			host == "tcp://localhost:2376" ||
   312  			host == "tcp://127.0.0.1:2375" ||
   313  			host == "tcp://127.0.0.1:2376" ||
   314  
   315  			// https://github.com/moby/moby/blob/master/client/client_windows.go#L4
   316  			host == "npipe:////./pipe/docker_engine" ||
   317  
   318  			// https://github.com/moby/moby/blob/master/client/client_unix.go#L6
   319  			host == "unix:///var/run/docker.sock" ||
   320  
   321  			// Docker Desktop for Linux - socket is in ~/.docker/desktop/docker.sock
   322  			(strings.HasPrefix(host, "unix://") && strings.HasSuffix(host, "/.docker/desktop/docker.sock")) ||
   323  
   324  			// Docker Desktop for Mac 4.13+ - socket is in ~/.docker/run/docker.sock
   325  			(strings.HasPrefix(host, "unix://") && strings.HasSuffix(host, "/.docker/run/docker.sock")) ||
   326  
   327  			// Rancher Desktop without admin access on Linux/Mac is in ~/.rd/docker.sock
   328  			(strings.HasPrefix(host, "unix://") && strings.HasSuffix(host, "/.rd/docker.sock"))
   329  
   330  	if isStandardHost {
   331  		return true
   332  	}
   333  
   334  	defaultParseHost, err := opts.ParseHost(true, "")
   335  	if err != nil {
   336  		return false
   337  	}
   338  
   339  	return host == defaultParseHost
   340  
   341  }
   342  
   343  func willBuildToKubeContext(ctx context.Context, product clusterid.Product, kubeContext k8s.KubeContext, env Env) bool {
   344  	switch product {
   345  	case clusterid.ProductDockerDesktop:
   346  		return isDefaultHost(env)
   347  	case clusterid.ProductRancherDesktop:
   348  		// N.B. Rancher Desktop creates a Docker socket at /var/run/docker.sock
   349  		// (the same as Docker Desktop)
   350  		return isDefaultHost(env)
   351  	case clusterid.ProductColima:
   352  		// Socket is stored in a directory named `.colima[-$profile]`
   353  		// For example:
   354  		//    colima default profile < 0.4 -> ~/.colima/docker.sock -> kubecontext colima
   355  		//    colima "test" profile < 0.4 -> ~/.colima-test/docker.sock -> kubecontext colima-test
   356  		//    colima default profile >= 0.4 -> ~/.colima/default/docker.sock -> kubecontext colima
   357  		//    colima "test" profile >= 0.4 -> ~/.colima/test/docker.sock -> kubecontext colima-test
   358  		//    colima linux profile >= 0.4 -> ~/.config/colima/default/docker.sock -> kubecontext colima
   359  		//    colima linux "test" profile >= 0.4 -> ~/.config/colima/test/docker.sock -> kubecontext colima-test
   360  		//
   361  		// NOTE: ~ is used for legibility here; in practice, the paths MUST
   362  		// be fully qualified!
   363  		//
   364  		// We look for the existence of the `/` in the path after the dir
   365  		// to prevent mismatching Colima profiles: e.g. a KubeContext of
   366  		// `colima-test` and `DOCKER_HOST=unix://~/.colima/docker.sock`
   367  		// should NOT be considered as building to the context, as these
   368  		// are two distinct Colima VMs/profiles. (This would almost always
   369  		// be indicative of user error, but we respect the Docker + K8s
   370  		// configs as provided to Tilt as-is. We emit a warning upon
   371  		// detecting a likely misconfiguration here.)
   372  		dockerColimaProfile := ""
   373  		parts := strings.Split(env.DaemonHost(), "/.colima")
   374  		if len(parts) == 1 {
   375  			parts = strings.Split(env.DaemonHost(), "/.config/colima")
   376  		}
   377  		if len(parts) >= 2 {
   378  			lastPart := parts[len(parts)-1]
   379  			if strings.HasPrefix(lastPart, "-") {
   380  				dockerColimaProfile = strings.Split(strings.TrimPrefix(lastPart, "-"), "/")[0]
   381  			} else {
   382  				moreParts := strings.Split(lastPart, "/")
   383  				if len(moreParts) < 3 {
   384  					dockerColimaProfile = "default"
   385  				} else {
   386  					dockerColimaProfile = moreParts[1]
   387  				}
   388  			}
   389  		}
   390  
   391  		if dockerColimaProfile == "" {
   392  			logger.Get(ctx).Warnf("connected to Kubernetes running on Colima, but building on a non-Colima Docker socket")
   393  			return false
   394  		}
   395  
   396  		kubeColimaProfile := ""
   397  		if string(kubeContext) == "colima" {
   398  			kubeColimaProfile = "default"
   399  		} else {
   400  			kubeColimaProfile = strings.TrimPrefix(string(kubeContext), "colima-")
   401  		}
   402  		if dockerColimaProfile != kubeColimaProfile {
   403  			logger.Get(ctx).Warnf("connected to Kubernetes on Colima profile %s, but building on Docker on Colima profile %s",
   404  				kubeColimaProfile, dockerColimaProfile)
   405  			return false
   406  		}
   407  		return true
   408  	case clusterid.ProductOrbstack:
   409  		// Orbstack docker socket is $HOME/.orbstack/run/docker.sock
   410  		host := env.DaemonHost()
   411  		if strings.HasPrefix(host, "unix://") && strings.HasSuffix(host, "/.orbstack/run/docker.sock") {
   412  			return true
   413  		}
   414  		logger.Get(ctx).Warnf("connected to Kubernetes running on Orbstack, but building on a non-Orbstack Docker socket")
   415  		return false
   416  	}
   417  	return false
   418  }