builder.go 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. package docker
  2. import (
  3. "archive/tar"
  4. "bytes"
  5. "context"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "io/ioutil"
  10. "os"
  11. "os/exec"
  12. "path/filepath"
  13. "time"
  14. "github.com/docker/docker/api/types"
  15. "github.com/docker/docker/pkg/archive"
  16. "github.com/docker/docker/pkg/fileutils"
  17. "github.com/moby/buildkit/frontend/dockerfile/dockerignore"
  18. "github.com/moby/moby/pkg/jsonmessage"
  19. "github.com/moby/moby/pkg/stringid"
  20. "github.com/moby/term"
  21. "mvdan.cc/sh/v3/shell"
  22. )
  23. type BuildOpts struct {
  24. ImageRepo string
  25. Tag string
  26. CurrentTag string
  27. BuildContext string
  28. DockerfilePath string
  29. IsDockerfileInCtx bool
  30. UseCache bool
  31. Env map[string]string
  32. LogFile *os.File
  33. }
  34. // BuildLocal
  35. func (a *Agent) BuildLocal(ctx context.Context, opts *BuildOpts) (err error) {
  36. if os.Getenv("DOCKER_BUILDKIT") == "1" {
  37. return buildLocalWithBuildkit(ctx, *opts)
  38. }
  39. dockerfilePath := opts.DockerfilePath
  40. // attempt to read dockerignore file and paths
  41. dockerIgnoreBytes, _ := os.ReadFile(".dockerignore")
  42. var excludes []string
  43. if len(dockerIgnoreBytes) != 0 {
  44. excludes, err = dockerignore.ReadAll(bytes.NewBuffer(dockerIgnoreBytes))
  45. if err != nil {
  46. return err
  47. }
  48. }
  49. excludes = trimBuildFilesFromExcludes(excludes, dockerfilePath)
  50. tar, err := archive.TarWithOptions(opts.BuildContext, &archive.TarOptions{
  51. ExcludePatterns: excludes,
  52. })
  53. if err != nil {
  54. return err
  55. }
  56. var writer io.Writer = os.Stderr
  57. if opts.LogFile != nil {
  58. writer = io.MultiWriter(os.Stderr, opts.LogFile)
  59. }
  60. if !opts.IsDockerfileInCtx {
  61. dockerfileCtx, err := os.Open(dockerfilePath)
  62. if err != nil {
  63. return fmt.Errorf("unable to open Dockerfile: %v", err)
  64. }
  65. defer dockerfileCtx.Close()
  66. // add the dockerfile to the build context
  67. tar, dockerfilePath, err = AddDockerfileToBuildContext(dockerfileCtx, tar)
  68. if err != nil {
  69. return err
  70. }
  71. }
  72. buildArgs := make(map[string]*string)
  73. for key, val := range opts.Env {
  74. valCopy := val
  75. buildArgs[key] = &valCopy
  76. }
  77. // attach BUILDKIT_INLINE_CACHE=1 by default, to take advantage of caching
  78. inlineCacheVal := "1"
  79. buildArgs["BUILDKIT_INLINE_CACHE"] = &inlineCacheVal
  80. out, err := a.ImageBuild(ctx, tar, types.ImageBuildOptions{
  81. Dockerfile: dockerfilePath,
  82. BuildArgs: buildArgs,
  83. Tags: []string{
  84. fmt.Sprintf("%s:%s", opts.ImageRepo, opts.Tag),
  85. },
  86. CacheFrom: []string{
  87. fmt.Sprintf("%s:%s", opts.ImageRepo, opts.CurrentTag),
  88. },
  89. Remove: true,
  90. Platform: "linux/amd64",
  91. })
  92. if err != nil {
  93. return err
  94. }
  95. defer out.Body.Close()
  96. termFd, isTerm := term.GetFdInfo(os.Stderr)
  97. return jsonmessage.DisplayJSONMessagesStream(out.Body, writer, termFd, isTerm, nil)
  98. }
  99. func trimBuildFilesFromExcludes(excludes []string, dockerfile string) []string {
  100. if keep, _ := fileutils.Matches(".dockerignore", excludes); keep {
  101. excludes = append(excludes, "!.dockerignore")
  102. }
  103. if keep, _ := fileutils.Matches(dockerfile, excludes); keep {
  104. excludes = append(excludes, "!"+dockerfile)
  105. }
  106. return excludes
  107. }
  108. // AddDockerfileToBuildContext from a ReadCloser, returns a new archive and
  109. // the relative path to the dockerfile in the context.
  110. func AddDockerfileToBuildContext(dockerfileCtx io.ReadCloser, buildCtx io.ReadCloser) (io.ReadCloser, string, error) {
  111. file, err := ioutil.ReadAll(dockerfileCtx)
  112. dockerfileCtx.Close()
  113. if err != nil {
  114. return nil, "", err
  115. }
  116. now := time.Now()
  117. hdrTmpl := &tar.Header{
  118. Mode: 0o600,
  119. Uid: 0,
  120. Gid: 0,
  121. ModTime: now,
  122. Typeflag: tar.TypeReg,
  123. AccessTime: now,
  124. ChangeTime: now,
  125. }
  126. randomName := ".dockerfile." + stringid.GenerateRandomID()[:20]
  127. buildCtx = archive.ReplaceFileTarWrapper(buildCtx, map[string]archive.TarModifierFunc{
  128. // Add the dockerfile with a random filename
  129. randomName: func(_ string, h *tar.Header, content io.Reader) (*tar.Header, []byte, error) {
  130. return hdrTmpl, file, nil
  131. },
  132. // Update .dockerignore to include the random filename
  133. ".dockerignore": func(_ string, h *tar.Header, content io.Reader) (*tar.Header, []byte, error) {
  134. if h == nil {
  135. h = hdrTmpl
  136. }
  137. b := &bytes.Buffer{}
  138. if content != nil {
  139. if _, err := b.ReadFrom(content); err != nil {
  140. return nil, nil, err
  141. }
  142. } else {
  143. b.WriteString(".dockerignore")
  144. }
  145. b.WriteString("\n" + randomName + "\n")
  146. return h, b.Bytes(), nil
  147. },
  148. })
  149. return buildCtx, randomName, nil
  150. }
  151. func buildLocalWithBuildkit(ctx context.Context, opts BuildOpts) error {
  152. if _, err := exec.LookPath("docker"); err != nil {
  153. return fmt.Errorf("unable to find docker binary in PATH for buildkit build: %w", err)
  154. }
  155. // prepare Dockerfile if the location isn't inside the build context
  156. if !opts.IsDockerfileInCtx {
  157. if err := injectDockerfileIntoBuildContext(opts.BuildContext, opts.DockerfilePath); err != nil {
  158. return fmt.Errorf("unable to inject Dockerfile into build context: %w", err)
  159. }
  160. }
  161. // parse any arguments
  162. var extraDockerArgs []string
  163. if buildkitArgs := os.Getenv("PORTER_BUILDKIT_ARGS"); buildkitArgs != "" {
  164. parsedFields, err := shell.Fields(buildkitArgs, func(name string) string {
  165. return os.Getenv(name)
  166. })
  167. if err != nil {
  168. return fmt.Errorf("error while parsing buildkit args: %w", err)
  169. }
  170. extraDockerArgs = parsedFields
  171. }
  172. commandArgs := []string{
  173. "build",
  174. "--platform", "linux/amd64",
  175. "--tag", fmt.Sprintf("%s:%s", opts.ImageRepo, opts.Tag),
  176. "--cache-from", fmt.Sprintf("%s:%s", opts.ImageRepo, opts.CurrentTag),
  177. }
  178. for key, val := range opts.Env {
  179. commandArgs = append(commandArgs, "--build-arg", fmt.Sprintf("%s=%s", key, val))
  180. }
  181. commandArgs = append(commandArgs, extraDockerArgs...)
  182. // note: the path _must_ be the last argument
  183. commandArgs = append(commandArgs, opts.BuildContext)
  184. stdoutWriters := []io.Writer{os.Stdout}
  185. stderrWriters := []io.Writer{os.Stderr}
  186. if opts.LogFile != nil {
  187. stdoutWriters = append(stdoutWriters, opts.LogFile)
  188. stderrWriters = append(stderrWriters, opts.LogFile)
  189. }
  190. // #nosec G204 - The command is meant to be variable
  191. cmd := exec.CommandContext(ctx, "docker", commandArgs...)
  192. cmd.Dir = opts.BuildContext
  193. cmd.Stdout = io.MultiWriter(stdoutWriters...)
  194. cmd.Stderr = io.MultiWriter(stderrWriters...)
  195. if err := cmd.Start(); err != nil {
  196. return fmt.Errorf("unable to start the build command: %w", err)
  197. }
  198. exitCode := 0
  199. execErr := cmd.Wait()
  200. if execErr != nil {
  201. if exitError, ok := execErr.(*exec.ExitError); ok {
  202. exitCode = exitError.ExitCode()
  203. }
  204. }
  205. if err := ctx.Err(); err != nil && err == context.Canceled {
  206. return fmt.Errorf("build command canceled: %w", ctx.Err())
  207. }
  208. if err := ctx.Err(); err != nil {
  209. return fmt.Errorf("error while running build: %w", err)
  210. }
  211. if exitCode != 0 {
  212. return fmt.Errorf("build exited with non-zero exit code %d", exitCode)
  213. }
  214. if execErr != nil {
  215. return fmt.Errorf("error while running build: %w", execErr)
  216. }
  217. return nil
  218. }
  219. func injectDockerfileIntoBuildContext(buildContext string, dockerfilePath string) error {
  220. randomName := ".dockerfile." + stringid.GenerateRandomID()[:20]
  221. data := map[string]func() ([]byte, error){
  222. randomName: func() ([]byte, error) {
  223. return os.ReadFile(filepath.Clean(dockerfilePath))
  224. },
  225. ".dockerignore": func() ([]byte, error) {
  226. dockerignorePath := filepath.Join(buildContext, ".dockerignore")
  227. dockerignorePath = filepath.Clean(dockerignorePath)
  228. if _, err := os.Stat(dockerignorePath); errors.Is(err, os.ErrNotExist) {
  229. if err := os.WriteFile(dockerignorePath, []byte{}, os.FileMode(0o600)); err != nil {
  230. return []byte{}, err
  231. }
  232. }
  233. data, err := os.ReadFile(dockerignorePath)
  234. if err != nil {
  235. return data, err
  236. }
  237. b := bytes.NewBuffer(data)
  238. b.WriteString(".dockerignore")
  239. b.WriteString("\n" + randomName + "\n")
  240. return b.Bytes(), nil
  241. },
  242. }
  243. for filename, fn := range data {
  244. bytes, err := fn()
  245. if err != nil {
  246. return err
  247. }
  248. return os.WriteFile(filepath.Join(buildContext, filename), bytes, os.FileMode(0o600))
  249. }
  250. return nil
  251. }