github.com/supabase/cli@v1.168.1/internal/utils/docker.go (about) 1 package utils 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/base64" 7 "encoding/json" 8 "fmt" 9 "io" 10 "log" 11 "os" 12 "regexp" 13 "strings" 14 "sync" 15 "time" 16 17 podman "github.com/containers/common/libnetwork/types" 18 "github.com/docker/cli/cli/command" 19 "github.com/docker/cli/cli/compose/loader" 20 dockerConfig "github.com/docker/cli/cli/config" 21 dockerFlags "github.com/docker/cli/cli/flags" 22 "github.com/docker/cli/cli/streams" 23 "github.com/docker/docker/api/types" 24 "github.com/docker/docker/api/types/container" 25 "github.com/docker/docker/api/types/filters" 26 "github.com/docker/docker/api/types/image" 27 "github.com/docker/docker/api/types/mount" 28 "github.com/docker/docker/api/types/network" 29 "github.com/docker/docker/api/types/volume" 30 "github.com/docker/docker/client" 31 "github.com/docker/docker/errdefs" 32 "github.com/docker/docker/pkg/jsonmessage" 33 "github.com/docker/docker/pkg/stdcopy" 34 "github.com/go-errors/errors" 35 "github.com/spf13/viper" 36 ) 37 38 var Docker = NewDocker() 39 40 func NewDocker() *client.Client { 41 // TODO: refactor to initialize lazily 42 cli, err := command.NewDockerCli() 43 if err != nil { 44 log.Fatalln("Failed to create Docker client:", err) 45 } 46 if err := cli.Initialize(&dockerFlags.ClientOptions{}); err != nil { 47 log.Fatalln("Failed to initialize Docker client:", err) 48 } 49 return cli.Client().(*client.Client) 50 } 51 52 const ( 53 CliProjectLabel = "com.supabase.cli.project" 54 composeProjectLabel = "com.docker.compose.project" 55 ) 56 57 func DockerNetworkCreateIfNotExists(ctx context.Context, networkId string) error { 58 _, err := Docker.NetworkCreate( 59 ctx, 60 networkId, 61 types.NetworkCreate{ 62 CheckDuplicate: true, 63 Labels: map[string]string{ 64 CliProjectLabel: Config.ProjectId, 65 composeProjectLabel: Config.ProjectId, 66 }, 67 }, 68 ) 69 // if error is network already exists, no need to propagate to user 70 if errdefs.IsConflict(err) || errors.Is(err, podman.ErrNetworkExists) { 71 return nil 72 } 73 if err != nil { 74 return errors.Errorf("failed to create docker network: %w", err) 75 } 76 return err 77 } 78 79 func WaitAll[T any](containers []T, exec func(container T) error) []error { 80 var wg sync.WaitGroup 81 result := make([]error, len(containers)) 82 for i, container := range containers { 83 wg.Add(1) 84 go func(i int, container T) { 85 defer wg.Done() 86 result[i] = exec(container) 87 }(i, container) 88 } 89 wg.Wait() 90 return result 91 } 92 93 // NoBackupVolume TODO: encapsulate this state in a class 94 var NoBackupVolume = false 95 96 func DockerRemoveAll(ctx context.Context, w io.Writer) error { 97 args := CliProjectFilter() 98 containers, err := Docker.ContainerList(ctx, container.ListOptions{ 99 All: true, 100 Filters: args, 101 }) 102 if err != nil { 103 return errors.Errorf("failed to list containers: %w", err) 104 } 105 // Gracefully shutdown containers 106 var ids []string 107 for _, c := range containers { 108 if c.State == "running" { 109 ids = append(ids, c.ID) 110 } 111 } 112 fmt.Fprintln(w, "Stopping containers...") 113 result := WaitAll(ids, func(id string) error { 114 if err := Docker.ContainerStop(ctx, id, container.StopOptions{}); err != nil { 115 return errors.Errorf("failed to stop container: %w", err) 116 } 117 return nil 118 }) 119 if err := errors.Join(result...); err != nil { 120 return err 121 } 122 if report, err := Docker.ContainersPrune(ctx, args); err != nil { 123 return errors.Errorf("failed to prune containers: %w", err) 124 } else if viper.GetBool("DEBUG") { 125 fmt.Fprintln(os.Stderr, "Pruned containers:", report.ContainersDeleted) 126 } 127 // Remove named volumes 128 if NoBackupVolume { 129 // Since docker engine 25.0.3, all flag is required to include named volumes. 130 // https://github.com/docker/cli/blob/master/cli/command/volume/prune.go#L76 131 vargs := args.Clone() 132 vargs.Add("all", "true") 133 if report, err := Docker.VolumesPrune(ctx, vargs); err != nil { 134 return errors.Errorf("failed to prune volumes: %w", err) 135 } else if viper.GetBool("DEBUG") { 136 fmt.Fprintln(os.Stderr, "Pruned volumes:", report.VolumesDeleted) 137 } 138 } 139 // Remove networks. 140 if report, err := Docker.NetworksPrune(ctx, args); err != nil { 141 return errors.Errorf("failed to prune networks: %w", err) 142 } else if viper.GetBool("DEBUG") { 143 fmt.Fprintln(os.Stderr, "Pruned network:", report.NetworksDeleted) 144 } 145 return nil 146 } 147 148 func CliProjectFilter() filters.Args { 149 return filters.NewArgs( 150 filters.Arg("label", CliProjectLabel+"="+Config.ProjectId), 151 ) 152 } 153 154 var ( 155 // Only supports one registry per command invocation 156 registryAuth string 157 registryOnce sync.Once 158 ) 159 160 func GetRegistryAuth() string { 161 registryOnce.Do(func() { 162 config := dockerConfig.LoadDefaultConfigFile(os.Stderr) 163 // Ref: https://docs.docker.com/engine/api/sdk/examples/#pull-an-image-with-authentication 164 auth, err := config.GetAuthConfig(GetRegistry()) 165 if err != nil { 166 fmt.Fprintln(os.Stderr, "Failed to load registry credentials:", err) 167 return 168 } 169 encoded, err := json.Marshal(auth) 170 if err != nil { 171 fmt.Fprintln(os.Stderr, "Failed to serialise auth config:", err) 172 return 173 } 174 registryAuth = base64.URLEncoding.EncodeToString(encoded) 175 }) 176 return registryAuth 177 } 178 179 // Defaults to Supabase public ECR for faster image pull 180 const defaultRegistry = "public.ecr.aws" 181 182 func GetRegistry() string { 183 registry := viper.GetString("INTERNAL_IMAGE_REGISTRY") 184 if len(registry) == 0 { 185 return defaultRegistry 186 } 187 return strings.ToLower(registry) 188 } 189 190 func GetRegistryImageUrl(imageName string) string { 191 registry := GetRegistry() 192 if registry == "docker.io" { 193 return imageName 194 } 195 // Configure mirror registry 196 parts := strings.Split(imageName, "/") 197 imageName = parts[len(parts)-1] 198 return registry + "/supabase/" + imageName 199 } 200 201 func DockerImagePull(ctx context.Context, imageTag string, w io.Writer) error { 202 out, err := Docker.ImagePull(ctx, imageTag, image.PullOptions{ 203 RegistryAuth: GetRegistryAuth(), 204 }) 205 if err != nil { 206 return errors.Errorf("failed to pull docker image: %w", err) 207 } 208 defer out.Close() 209 if err := jsonmessage.DisplayJSONMessagesToStream(out, streams.NewOut(w), nil); err != nil { 210 return errors.Errorf("failed to display json stream: %w", err) 211 } 212 return nil 213 } 214 215 // Used by unit tests 216 var timeUnit = time.Second 217 218 func DockerImagePullWithRetry(ctx context.Context, image string, retries int) error { 219 err := DockerImagePull(ctx, image, os.Stderr) 220 for i := 0; i < retries; i++ { 221 if err == nil || errors.Is(ctx.Err(), context.Canceled) { 222 break 223 } 224 fmt.Fprintln(os.Stderr, err) 225 period := time.Duration(2<<(i+1)) * timeUnit 226 fmt.Fprintf(os.Stderr, "Retrying after %v: %s\n", period, image) 227 time.Sleep(period) 228 err = DockerImagePull(ctx, image, os.Stderr) 229 } 230 return err 231 } 232 233 func DockerPullImageIfNotCached(ctx context.Context, imageName string) error { 234 imageUrl := GetRegistryImageUrl(imageName) 235 if _, _, err := Docker.ImageInspectWithRaw(ctx, imageUrl); err == nil { 236 return nil 237 } else if !client.IsErrNotFound(err) { 238 return errors.Errorf("failed to inspect docker image: %w", err) 239 } 240 return DockerImagePullWithRetry(ctx, imageUrl, 2) 241 } 242 243 var suggestDockerInstall = "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop" 244 245 func DockerStart(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string) (string, error) { 246 // Pull container image 247 if err := DockerPullImageIfNotCached(ctx, config.Image); err != nil { 248 if client.IsErrConnectionFailed(err) { 249 CmdSuggestion = suggestDockerInstall 250 } 251 return "", err 252 } 253 // Setup default config 254 config.Image = GetRegistryImageUrl(config.Image) 255 if config.Labels == nil { 256 config.Labels = map[string]string{} 257 } 258 config.Labels[CliProjectLabel] = Config.ProjectId 259 config.Labels[composeProjectLabel] = Config.ProjectId 260 if len(hostConfig.NetworkMode) == 0 { 261 hostConfig.NetworkMode = container.NetworkMode(NetId) 262 } 263 // Create network with name 264 if hostConfig.NetworkMode.IsUserDefined() && hostConfig.NetworkMode.UserDefined() != "host" { 265 if err := DockerNetworkCreateIfNotExists(ctx, hostConfig.NetworkMode.NetworkName()); err != nil { 266 return "", err 267 } 268 } 269 var binds, sources []string 270 for _, bind := range hostConfig.Binds { 271 spec, err := loader.ParseVolume(bind) 272 if err != nil { 273 return "", errors.Errorf("failed to parse docker volume: %w", err) 274 } 275 if spec.Type != string(mount.TypeVolume) { 276 binds = append(binds, bind) 277 } else if len(spec.Source) > 0 { 278 sources = append(sources, spec.Source) 279 } 280 } 281 // Skip named volume for BitBucket pipeline 282 if os.Getenv("BITBUCKET_CLONE_DIR") != "" { 283 hostConfig.Binds = binds 284 } else { 285 // Create named volumes with labels 286 for _, name := range sources { 287 if _, err := Docker.VolumeCreate(ctx, volume.CreateOptions{ 288 Name: name, 289 Labels: config.Labels, 290 }); err != nil { 291 return "", errors.Errorf("failed to create volume: %w", err) 292 } 293 } 294 } 295 // Create container from image 296 resp, err := Docker.ContainerCreate(ctx, &config, &hostConfig, &networkingConfig, nil, containerName) 297 if err != nil { 298 return "", errors.Errorf("failed to create docker container: %w", err) 299 } 300 // Run container in background 301 err = Docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 302 if err != nil { 303 if hostPort := parsePortBindError(err); len(hostPort) > 0 { 304 CmdSuggestion = suggestDockerStop(ctx, hostPort) 305 prefix := "Or configure" 306 if len(CmdSuggestion) == 0 { 307 prefix = "Try configuring" 308 } 309 name := containerName 310 if endpoint, ok := networkingConfig.EndpointsConfig[NetId]; ok && len(endpoint.Aliases) > 0 { 311 name = endpoint.Aliases[0] 312 } 313 CmdSuggestion += fmt.Sprintf("\n%s a different %s port in %s", prefix, name, Bold(ConfigPath)) 314 } 315 err = errors.Errorf("failed to start docker container: %w", err) 316 } 317 return resp.ID, err 318 } 319 320 func DockerRemove(containerId string) { 321 if err := Docker.ContainerRemove(context.Background(), containerId, container.RemoveOptions{ 322 RemoveVolumes: true, 323 Force: true, 324 }); err != nil { 325 fmt.Fprintln(os.Stderr, "Failed to remove container:", containerId, err) 326 } 327 } 328 329 // Runs a container image exactly once, returning stdout and throwing error on non-zero exit code. 330 func DockerRunOnce(ctx context.Context, image string, env []string, cmd []string) (string, error) { 331 stderr := io.Discard 332 if viper.GetBool("DEBUG") { 333 stderr = os.Stderr 334 } 335 var out bytes.Buffer 336 err := DockerRunOnceWithStream(ctx, image, env, cmd, &out, stderr) 337 return out.String(), err 338 } 339 340 func DockerRunOnceWithStream(ctx context.Context, image string, env, cmd []string, stdout, stderr io.Writer) error { 341 return DockerRunOnceWithConfig(ctx, container.Config{ 342 Image: image, 343 Env: env, 344 Cmd: cmd, 345 }, container.HostConfig{}, network.NetworkingConfig{}, "", stdout, stderr) 346 } 347 348 func DockerRunOnceWithConfig(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string, stdout, stderr io.Writer) error { 349 // Cannot rely on docker's auto remove because 350 // 1. We must inspect exit code after container stops 351 // 2. Context cancellation may happen after start 352 container, err := DockerStart(ctx, config, hostConfig, networkingConfig, containerName) 353 if err != nil { 354 return err 355 } 356 defer DockerRemove(container) 357 return DockerStreamLogs(ctx, container, stdout, stderr) 358 } 359 360 func DockerStreamLogs(ctx context.Context, containerId string, stdout, stderr io.Writer) error { 361 // Stream logs 362 logs, err := Docker.ContainerLogs(ctx, containerId, container.LogsOptions{ 363 ShowStdout: true, 364 ShowStderr: true, 365 Follow: true, 366 }) 367 if err != nil { 368 return errors.Errorf("failed to read docker logs: %w", err) 369 } 370 defer logs.Close() 371 if _, err := stdcopy.StdCopy(stdout, stderr, logs); err != nil { 372 return errors.Errorf("failed to copy docker logs: %w", err) 373 } 374 // Check exit code 375 resp, err := Docker.ContainerInspect(ctx, containerId) 376 if err != nil { 377 return errors.Errorf("failed to inspect docker container: %w", err) 378 } 379 if resp.State.ExitCode > 0 { 380 return errors.Errorf("error running container: exit %d", resp.State.ExitCode) 381 } 382 return nil 383 } 384 385 // Exec a command once inside a container, returning stdout and throwing error on non-zero exit code. 386 func DockerExecOnce(ctx context.Context, container string, env []string, cmd []string) (string, error) { 387 stderr := io.Discard 388 if viper.GetBool("DEBUG") { 389 stderr = os.Stderr 390 } 391 var out bytes.Buffer 392 err := DockerExecOnceWithStream(ctx, container, "", env, cmd, &out, stderr) 393 return out.String(), err 394 } 395 396 func DockerExecOnceWithStream(ctx context.Context, container, workdir string, env, cmd []string, stdout, stderr io.Writer) error { 397 // Reset shadow database 398 exec, err := Docker.ContainerExecCreate(ctx, container, types.ExecConfig{ 399 Env: env, 400 Cmd: cmd, 401 WorkingDir: workdir, 402 AttachStderr: true, 403 AttachStdout: true, 404 }) 405 if err != nil { 406 return errors.Errorf("failed to exec docker create: %w", err) 407 } 408 // Read exec output 409 resp, err := Docker.ContainerExecAttach(ctx, exec.ID, types.ExecStartCheck{}) 410 if err != nil { 411 return errors.Errorf("failed to exec docker attach: %w", err) 412 } 413 defer resp.Close() 414 // Capture error details 415 if _, err := stdcopy.StdCopy(stdout, stderr, resp.Reader); err != nil { 416 return errors.Errorf("failed to copy docker logs: %w", err) 417 } 418 // Get the exit code 419 iresp, err := Docker.ContainerExecInspect(ctx, exec.ID) 420 if err != nil { 421 return errors.Errorf("failed to exec docker inspect: %w", err) 422 } 423 if iresp.ExitCode > 0 { 424 err = errors.New("error executing command") 425 } 426 return err 427 } 428 429 var portErrorPattern = regexp.MustCompile("Bind for (.*) failed: port is already allocated") 430 431 func parsePortBindError(err error) string { 432 matches := portErrorPattern.FindStringSubmatch(err.Error()) 433 if len(matches) > 1 { 434 return matches[len(matches)-1] 435 } 436 return "" 437 } 438 439 func suggestDockerStop(ctx context.Context, hostPort string) string { 440 if containers, err := Docker.ContainerList(ctx, container.ListOptions{}); err == nil { 441 for _, c := range containers { 442 for _, p := range c.Ports { 443 if fmt.Sprintf("%s:%d", p.IP, p.PublicPort) == hostPort { 444 if project, ok := c.Labels[CliProjectLabel]; ok { 445 return "\nTry stopping the running project with " + Aqua("supabase stop --project-id "+project) 446 } else { 447 name := c.ID 448 if len(c.Names) > 0 { 449 name = c.Names[0] 450 } 451 return "\nTry stopping the running container with " + Aqua("docker stop "+name) 452 } 453 } 454 } 455 } 456 } 457 return "" 458 } 459 460 func replaceImageTag(image string, tag string) string { 461 index := strings.IndexByte(image, ':') 462 return image[:index+1] + strings.TrimSpace(tag) 463 }