builder.go 8.9 KB

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