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 }