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 }