github.com/Redstoneguy129/cli@v0.0.0-20230211220159-15dca4e91917/internal/utils/docker.go (about)

     1  package utils
     2  
     3  import (
     4  	"archive/tar"
     5  	"bytes"
     6  	"context"
     7  	"encoding/base64"
     8  	"encoding/json"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"os"
    13  	"strings"
    14  	"sync"
    15  	"time"
    16  
    17  	"github.com/docker/cli/cli/compose/loader"
    18  	dockerConfig "github.com/docker/cli/cli/config"
    19  	"github.com/docker/cli/cli/streams"
    20  	"github.com/docker/docker/api/types"
    21  	"github.com/docker/docker/api/types/container"
    22  	"github.com/docker/docker/api/types/mount"
    23  	"github.com/docker/docker/client"
    24  	"github.com/docker/docker/errdefs"
    25  	"github.com/docker/docker/pkg/jsonmessage"
    26  	"github.com/docker/docker/pkg/stdcopy"
    27  	"github.com/spf13/viper"
    28  )
    29  
    30  // TODO: refactor to initialise lazily
    31  var Docker = NewDocker()
    32  
    33  func NewDocker() *client.Client {
    34  	docker, err := client.NewClientWithOpts(
    35  		client.WithAPIVersionNegotiation(),
    36  		// Support env (e.g. for mock setup or rootless docker)
    37  		client.FromEnv,
    38  	)
    39  	if err != nil {
    40  		fmt.Fprintln(os.Stderr, "Failed to initialize Docker client:", err)
    41  		os.Exit(1)
    42  	}
    43  	return docker
    44  }
    45  
    46  func AssertDockerIsRunning(ctx context.Context) error {
    47  	if _, err := Docker.Ping(ctx); err != nil {
    48  		return NewError(err.Error())
    49  	}
    50  
    51  	return nil
    52  }
    53  
    54  func DockerNetworkCreateIfNotExists(ctx context.Context, networkId string) error {
    55  	_, err := Docker.NetworkCreate(
    56  		ctx,
    57  		networkId,
    58  		types.NetworkCreate{
    59  			CheckDuplicate: true,
    60  			Labels: map[string]string{
    61  				"com.supabase.cli.project":   Config.ProjectId,
    62  				"com.docker.compose.project": Config.ProjectId,
    63  			},
    64  		},
    65  	)
    66  	// if error is network already exists, no need to propagate to user
    67  	if errdefs.IsConflict(err) {
    68  		return nil
    69  	}
    70  	return err
    71  }
    72  
    73  func DockerExec(ctx context.Context, container string, cmd []string) (io.Reader, error) {
    74  	exec, err := Docker.ContainerExecCreate(
    75  		ctx,
    76  		container,
    77  		types.ExecConfig{Cmd: cmd, AttachStderr: true, AttachStdout: true},
    78  	)
    79  	if err != nil {
    80  		return nil, err
    81  	}
    82  
    83  	resp, err := Docker.ContainerExecAttach(ctx, exec.ID, types.ExecStartCheck{})
    84  	if err != nil {
    85  		return nil, err
    86  	}
    87  
    88  	return resp.Reader, nil
    89  }
    90  
    91  // Used by unit tests
    92  // NOTE: There's a risk of data race with reads & writes from `DockerRun` and
    93  // reads from `DockerRemoveAll`, but since they're expected to be run on the
    94  // same thread, this is fine.
    95  var (
    96  	Containers []string
    97  	Volumes    []string
    98  )
    99  
   100  func WaitAll(containers []string, exec func(container string)) {
   101  	var wg sync.WaitGroup
   102  	for _, container := range containers {
   103  		wg.Add(1)
   104  		go func(container string) {
   105  			defer wg.Done()
   106  			exec(container)
   107  		}(container)
   108  	}
   109  	wg.Wait()
   110  }
   111  
   112  func DockerRemoveAll(ctx context.Context) {
   113  	WaitAll(Containers, func(container string) {
   114  		_ = Docker.ContainerRemove(ctx, container, types.ContainerRemoveOptions{
   115  			RemoveVolumes: true,
   116  			Force:         true,
   117  		})
   118  	})
   119  	WaitAll(Volumes, func(name string) {
   120  		_ = Docker.VolumeRemove(ctx, name, true)
   121  	})
   122  	_ = Docker.NetworkRemove(ctx, NetId)
   123  }
   124  
   125  func DockerAddFile(ctx context.Context, container string, fileName string, content []byte) error {
   126  	var buf bytes.Buffer
   127  	tw := tar.NewWriter(&buf)
   128  	err := tw.WriteHeader(&tar.Header{
   129  		Name: fileName,
   130  		Mode: 0777,
   131  		Size: int64(len(content)),
   132  	})
   133  
   134  	if err != nil {
   135  		return fmt.Errorf("failed to copy file: %v", err)
   136  	}
   137  
   138  	_, err = tw.Write(content)
   139  
   140  	if err != nil {
   141  		return fmt.Errorf("failed to copy file: %v", err)
   142  	}
   143  
   144  	err = tw.Close()
   145  
   146  	if err != nil {
   147  		return fmt.Errorf("failed to copy file: %v", err)
   148  	}
   149  
   150  	err = Docker.CopyToContainer(ctx, container, "/tmp", &buf, types.CopyToContainerOptions{})
   151  	if err != nil {
   152  		return fmt.Errorf("failed to copy file: %v", err)
   153  	}
   154  	return nil
   155  }
   156  
   157  var (
   158  	// Only supports one registry per command invocation
   159  	registryAuth string
   160  	registryOnce sync.Once
   161  )
   162  
   163  func GetRegistryAuth() string {
   164  	registryOnce.Do(func() {
   165  		config := dockerConfig.LoadDefaultConfigFile(os.Stderr)
   166  		// Ref: https://docs.docker.com/engine/api/sdk/examples/#pull-an-image-with-authentication
   167  		auth, err := config.GetAuthConfig(getRegistry())
   168  		if err != nil {
   169  			fmt.Fprintln(os.Stderr, "Failed to load registry credentials:", err)
   170  			return
   171  		}
   172  		encoded, err := json.Marshal(auth)
   173  		if err != nil {
   174  			fmt.Fprintln(os.Stderr, "Failed to serialise auth config:", err)
   175  			return
   176  		}
   177  		registryAuth = base64.URLEncoding.EncodeToString(encoded)
   178  	})
   179  	return registryAuth
   180  }
   181  
   182  // Defaults to Supabase public ECR for faster image pull
   183  const defaultRegistry = "public.ecr.aws"
   184  
   185  func getRegistry() string {
   186  	registry := viper.GetString("INTERNAL_IMAGE_REGISTRY")
   187  	if len(registry) == 0 {
   188  		return defaultRegistry
   189  	}
   190  	return strings.ToLower(registry)
   191  }
   192  
   193  func GetRegistryImageUrl(imageName string) string {
   194  	registry := getRegistry()
   195  	if registry == "docker.io" {
   196  		return imageName
   197  	}
   198  	// Configure mirror registry
   199  	parts := strings.Split(imageName, "/")
   200  	imageName = parts[len(parts)-1]
   201  	return registry + "/supabase/" + imageName
   202  }
   203  
   204  func DockerImagePull(ctx context.Context, image string, w io.Writer) error {
   205  	out, err := Docker.ImagePull(ctx, image, types.ImagePullOptions{
   206  		RegistryAuth: GetRegistryAuth(),
   207  	})
   208  	if err != nil {
   209  		return err
   210  	}
   211  	defer out.Close()
   212  	return jsonmessage.DisplayJSONMessagesToStream(out, streams.NewOut(w), 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 {
   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 err
   239  	}
   240  	return DockerImagePullWithRetry(ctx, imageUrl, 2)
   241  }
   242  
   243  func DockerStop(containerID string) {
   244  	if err := Docker.ContainerStop(context.Background(), containerID, nil); err != nil {
   245  		fmt.Fprintln(os.Stderr, "Failed to stop container:", containerID, err)
   246  	}
   247  }
   248  
   249  func DockerStart(ctx context.Context, config container.Config, hostConfig container.HostConfig, containerName string) (string, error) {
   250  	// Pull container image
   251  	if err := DockerPullImageIfNotCached(ctx, config.Image); err != nil {
   252  		return "", err
   253  	}
   254  	// Setup default config
   255  	config.Image = GetRegistryImageUrl(config.Image)
   256  	if config.Labels == nil {
   257  		config.Labels = map[string]string{}
   258  	}
   259  	config.Labels["com.supabase.cli.project"] = Config.ProjectId
   260  	config.Labels["com.docker.compose.project"] = Config.ProjectId
   261  	if len(hostConfig.NetworkMode) == 0 {
   262  		hostConfig.NetworkMode = container.NetworkMode(NetId)
   263  	}
   264  	// Create network with name
   265  	if err := DockerNetworkCreateIfNotExists(ctx, string(hostConfig.NetworkMode)); err != nil {
   266  		return "", err
   267  	}
   268  	// Create container from image
   269  	resp, err := Docker.ContainerCreate(ctx, &config, &hostConfig, nil, nil, containerName)
   270  	if err != nil {
   271  		return "", err
   272  	}
   273  	// Track container id for cleanup
   274  	Containers = append(Containers, resp.ID)
   275  	for _, bind := range hostConfig.Binds {
   276  		spec, err := loader.ParseVolume(bind)
   277  		if err != nil {
   278  			return "", err
   279  		}
   280  		// Track named volumes for cleanup
   281  		if len(spec.Source) > 0 && spec.Type == string(mount.TypeVolume) {
   282  			Volumes = append(Volumes, spec.Source)
   283  		}
   284  	}
   285  	// Run container in background
   286  	return resp.ID, Docker.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
   287  }
   288  
   289  func DockerRemove(containerId string) {
   290  	if err := Docker.ContainerRemove(context.Background(), containerId, types.ContainerRemoveOptions{
   291  		RemoveVolumes: true,
   292  		Force:         true,
   293  	}); err != nil {
   294  		fmt.Fprintln(os.Stderr, "Failed to remove container:", containerId, err)
   295  	}
   296  }
   297  
   298  // Runs a container image exactly once, returning stdout and throwing error on non-zero exit code.
   299  func DockerRunOnce(ctx context.Context, image string, env []string, cmd []string) (string, error) {
   300  	stderr := io.Discard
   301  	if viper.GetBool("DEBUG") {
   302  		stderr = os.Stderr
   303  	}
   304  	var out bytes.Buffer
   305  	err := DockerRunOnceWithStream(ctx, image, env, cmd, nil, "", &out, stderr)
   306  	return out.String(), err
   307  }
   308  
   309  func DockerRunOnceWithStream(ctx context.Context, image string, env, cmd, binds []string, containerName string, stdout, stderr io.Writer) error {
   310  	// Cannot rely on docker's auto remove because
   311  	//   1. We must inspect exit code after container stops
   312  	//   2. Context cancellation may happen after start
   313  	container, err := DockerStart(ctx, container.Config{
   314  		Image: image,
   315  		Env:   env,
   316  		Cmd:   cmd,
   317  	}, container.HostConfig{
   318  		Binds: binds,
   319  		// Allows containerized functions on Linux to reach host OS
   320  		ExtraHosts: []string{"host.docker.internal:host-gateway"},
   321  	}, containerName)
   322  	if err != nil {
   323  		return err
   324  	}
   325  	defer DockerRemove(container)
   326  	// Stream logs
   327  	logs, err := Docker.ContainerLogs(ctx, container, types.ContainerLogsOptions{
   328  		ShowStdout: true,
   329  		ShowStderr: true,
   330  		Follow:     true,
   331  	})
   332  	if err != nil {
   333  		return err
   334  	}
   335  	defer logs.Close()
   336  	if _, err := stdcopy.StdCopy(stdout, stderr, logs); err != nil {
   337  		return err
   338  	}
   339  	// Check exit code
   340  	resp, err := Docker.ContainerInspect(ctx, container)
   341  	if err != nil {
   342  		return err
   343  	}
   344  	if resp.State.ExitCode > 0 {
   345  		return fmt.Errorf("error running container: exit %d", resp.State.ExitCode)
   346  	}
   347  	return nil
   348  }
   349  
   350  // Exec a command once inside a container, returning stdout and throwing error on non-zero exit code.
   351  func DockerExecOnce(ctx context.Context, container string, env []string, cmd []string) (string, error) {
   352  	// Reset shadow database
   353  	exec, err := Docker.ContainerExecCreate(ctx, container, types.ExecConfig{
   354  		Env:          env,
   355  		Cmd:          cmd,
   356  		AttachStderr: viper.GetBool("DEBUG"),
   357  		AttachStdout: true,
   358  	})
   359  	if err != nil {
   360  		return "", err
   361  	}
   362  	// Read exec output
   363  	resp, err := Docker.ContainerExecAttach(ctx, exec.ID, types.ExecStartCheck{})
   364  	if err != nil {
   365  		return "", err
   366  	}
   367  	defer resp.Close()
   368  	// Capture error details
   369  	var out bytes.Buffer
   370  	if _, err := stdcopy.StdCopy(&out, os.Stderr, resp.Reader); err != nil {
   371  		return "", err
   372  	}
   373  	// Get the exit code
   374  	iresp, err := Docker.ContainerExecInspect(ctx, exec.ID)
   375  	if err != nil {
   376  		return "", err
   377  	}
   378  	if iresp.ExitCode > 0 {
   379  		err = errors.New("error executing command")
   380  	}
   381  	return out.String(), err
   382  }