build_bake.go 12 KB

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