build_bake.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  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. "bytes"
  16. "context"
  17. "encoding/json"
  18. "errors"
  19. "fmt"
  20. "io"
  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/progress/progressui"
  37. "github.com/sirupsen/logrus"
  38. "github.com/spf13/cobra"
  39. "go.opentelemetry.io/otel"
  40. "go.opentelemetry.io/otel/propagation"
  41. "golang.org/x/sync/errgroup"
  42. )
  43. func buildWithBake(dockerCli command.Cli) (bool, error) {
  44. b, ok := os.LookupEnv("COMPOSE_BAKE")
  45. if !ok {
  46. if dockerCli.ConfigFile().Plugins["compose"]["build"] == "bake" {
  47. b, ok = "true", true
  48. }
  49. }
  50. if !ok {
  51. return false, nil
  52. }
  53. bake, err := strconv.ParseBool(b)
  54. if err != nil {
  55. return false, err
  56. }
  57. if !bake {
  58. return false, nil
  59. }
  60. enabled, err := dockerCli.BuildKitEnabled()
  61. if err != nil {
  62. return false, err
  63. }
  64. if !enabled {
  65. logrus.Warnf("Docker Compose is configured to build using Bake, but buildkit isn't enabled")
  66. }
  67. _, err = manager.GetPlugin("buildx", dockerCli, &cobra.Command{})
  68. if err != nil {
  69. if manager.IsNotFound(err) {
  70. logrus.Warnf("Docker Compose is configured to build using Bake, but buildx isn't installed")
  71. return false, nil
  72. }
  73. return false, err
  74. }
  75. return true, err
  76. }
  77. // We _could_ use bake.* types from github.com/docker/buildx but long term plan is to remove buildx as a dependency
  78. type bakeConfig struct {
  79. Groups map[string]bakeGroup `json:"group"`
  80. Targets map[string]bakeTarget `json:"target"`
  81. }
  82. type bakeGroup struct {
  83. Targets []string `json:"targets"`
  84. }
  85. type bakeTarget struct {
  86. Context string `json:"context,omitempty"`
  87. Contexts map[string]string `json:"contexts,omitempty"`
  88. Dockerfile string `json:"dockerfile,omitempty"`
  89. DockerfileInline string `json:"dockerfile-inline,omitempty"`
  90. Args map[string]string `json:"args,omitempty"`
  91. Labels map[string]string `json:"labels,omitempty"`
  92. Tags []string `json:"tags,omitempty"`
  93. CacheFrom []string `json:"cache-from,omitempty"`
  94. CacheTo []string `json:"cache-to,omitempty"`
  95. Secrets []string `json:"secret,omitempty"`
  96. SSH []string `json:"ssh,omitempty"`
  97. Platforms []string `json:"platforms,omitempty"`
  98. Target string `json:"target,omitempty"`
  99. Pull bool `json:"pull,omitempty"`
  100. NoCache bool `json:"no-cache,omitempty"`
  101. ShmSize types.UnitBytes `json:"shm-size,omitempty"`
  102. Ulimits []string `json:"ulimits,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. }
  110. func (s *composeService) doBuildBake(ctx context.Context, project *types.Project, serviceToBeBuild types.Services, options api.BuildOptions) (map[string]string, error) { //nolint:gocyclo
  111. cw := progress.ContextWriter(ctx)
  112. for name := range serviceToBeBuild {
  113. cw.Event(progress.BuildingEvent(name))
  114. }
  115. eg := errgroup.Group{}
  116. ch := make(chan *client.SolveStatus)
  117. display, err := progressui.NewDisplay(os.Stdout, progressui.DisplayMode(options.Progress))
  118. if err != nil {
  119. return nil, err
  120. }
  121. eg.Go(func() error {
  122. _, err := display.UpdateFrom(ctx, ch)
  123. return err
  124. })
  125. cfg := bakeConfig{
  126. Groups: map[string]bakeGroup{},
  127. Targets: map[string]bakeTarget{},
  128. }
  129. var group bakeGroup
  130. var privileged bool
  131. for serviceName, service := range serviceToBeBuild {
  132. if service.Build == nil {
  133. continue
  134. }
  135. build := *service.Build
  136. args := types.Mapping{}
  137. for k, v := range resolveAndMergeBuildArgs(s.dockerCli, project, service, options) {
  138. if v == nil {
  139. continue
  140. }
  141. args[k] = *v
  142. }
  143. image := api.GetImageNameOrDefault(service, project.Name)
  144. entitlements := build.Entitlements
  145. if slices.Contains(build.Entitlements, "security.insecure") {
  146. privileged = true
  147. }
  148. if build.Privileged {
  149. entitlements = append(entitlements, "security.insecure")
  150. privileged = true
  151. }
  152. outputs := []string{"type=docker"}
  153. if options.Push && service.Image != "" {
  154. outputs = append(outputs, "type=image,push=true")
  155. }
  156. cfg.Targets[serviceName] = bakeTarget{
  157. Context: build.Context,
  158. Contexts: additionalContexts(build.AdditionalContexts, service.DependsOn, options.Compatibility),
  159. Dockerfile: dockerFilePath(build.Context, build.Dockerfile),
  160. DockerfileInline: build.DockerfileInline,
  161. Args: args,
  162. Labels: build.Labels,
  163. Tags: append(build.Tags, image),
  164. CacheFrom: build.CacheFrom,
  165. // CacheTo: TODO
  166. Platforms: build.Platforms,
  167. Target: build.Target,
  168. Secrets: toBakeSecrets(project, build.Secrets),
  169. SSH: toBakeSSH(append(build.SSH, options.SSHs...)),
  170. Pull: options.Pull,
  171. NoCache: options.NoCache,
  172. ShmSize: build.ShmSize,
  173. Ulimits: toBakeUlimits(build.Ulimits),
  174. Entitlements: entitlements,
  175. Outputs: outputs,
  176. }
  177. group.Targets = append(group.Targets, serviceName)
  178. }
  179. cfg.Groups["default"] = group
  180. b, err := json.Marshal(cfg)
  181. if err != nil {
  182. return nil, err
  183. }
  184. metadata, err := os.CreateTemp(os.TempDir(), "compose")
  185. if err != nil {
  186. return nil, err
  187. }
  188. buildx, err := manager.GetPlugin("buildx", s.dockerCli, &cobra.Command{})
  189. if err != nil {
  190. return nil, err
  191. }
  192. args := []string{"bake", "--file", "-", "--progress", "rawjson", "--metadata-file", metadata.Name()}
  193. mustAllow := buildx.Version != "" && versions.GreaterThanOrEqualTo(buildx.Version[1:], "0.17.0")
  194. if privileged && mustAllow {
  195. args = append(args, "--allow", "security.insecure")
  196. }
  197. cmd := exec.CommandContext(ctx, buildx.Path, args...)
  198. // Remove DOCKER_CLI_PLUGIN... variable so buildx can detect it run standalone
  199. cmd.Env = filter(os.Environ(), manager.ReexecEnvvar)
  200. // Use docker/cli mechanism to propagate termination signal to child process
  201. server, err := socket.NewPluginServer(nil)
  202. if err != nil {
  203. defer server.Close() //nolint:errcheck
  204. cmd.Cancel = server.Close
  205. cmd.Env = replace(cmd.Env, socket.EnvKey, server.Addr().String())
  206. }
  207. cmd.Env = append(cmd.Env, fmt.Sprintf("DOCKER_CONTEXT=%s", s.dockerCli.CurrentContext()))
  208. // propagate opentelemetry context to child process, see https://github.com/open-telemetry/oteps/blob/main/text/0258-env-context-baggage-carriers.md
  209. carrier := propagation.MapCarrier{}
  210. otel.GetTextMapPropagator().Inject(ctx, &carrier)
  211. cmd.Env = append(cmd.Env, types.Mapping(carrier).Values()...)
  212. cmd.Stdout = s.stdout()
  213. cmd.Stdin = bytes.NewBuffer(b)
  214. pipe, err := cmd.StderrPipe()
  215. if err != nil {
  216. return nil, err
  217. }
  218. err = cmd.Start()
  219. if err != nil {
  220. return nil, err
  221. }
  222. eg.Go(cmd.Wait)
  223. for {
  224. decoder := json.NewDecoder(pipe)
  225. var s client.SolveStatus
  226. err := decoder.Decode(&s)
  227. if err != nil {
  228. if errors.Is(err, io.EOF) {
  229. break
  230. }
  231. // bake displays build details at the end of a build, which isn't a json SolveStatus
  232. continue
  233. }
  234. ch <- &s
  235. }
  236. close(ch) // stop build progress UI
  237. err = eg.Wait()
  238. if err != nil {
  239. return nil, err
  240. }
  241. b, err = os.ReadFile(metadata.Name())
  242. if err != nil {
  243. return nil, err
  244. }
  245. var md bakeMetadata
  246. err = json.Unmarshal(b, &md)
  247. if err != nil {
  248. return nil, err
  249. }
  250. results := map[string]string{}
  251. for name, m := range md {
  252. results[name] = m.Digest
  253. cw.Event(progress.BuiltEvent(name))
  254. }
  255. return results, nil
  256. }
  257. func additionalContexts(contexts types.Mapping, dependencies types.DependsOnConfig, compatibility bool) map[string]string {
  258. ac := map[string]string{}
  259. if compatibility {
  260. for name := range dependencies {
  261. ac[name] = "target:" + name
  262. }
  263. }
  264. for k, v := range contexts {
  265. ac[k] = v
  266. }
  267. return ac
  268. }
  269. func toBakeUlimits(ulimits map[string]*types.UlimitsConfig) []string {
  270. s := []string{}
  271. for u, l := range ulimits {
  272. if l.Single > 0 {
  273. s = append(s, fmt.Sprintf("%s=%d", u, l.Single))
  274. } else {
  275. s = append(s, fmt.Sprintf("%s=%d:%d", u, l.Soft, l.Hard))
  276. }
  277. }
  278. return s
  279. }
  280. func toBakeSSH(ssh types.SSHConfig) []string {
  281. var s []string
  282. for _, key := range ssh {
  283. s = append(s, fmt.Sprintf("%s=%s", key.ID, key.Path))
  284. }
  285. return s
  286. }
  287. func toBakeSecrets(project *types.Project, secrets []types.ServiceSecretConfig) []string {
  288. var s []string
  289. for _, ref := range secrets {
  290. def := project.Secrets[ref.Source]
  291. target := ref.Target
  292. if target == "" {
  293. target = ref.Source
  294. }
  295. switch {
  296. case def.Environment != "":
  297. s = append(s, fmt.Sprintf("id=%s,type=env,env=%s", target, def.Environment))
  298. case def.File != "":
  299. s = append(s, fmt.Sprintf("id=%s,type=file,src=%s", target, def.File))
  300. }
  301. }
  302. return s
  303. }
  304. func filter(environ []string, variable string) []string {
  305. prefix := variable + "="
  306. filtered := make([]string, 0, len(environ))
  307. for _, val := range environ {
  308. if !strings.HasPrefix(val, prefix) {
  309. filtered = append(filtered, val)
  310. }
  311. }
  312. return filtered
  313. }
  314. func replace(environ []string, variable, value string) []string {
  315. filtered := filter(environ, variable)
  316. return append(filtered, fmt.Sprintf("%s=%s", variable, value))
  317. }
  318. func dockerFilePath(ctxName string, dockerfile string) string {
  319. if dockerfile == "" {
  320. return ""
  321. }
  322. if urlutil.IsGitURL(ctxName) || filepath.IsAbs(dockerfile) {
  323. return dockerfile
  324. }
  325. return filepath.Join(ctxName, dockerfile)
  326. }