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 }