build_bake.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. /*
  2. Copyright 2020 Docker Compose CLI authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package compose
  14. import (
  15. "bufio"
  16. "bytes"
  17. "context"
  18. "encoding/json"
  19. "errors"
  20. "fmt"
  21. "os"
  22. "os/exec"
  23. "path/filepath"
  24. "slices"
  25. "strconv"
  26. "strings"
  27. "github.com/compose-spec/compose-go/v2/types"
  28. "github.com/docker/cli/cli-plugins/manager"
  29. "github.com/docker/cli/cli-plugins/socket"
  30. "github.com/docker/cli/cli/command"
  31. "github.com/docker/compose/v2/pkg/api"
  32. "github.com/docker/compose/v2/pkg/progress"
  33. "github.com/docker/docker/api/types/versions"
  34. "github.com/docker/docker/builder/remotecontext/urlutil"
  35. "github.com/moby/buildkit/client"
  36. "github.com/moby/buildkit/util/gitutil"
  37. "github.com/moby/buildkit/util/progress/progressui"
  38. "github.com/sirupsen/logrus"
  39. "github.com/spf13/cobra"
  40. "go.opentelemetry.io/otel"
  41. "go.opentelemetry.io/otel/propagation"
  42. "golang.org/x/sync/errgroup"
  43. )
  44. func buildWithBake(dockerCli command.Cli) (bool, error) {
  45. b, ok := os.LookupEnv("COMPOSE_BAKE")
  46. if !ok {
  47. b = "true"
  48. }
  49. bake, err := strconv.ParseBool(b)
  50. if err != nil {
  51. return false, err
  52. }
  53. if !bake {
  54. return false, nil
  55. }
  56. enabled, err := dockerCli.BuildKitEnabled()
  57. if err != nil {
  58. return false, err
  59. }
  60. if !enabled {
  61. logrus.Warnf("Docker Compose is configured to build using Bake, but buildkit isn't enabled")
  62. return false, nil
  63. }
  64. _, err = manager.GetPlugin("buildx", dockerCli, &cobra.Command{})
  65. if err != nil {
  66. if manager.IsNotFound(err) {
  67. logrus.Warnf("Docker Compose is configured to build using Bake, but buildx isn't installed")
  68. return false, nil
  69. }
  70. return false, err
  71. }
  72. return true, err
  73. }
  74. // We _could_ use bake.* types from github.com/docker/buildx but long term plan is to remove buildx as a dependency
  75. type bakeConfig struct {
  76. Groups map[string]bakeGroup `json:"group"`
  77. Targets map[string]bakeTarget `json:"target"`
  78. }
  79. type bakeGroup struct {
  80. Targets []string `json:"targets"`
  81. }
  82. type bakeTarget struct {
  83. Context string `json:"context,omitempty"`
  84. Contexts map[string]string `json:"contexts,omitempty"`
  85. Dockerfile string `json:"dockerfile,omitempty"`
  86. DockerfileInline string `json:"dockerfile-inline,omitempty"`
  87. Args map[string]string `json:"args,omitempty"`
  88. Labels map[string]string `json:"labels,omitempty"`
  89. Tags []string `json:"tags,omitempty"`
  90. CacheFrom []string `json:"cache-from,omitempty"`
  91. CacheTo []string `json:"cache-to,omitempty"`
  92. Target string `json:"target,omitempty"`
  93. Secrets []string `json:"secret,omitempty"`
  94. SSH []string `json:"ssh,omitempty"`
  95. Platforms []string `json:"platforms,omitempty"`
  96. Pull bool `json:"pull,omitempty"`
  97. NoCache bool `json:"no-cache,omitempty"`
  98. NetworkMode string `json:"network,omitempty"`
  99. NoCacheFilter []string `json:"no-cache-filter,omitempty"`
  100. ShmSize types.UnitBytes `json:"shm-size,omitempty"`
  101. Ulimits []string `json:"ulimits,omitempty"`
  102. Call string `json:"call,omitempty"`
  103. Entitlements []string `json:"entitlements,omitempty"`
  104. Outputs []string `json:"output,omitempty"`
  105. }
  106. type bakeMetadata map[string]buildStatus
  107. type buildStatus struct {
  108. Digest string `json:"containerimage.digest"`
  109. Image string `json:"image.name"`
  110. }
  111. func (s *composeService) doBuildBake(ctx context.Context, project *types.Project, serviceToBeBuild types.Services, options api.BuildOptions) (map[string]string, error) { //nolint:gocyclo
  112. eg := errgroup.Group{}
  113. ch := make(chan *client.SolveStatus)
  114. display, err := progressui.NewDisplay(os.Stdout, progressui.DisplayMode(options.Progress))
  115. if err != nil {
  116. return nil, err
  117. }
  118. eg.Go(func() error {
  119. _, err := display.UpdateFrom(ctx, ch)
  120. return err
  121. })
  122. cfg := bakeConfig{
  123. Groups: map[string]bakeGroup{},
  124. Targets: map[string]bakeTarget{},
  125. }
  126. var (
  127. group bakeGroup
  128. privileged bool
  129. read []string
  130. expectedImages = make(map[string]string, len(serviceToBeBuild)) // service name -> expected image
  131. )
  132. for serviceName, service := range serviceToBeBuild {
  133. if service.Build == nil {
  134. continue
  135. }
  136. build := *service.Build
  137. args := types.Mapping{}
  138. for k, v := range resolveAndMergeBuildArgs(s.dockerCli, project, service, options) {
  139. if v == nil {
  140. continue
  141. }
  142. args[k] = *v
  143. }
  144. image := api.GetImageNameOrDefault(service, project.Name)
  145. expectedImages[serviceName] = image
  146. entitlements := build.Entitlements
  147. if slices.Contains(build.Entitlements, "security.insecure") {
  148. privileged = true
  149. }
  150. if build.Privileged {
  151. entitlements = append(entitlements, "security.insecure")
  152. privileged = true
  153. }
  154. var outputs []string
  155. var call string
  156. push := options.Push && service.Image != ""
  157. switch {
  158. case options.Check:
  159. call = "lint"
  160. case len(service.Build.Platforms) > 1:
  161. outputs = []string{fmt.Sprintf("type=image,push=%t", push)}
  162. default:
  163. outputs = []string{fmt.Sprintf("type=docker,load=true,push=%t", push)}
  164. }
  165. read = append(read, build.Context)
  166. for _, path := range build.AdditionalContexts {
  167. _, err := gitutil.ParseGitRef(path)
  168. if !strings.Contains(path, "://") && err != nil {
  169. read = append(read, path)
  170. }
  171. }
  172. cfg.Targets[serviceName] = bakeTarget{
  173. Context: build.Context,
  174. Contexts: additionalContexts(build.AdditionalContexts),
  175. Dockerfile: dockerFilePath(build.Context, build.Dockerfile),
  176. DockerfileInline: strings.ReplaceAll(build.DockerfileInline, "${", "$${"),
  177. Args: args,
  178. Labels: build.Labels,
  179. Tags: append(build.Tags, image),
  180. CacheFrom: build.CacheFrom,
  181. // CacheTo: TODO
  182. Platforms: build.Platforms,
  183. Target: build.Target,
  184. Secrets: toBakeSecrets(project, build.Secrets),
  185. SSH: toBakeSSH(append(build.SSH, options.SSHs...)),
  186. Pull: options.Pull,
  187. NoCache: options.NoCache,
  188. ShmSize: build.ShmSize,
  189. Ulimits: toBakeUlimits(build.Ulimits),
  190. Entitlements: entitlements,
  191. Outputs: outputs,
  192. Call: call,
  193. }
  194. group.Targets = append(group.Targets, serviceName)
  195. }
  196. cfg.Groups["default"] = group
  197. b, err := json.MarshalIndent(cfg, "", " ")
  198. if err != nil {
  199. return nil, err
  200. }
  201. if options.Print {
  202. _, err = fmt.Fprintln(s.stdout(), string(b))
  203. return nil, err
  204. }
  205. logrus.Debugf("bake build config:\n%s", string(b))
  206. metadata, err := os.CreateTemp(os.TempDir(), "compose")
  207. if err != nil {
  208. return nil, err
  209. }
  210. buildx, err := manager.GetPlugin("buildx", s.dockerCli, &cobra.Command{})
  211. if err != nil {
  212. return nil, err
  213. }
  214. args := []string{"bake", "--file", "-", "--progress", "rawjson", "--metadata-file", metadata.Name()}
  215. mustAllow := buildx.Version != "" && versions.GreaterThanOrEqualTo(buildx.Version[1:], "0.17.0")
  216. if mustAllow {
  217. // FIXME we should prompt user about this, but this is a breaking change in UX
  218. for _, path := range read {
  219. args = append(args, "--allow", "fs.read="+path)
  220. }
  221. if privileged {
  222. args = append(args, "--allow", "security.insecure")
  223. }
  224. }
  225. if options.Builder != "" {
  226. args = append(args, "--builder", options.Builder)
  227. }
  228. if options.Quiet {
  229. args = append(args, "--progress=quiet")
  230. }
  231. logrus.Debugf("Executing bake with args: %v", args)
  232. cmd := exec.CommandContext(ctx, buildx.Path, args...)
  233. // Remove DOCKER_CLI_PLUGIN... variable so buildx can detect it run standalone
  234. cmd.Env = filter(os.Environ(), manager.ReexecEnvvar)
  235. // Use docker/cli mechanism to propagate termination signal to child process
  236. server, err := socket.NewPluginServer(nil)
  237. if err != nil {
  238. defer server.Close() //nolint:errcheck
  239. cmd.Cancel = server.Close
  240. cmd.Env = replace(cmd.Env, socket.EnvKey, server.Addr().String())
  241. }
  242. cmd.Env = append(cmd.Env, fmt.Sprintf("DOCKER_CONTEXT=%s", s.dockerCli.CurrentContext()))
  243. // propagate opentelemetry context to child process, see https://github.com/open-telemetry/oteps/blob/main/text/0258-env-context-baggage-carriers.md
  244. carrier := propagation.MapCarrier{}
  245. otel.GetTextMapPropagator().Inject(ctx, &carrier)
  246. cmd.Env = append(cmd.Env, types.Mapping(carrier).Values()...)
  247. cmd.Stdout = s.stdout()
  248. cmd.Stdin = bytes.NewBuffer(b)
  249. pipe, err := cmd.StderrPipe()
  250. if err != nil {
  251. return nil, err
  252. }
  253. var errMessage []string
  254. scanner := bufio.NewScanner(pipe)
  255. scanner.Split(bufio.ScanLines)
  256. err = cmd.Start()
  257. if err != nil {
  258. return nil, err
  259. }
  260. eg.Go(cmd.Wait)
  261. for scanner.Scan() {
  262. line := scanner.Text()
  263. decoder := json.NewDecoder(strings.NewReader(line))
  264. var status client.SolveStatus
  265. err := decoder.Decode(&status)
  266. if err != nil {
  267. if strings.HasPrefix(line, "ERROR: ") {
  268. errMessage = append(errMessage, line[7:])
  269. } else {
  270. errMessage = append(errMessage, line)
  271. }
  272. continue
  273. }
  274. ch <- &status
  275. }
  276. close(ch) // stop build progress UI
  277. err = eg.Wait()
  278. if err != nil {
  279. if len(errMessage) > 0 {
  280. return nil, errors.New(strings.Join(errMessage, "\n"))
  281. }
  282. return nil, fmt.Errorf("failed to execute bake: %w", err)
  283. }
  284. b, err = os.ReadFile(metadata.Name())
  285. if err != nil {
  286. return nil, err
  287. }
  288. var md bakeMetadata
  289. err = json.Unmarshal(b, &md)
  290. if err != nil {
  291. return nil, err
  292. }
  293. cw := progress.ContextWriter(ctx)
  294. results := map[string]string{}
  295. for service, name := range expectedImages {
  296. built, ok := md[service] // bake target == service name
  297. if !ok {
  298. return nil, fmt.Errorf("build result not found in Bake metadata for service %s", service)
  299. }
  300. results[name] = built.Digest
  301. cw.Event(progress.BuiltEvent(name))
  302. }
  303. return results, nil
  304. }
  305. func additionalContexts(contexts types.Mapping) map[string]string {
  306. ac := map[string]string{}
  307. for k, v := range contexts {
  308. if target, found := strings.CutPrefix(v, types.ServicePrefix); found {
  309. v = "target:" + target
  310. }
  311. ac[k] = v
  312. }
  313. return ac
  314. }
  315. func toBakeUlimits(ulimits map[string]*types.UlimitsConfig) []string {
  316. s := []string{}
  317. for u, l := range ulimits {
  318. if l.Single > 0 {
  319. s = append(s, fmt.Sprintf("%s=%d", u, l.Single))
  320. } else {
  321. s = append(s, fmt.Sprintf("%s=%d:%d", u, l.Soft, l.Hard))
  322. }
  323. }
  324. return s
  325. }
  326. func toBakeSSH(ssh types.SSHConfig) []string {
  327. var s []string
  328. for _, key := range ssh {
  329. s = append(s, fmt.Sprintf("%s=%s", key.ID, key.Path))
  330. }
  331. return s
  332. }
  333. func toBakeSecrets(project *types.Project, secrets []types.ServiceSecretConfig) []string {
  334. var s []string
  335. for _, ref := range secrets {
  336. def := project.Secrets[ref.Source]
  337. target := ref.Target
  338. if target == "" {
  339. target = ref.Source
  340. }
  341. switch {
  342. case def.Environment != "":
  343. s = append(s, fmt.Sprintf("id=%s,type=env,env=%s", target, def.Environment))
  344. case def.File != "":
  345. s = append(s, fmt.Sprintf("id=%s,type=file,src=%s", target, def.File))
  346. }
  347. }
  348. return s
  349. }
  350. func filter(environ []string, variable string) []string {
  351. prefix := variable + "="
  352. filtered := make([]string, 0, len(environ))
  353. for _, val := range environ {
  354. if !strings.HasPrefix(val, prefix) {
  355. filtered = append(filtered, val)
  356. }
  357. }
  358. return filtered
  359. }
  360. func replace(environ []string, variable, value string) []string {
  361. filtered := filter(environ, variable)
  362. return append(filtered, fmt.Sprintf("%s=%s", variable, value))
  363. }
  364. func dockerFilePath(ctxName string, dockerfile string) string {
  365. if dockerfile == "" {
  366. return ""
  367. }
  368. if urlutil.IsGitURL(ctxName) {
  369. return dockerfile
  370. }
  371. if !filepath.IsAbs(dockerfile) {
  372. dockerfile = filepath.Join(ctxName, dockerfile)
  373. }
  374. symlinks, err := filepath.EvalSymlinks(dockerfile)
  375. if err == nil {
  376. return symlinks
  377. }
  378. return dockerfile
  379. }