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  }