github.com/tilt-dev/tilt@v0.36.0/internal/dockercompose/client.go (about)

     1  package dockercompose
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"context"
     7  	"fmt"
     8  	"io"
     9  	"os"
    10  	"os/exec"
    11  	"regexp"
    12  	"strings"
    13  	"sync"
    14  
    15  	"github.com/compose-spec/compose-go/v2/loader"
    16  	"golang.org/x/mod/semver"
    17  
    18  	"github.com/compose-spec/compose-go/v2/types"
    19  	"github.com/pkg/errors"
    20  
    21  	"github.com/tilt-dev/tilt/internal/container"
    22  	"github.com/tilt-dev/tilt/internal/docker"
    23  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    24  	"github.com/tilt-dev/tilt/pkg/logger"
    25  
    26  	compose "github.com/compose-spec/compose-go/v2/cli"
    27  )
    28  
    29  // versionRegex handles both v1 and v2 version outputs, which have several variations.
    30  // (See TestParseComposeVersionOutput for various cases.)
    31  var versionRegex = regexp.MustCompile(`(?mi)^docker[ -]compose(?: version)?:? v?([^\s,]+),?(?: build ([a-z0-9-]+))?`)
    32  
    33  // dcProjectOptions are used when loading Docker Compose projects via the Go library.
    34  //
    35  // See also: dcLoaderOption which is used for loading projects from the CLI fallback and for tests, which should
    36  // be kept in sync behavior-wise.
    37  var dcProjectOptions = []compose.ProjectOptionsFn{
    38  	compose.WithResolvedPaths(true),
    39  	compose.WithNormalization(true),
    40  	compose.WithOsEnv,
    41  }
    42  
    43  type DockerComposeClient interface {
    44  	Up(ctx context.Context, spec v1alpha1.DockerComposeServiceSpec, shouldBuild bool, stdout, stderr io.Writer) error
    45  	Down(ctx context.Context, spec v1alpha1.DockerComposeProject, stdout, stderr io.Writer, deleteVolumes bool) error
    46  	Rm(ctx context.Context, specs []v1alpha1.DockerComposeServiceSpec, stdout, stderr io.Writer) error
    47  	StreamEvents(ctx context.Context, spec v1alpha1.DockerComposeProject) (<-chan string, error)
    48  	Project(ctx context.Context, spec v1alpha1.DockerComposeProject) (*types.Project, error)
    49  	ContainerID(ctx context.Context, spec v1alpha1.DockerComposeServiceSpec) (container.ID, error)
    50  	Version(ctx context.Context) (canonicalVersion string, build string, err error)
    51  }
    52  
    53  type cmdDCClient struct {
    54  	env     docker.Env
    55  	mu      *sync.Mutex
    56  	initCmd *sync.Once
    57  
    58  	composeCmd []string
    59  	version    string
    60  	build      string
    61  	err        error
    62  }
    63  
    64  // TODO(dmiller): we might want to make this take a path to the docker-compose config so we don't
    65  // have to keep passing it in.
    66  func NewDockerComposeClient(lenv docker.LocalEnv) DockerComposeClient {
    67  	return &cmdDCClient{
    68  		env:     docker.Env(lenv),
    69  		mu:      &sync.Mutex{},
    70  		initCmd: &sync.Once{},
    71  	}
    72  }
    73  
    74  func (c *cmdDCClient) projectArgs(p v1alpha1.DockerComposeProject) []string {
    75  	result := []string{}
    76  
    77  	if p.Name != "" {
    78  		result = append(result, "--project-name", p.Name)
    79  	}
    80  
    81  	if p.ProjectPath != "" {
    82  		result = append(result, "--project-directory", p.ProjectPath)
    83  	}
    84  
    85  	if p.EnvFile != "" {
    86  		result = append(result, "--env-file", p.EnvFile)
    87  	}
    88  
    89  	if p.YAML != "" {
    90  		result = append(result, "-f", "-")
    91  	}
    92  
    93  	for _, cp := range p.ConfigPaths {
    94  		result = append(result, "-f", cp)
    95  	}
    96  
    97  	for _, p := range p.Profiles {
    98  		result = append(result, "--profile", p)
    99  	}
   100  
   101  	return result
   102  }
   103  
   104  func (c *cmdDCClient) Up(ctx context.Context, spec v1alpha1.DockerComposeServiceSpec, shouldBuild bool, stdout, stderr io.Writer) error {
   105  	genArgs := c.projectArgs(spec.Project)
   106  	// TODO(milas): this causes docker-compose to output a truly excessive amount of logging; it might
   107  	// 	make sense to hide it behind a special environment variable instead or something
   108  	if logger.Get(ctx).Level().ShouldDisplay(logger.VerboseLvl) {
   109  		genArgs = append(genArgs, "--verbose")
   110  	}
   111  
   112  	if shouldBuild {
   113  		var buildArgs = append([]string{}, genArgs...)
   114  		buildArgs = append(buildArgs, "build", spec.Service)
   115  		cmd := c.dcCommand(ctx, buildArgs)
   116  		cmd.Stdin = strings.NewReader(spec.Project.YAML)
   117  		cmd.Stdout = stdout
   118  		cmd.Stderr = stderr
   119  		err := cmd.Run()
   120  		if err != nil {
   121  			return FormatError(cmd, nil, err)
   122  		}
   123  	}
   124  
   125  	// docker-compose up is not thread-safe, because network operations are non-atomic. See:
   126  	// https://github.com/tilt-dev/tilt/issues/2817
   127  	//
   128  	// docker-compose build can run in parallel fine, so we only want the mutex on the 'up' call.
   129  	//
   130  	// TODO(nick): It might make sense to use a CondVar so that we can log a message
   131  	// when we're waiting on another build...
   132  	c.mu.Lock()
   133  	defer c.mu.Unlock()
   134  	runArgs := append([]string{}, genArgs...)
   135  	runArgs = append(runArgs, "up", "--no-deps", "--remove-orphans", "--no-build", "-d", spec.Service)
   136  
   137  	if spec.Project.Wait {
   138  		runArgs = append(runArgs, "--wait")
   139  	}
   140  
   141  	cmd := c.dcCommand(ctx, runArgs)
   142  	cmd.Stdin = strings.NewReader(spec.Project.YAML)
   143  	cmd.Stdout = stdout
   144  	cmd.Stderr = stderr
   145  
   146  	return FormatError(cmd, nil, cmd.Run())
   147  }
   148  
   149  func (c *cmdDCClient) Down(ctx context.Context, p v1alpha1.DockerComposeProject, stdout, stderr io.Writer, deleteVolumes bool) error {
   150  	// To be safe, we try not to run two docker-compose downs in parallel,
   151  	// because we know docker-compose up is not thread-safe.
   152  	c.mu.Lock()
   153  	defer c.mu.Unlock()
   154  
   155  	args := c.projectArgs(p)
   156  	if logger.Get(ctx).Level().ShouldDisplay(logger.VerboseLvl) {
   157  		args = append(args, "--verbose")
   158  	}
   159  
   160  	args = append(args, "down", "--remove-orphans")
   161  	if deleteVolumes {
   162  		args = append(args, "--volumes")
   163  	}
   164  	cmd := c.dcCommand(ctx, args)
   165  	cmd.Stdin = strings.NewReader(p.YAML)
   166  	cmd.Stdout = stdout
   167  	cmd.Stderr = stderr
   168  
   169  	err := cmd.Run()
   170  	if err != nil {
   171  		return FormatError(cmd, nil, err)
   172  	}
   173  
   174  	return nil
   175  }
   176  
   177  func (c *cmdDCClient) Rm(ctx context.Context, specs []v1alpha1.DockerComposeServiceSpec, stdout, stderr io.Writer) error {
   178  	if len(specs) == 0 {
   179  		return nil
   180  	}
   181  
   182  	// To be safe, we try not to run two docker-compose downs in parallel,
   183  	// because we know docker-compose up is not thread-safe.
   184  	c.mu.Lock()
   185  	defer c.mu.Unlock()
   186  
   187  	p := specs[0].Project
   188  	args := c.projectArgs(p)
   189  	if logger.Get(ctx).Level().ShouldDisplay(logger.VerboseLvl) {
   190  		args = append(args, "--verbose")
   191  	}
   192  
   193  	var serviceNames []string
   194  	for _, s := range specs {
   195  		serviceNames = append(serviceNames, s.Service)
   196  	}
   197  
   198  	// `docker-compose rm` does not support a `--timeout` option, so it possibly defaults to 10,
   199  	// like `docker-compose stop` or `docker-compose down`.
   200  	// If it turns out this command's timeout is too long, we might want to change this to first
   201  	// call `docker-compose stop --timeout $NUM`, to do the presumably slow part under a smaller
   202  	// timeout.
   203  	args = append(args, []string{"rm", "--stop", "--force"}...)
   204  	args = append(args, serviceNames...)
   205  	cmd := c.dcCommand(ctx, args)
   206  	cmd.Stdin = strings.NewReader(p.YAML)
   207  	cmd.Stdout = stdout
   208  	cmd.Stderr = stderr
   209  
   210  	err := cmd.Run()
   211  	if err != nil {
   212  		return FormatError(cmd, nil, err)
   213  	}
   214  
   215  	return nil
   216  }
   217  
   218  func (c *cmdDCClient) StreamEvents(ctx context.Context, p v1alpha1.DockerComposeProject) (<-chan string, error) {
   219  	ch := make(chan string)
   220  
   221  	args := c.projectArgs(p)
   222  	args = append(args, "events", "--json")
   223  	cmd := c.dcCommand(ctx, args)
   224  	cmd.Stdin = strings.NewReader(p.YAML)
   225  	stdout, err := cmd.StdoutPipe()
   226  	if err != nil {
   227  		return ch, errors.Wrap(err, "making stdout pipe for `docker-compose events`")
   228  	}
   229  
   230  	err = cmd.Start()
   231  	if err != nil {
   232  		return ch, errors.Wrapf(err, "`docker-compose %s`",
   233  			strings.Join(args, " "))
   234  	}
   235  	go func() {
   236  		scanner := bufio.NewScanner(stdout)
   237  		for scanner.Scan() {
   238  			ch <- scanner.Text()
   239  		}
   240  
   241  		if err := scanner.Err(); err != nil {
   242  			logger.Get(ctx).Infof("[DOCKER-COMPOSE WATCHER] scanning `events` output: %v", err)
   243  		}
   244  
   245  		err = cmd.Wait()
   246  		if err != nil {
   247  			logger.Get(ctx).Infof("[DOCKER-COMPOSE WATCHER] exited with error: %v", err)
   248  		}
   249  	}()
   250  
   251  	return ch, nil
   252  }
   253  
   254  func (c *cmdDCClient) Project(ctx context.Context, spec v1alpha1.DockerComposeProject) (*types.Project, error) {
   255  	var proj *types.Project
   256  	var err error
   257  
   258  	// First, use compose-go to natively load the project.
   259  	if len(spec.ConfigPaths) > 0 {
   260  		parsed, err := c.loadProjectNative(ctx, spec)
   261  		if err == nil {
   262  			proj = parsed
   263  		}
   264  	}
   265  
   266  	// HACK(milas): compose-go has known regressions with resolving variables during YAML loading
   267  	// 	if it fails, attempt to fallback to using the CLI to resolve the YAML and then parse
   268  	// 	it with compose-go
   269  	// 	see https://github.com/tilt-dev/tilt/issues/4795
   270  	if proj == nil {
   271  		proj, err = c.loadProjectCLI(ctx, spec)
   272  		if err != nil {
   273  			return nil, err
   274  		}
   275  	}
   276  
   277  	return proj, nil
   278  }
   279  
   280  func (c *cmdDCClient) ContainerID(ctx context.Context, spec v1alpha1.DockerComposeServiceSpec) (container.ID, error) {
   281  	id, err := c.dcOutput(ctx, spec.Project, "ps", "-a", "-q", spec.Service)
   282  	if err != nil {
   283  		return container.ID(""), err
   284  	}
   285  
   286  	return container.ID(id), nil
   287  }
   288  
   289  // Version returns the parsed output of `docker compose version`, the canonical version and build (if present).
   290  //
   291  // NOTE: The version subcommand was added in Docker Compose v1.4.0 (released 2015-08-04), so this won't work for
   292  //
   293  //	truly ancient versions, but handles both v1 and v2.
   294  func (c *cmdDCClient) Version(ctx context.Context) (string, string, error) {
   295  	c.initDcCommand()
   296  	return c.version, c.build, c.err
   297  }
   298  
   299  func composeProjectOptions(modelProj v1alpha1.DockerComposeProject, env []string) (*compose.ProjectOptions, error) {
   300  	var envFiles []string
   301  	if modelProj.EnvFile != "" {
   302  		envFiles = append(envFiles, modelProj.EnvFile)
   303  	}
   304  	// NOTE: take care to keep behavior in sync with loadProjectCLI()
   305  	allProjectOptions := append(dcProjectOptions,
   306  		compose.WithWorkingDirectory(modelProj.ProjectPath),
   307  		compose.WithName(modelProj.Name),
   308  		compose.WithResolvedPaths(true),
   309  		compose.WithEnv(env),
   310  		compose.WithProfiles(modelProj.Profiles),
   311  		compose.WithEnvFiles(envFiles...),
   312  	)
   313  	allProjectOptions = append(allProjectOptions, compose.WithDotEnv)
   314  	return compose.NewProjectOptions(modelProj.ConfigPaths, allProjectOptions...)
   315  }
   316  
   317  func (c *cmdDCClient) loadProjectNative(ctx context.Context, modelProj v1alpha1.DockerComposeProject) (*types.Project, error) {
   318  	opts, err := composeProjectOptions(modelProj, c.mergedEnv())
   319  	if err != nil {
   320  		return nil, err
   321  	}
   322  	proj, err := compose.ProjectFromOptions(ctx, opts)
   323  	if err != nil {
   324  		return nil, err
   325  	}
   326  	return proj, nil
   327  }
   328  
   329  func (c *cmdDCClient) loadProjectCLI(ctx context.Context, proj v1alpha1.DockerComposeProject) (*types.Project, error) {
   330  	resolvedYAML, err := c.dcOutput(ctx, proj, "config")
   331  	if err != nil {
   332  		return nil, err
   333  	}
   334  
   335  	// docker-compose is very inconsistent about whether it fully resolves paths or not via CLI, both between
   336  	// v1 and v2 as well as even different releases within v2, so set the workdir and force the loader to resolve
   337  	// any relative paths
   338  	return loader.LoadWithContext(ctx, types.ConfigDetails{
   339  		WorkingDir: proj.ProjectPath,
   340  		ConfigFiles: []types.ConfigFile{
   341  			{
   342  				Content: []byte(resolvedYAML),
   343  			},
   344  		},
   345  		// no environment specified because the CLI call will already have resolved all variables
   346  	}, dcLoaderOption(proj.Name), loader.WithProfiles(proj.Profiles))
   347  }
   348  
   349  // dcLoaderOption is used when loading Docker Compose projects via the CLI and fallback and for tests.
   350  //
   351  // See also: dcProjectOptions which is used for loading projects from the Go library, which should
   352  // be kept in sync behavior-wise.
   353  func dcLoaderOption(name string) func(opts *loader.Options) {
   354  	return func(opts *loader.Options) {
   355  		opts.SetProjectName(name, true)
   356  		opts.ResolvePaths = true
   357  		opts.SkipNormalization = false
   358  		opts.SkipInterpolation = false
   359  	}
   360  }
   361  
   362  func dcExecutableVersion(environ []string) ([]string, string, string, error) {
   363  	execVersion := func(names []string) (string, string, error) {
   364  		args := append(names, "version")
   365  		cmd := exec.Command(args[0], args[1:]...)
   366  		cmd.Env = append(os.Environ(), environ...)
   367  		stdout, err := cmd.Output()
   368  		if err != nil {
   369  			return "", "", FormatError(cmd, stdout, err)
   370  		}
   371  		ver, build, err := parseComposeVersionOutput(stdout)
   372  		return ver, build, err
   373  	}
   374  
   375  	var cmd []string
   376  	if cmdstr := os.Getenv("TILT_DOCKER_COMPOSE_CMD"); cmdstr != "" {
   377  		cmd = []string{cmdstr}
   378  		ver, build, err := execVersion(cmd)
   379  		return cmd, ver, build, err
   380  	}
   381  
   382  	cmd = []string{"docker", "compose"}
   383  	ver, build, err := execVersion(cmd)
   384  	if err != nil {
   385  		cmd = []string{"docker-compose"}
   386  		ver, build, err = execVersion(cmd)
   387  	}
   388  
   389  	return cmd, ver, build, err
   390  }
   391  
   392  func (c *cmdDCClient) initDcCommand() {
   393  	c.initCmd.Do(func() {
   394  		cmd, version, build, err := dcExecutableVersion(c.env.AsEnviron())
   395  		c.composeCmd = cmd
   396  		c.version = version
   397  		c.build = build
   398  		c.err = err
   399  	})
   400  }
   401  
   402  func (c *cmdDCClient) mergedEnv() []string {
   403  	return append(os.Environ(), c.env.AsEnviron()...)
   404  }
   405  
   406  func (c *cmdDCClient) dcCommand(ctx context.Context, args []string) *exec.Cmd {
   407  	c.initDcCommand()
   408  	composeCmd := c.composeCmd[0]
   409  	composeArgs := c.composeCmd[1:]
   410  	if len(composeArgs) > 0 {
   411  		args = append(composeArgs, args...)
   412  	}
   413  	cmd := exec.CommandContext(ctx, composeCmd, args...)
   414  	cmd.Env = c.mergedEnv()
   415  	return cmd
   416  }
   417  
   418  func (c *cmdDCClient) dcOutput(ctx context.Context, p v1alpha1.DockerComposeProject, args ...string) (string, error) {
   419  
   420  	tempArgs := c.projectArgs(p)
   421  	args = append(tempArgs, args...)
   422  	cmd := c.dcCommand(ctx, args)
   423  	cmd.Stdin = strings.NewReader(p.YAML)
   424  
   425  	output, err := cmd.Output()
   426  	if err != nil {
   427  		errorMessage := fmt.Sprintf("command %q failed.\nerror: %v\nstdout: %q", cmd.Args, err, string(output))
   428  		if err, ok := err.(*exec.ExitError); ok {
   429  			errorMessage += fmt.Sprintf("\nstderr: '%v'", string(err.Stderr))
   430  		}
   431  		err = fmt.Errorf("%s", errorMessage)
   432  	}
   433  	return strings.TrimSpace(string(output)), err
   434  }
   435  
   436  // parseComposeVersionOutput parses the raw output of `docker-compose version` for both v1.x + v2.x Compose
   437  // and returns the canonical semver + build (might be blank) or an error.
   438  func parseComposeVersionOutput(stdout []byte) (string, string, error) {
   439  	// match 0: raw output
   440  	// match 1: version w/o leading v (required)
   441  	// match 2: build (optional)
   442  	m := versionRegex.FindSubmatch(bytes.TrimSpace(stdout))
   443  	if len(m) < 2 {
   444  		return "", "", fmt.Errorf("could not parse version from output: %q", string(stdout))
   445  	}
   446  	rawVersion := "v" + string(m[1])
   447  	canonicalVersion := semver.Canonical(rawVersion)
   448  	if canonicalVersion == "" {
   449  		return "", "", fmt.Errorf("invalid version: %q", rawVersion)
   450  	}
   451  	build := semver.Build(rawVersion)
   452  	if build != "" {
   453  		// prefer semver build if present, but strip off the leading `+`
   454  		// (currently, Docker Compose has not made use of this, preferring to list the build independently if at all)
   455  		build = strings.TrimPrefix(build, "+")
   456  	} else if len(m) > 2 {
   457  		// otherwise, fall back to regex match if possible
   458  		build = string(m[2])
   459  	}
   460  	return canonicalVersion, build, nil
   461  }
   462  
   463  func FormatError(cmd *exec.Cmd, stdout []byte, err error) error {
   464  	if err == nil {
   465  		return nil
   466  	}
   467  	errorMessage := fmt.Sprintf("command %q failed.\nerror: %v\n", cmd.Args, err)
   468  	if len(stdout) > 0 {
   469  		errorMessage += fmt.Sprintf("\nstdout: '%v'", string(stdout))
   470  	}
   471  	if err, ok := err.(*exec.ExitError); ok && len(err.Stderr) > 0 {
   472  		errorMessage += fmt.Sprintf("\nstderr: '%v'", string(err.Stderr))
   473  	}
   474  	return fmt.Errorf("%s", errorMessage)
   475  }