builder.go 11 KB

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