From 2c215cfb0fb1d19d1a26e094249bf178b2851b28 Mon Sep 17 00:00:00 2001 From: Yoriyasu Yano <430092+yorinasub17@users.noreply.github.com> Date: Sat, 7 Aug 2021 08:01:21 -0500 Subject: [PATCH] Support multiarch docker image builds (#970) * Support multiarch docker image builds * Make sure to install docker buildx plugin in circleci * Use newer ubuntu and docker for buildx * Remove test * Fix build * Make sure to check against the right name * Actually fix ecr repo tests --- .circleci/config.yml | 24 ++++++++- modules/aws/ecr_test.go | 13 +++-- modules/docker/build.go | 99 +++++++++++++++++++++++++++++++----- modules/docker/build_test.go | 18 +++++++ modules/docker/push.go | 25 +++++++++ 5 files changed, 160 insertions(+), 19 deletions(-) create mode 100644 modules/docker/push.go diff --git a/.circleci/config.yml b/.circleci/config.yml index 1cf3eccc2..efdb636c9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -15,6 +15,13 @@ env: &env BIN_BUILD_PARALLELISM: 3 defaults: &defaults + machine: + enabled: true + image: "ubuntu-2004:202107-02" + <<: *env + +# minikube setup requires ubuntu16.04 +minikube_defaults: &minikube_defaults machine: enabled: true image: "ubuntu-1604:201903-01" @@ -53,6 +60,17 @@ install_gruntwork_utils: &install_gruntwork_utils sudo ln -s /usr/local/go/bin/go /usr/bin/go echo "The installed version of Go is now $(go version)" +install_docker_buildx: &install_docker_buildx + name: install docker buildx + command: | + curl -sLO https://github.com/docker/buildx/releases/download/v0.6.1/buildx-v0.6.1.linux-amd64 + mkdir -p ~/.docker/cli-plugins + mv buildx-v0.6.1.linux-amd64 ~/.docker/cli-plugins/docker-buildx + chmod a+x ~/.docker/cli-plugins/docker-buildx + + # Verify buildx is available + docker buildx create --use + configure_environment_for_gcp: &configure_environment_for_gcp name: configure environment for gcp command: | @@ -143,6 +161,8 @@ jobs: - run: <<: *install_gruntwork_utils + - run: + <<: *install_docker_buildx # The weird way you have to set PATH in Circle 2.0 - run: echo 'export PATH=$HOME/.local/bin:$HOME/terraform:$HOME/packer:$PATH' >> $BASH_ENV @@ -209,7 +229,7 @@ jobs: path: /tmp/logs kubernetes_test: - <<: *defaults + <<: *minikube_defaults steps: - attach_workspace: at: /home/circleci @@ -245,7 +265,7 @@ jobs: helm_test: - <<: *defaults + <<: *minikube_defaults steps: - attach_workspace: at: /home/circleci diff --git a/modules/aws/ecr_test.go b/modules/aws/ecr_test.go index 4ed559d66..b21652192 100644 --- a/modules/aws/ecr_test.go +++ b/modules/aws/ecr_test.go @@ -1,9 +1,12 @@ package aws import ( + "fmt" + "strings" "testing" "github.com/aws/aws-sdk-go/aws" + "github.com/gruntwork-io/terratest/modules/random" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -12,14 +15,14 @@ func TestEcrRepo(t *testing.T) { t.Parallel() region := GetRandomStableRegion(t, nil, nil) - repo1, err := CreateECRRepoE(t, region, "terratest") + ecrRepoName := fmt.Sprintf("terratest%s", strings.ToLower(random.UniqueId())) + repo1, err := CreateECRRepoE(t, region, ecrRepoName) defer DeleteECRRepo(t, region, repo1) - require.NoError(t, err) - assert.Equal(t, "terratest", aws.StringValue(repo1.RepositoryName)) - repo2, err := GetECRRepoE(t, region, "terratest") + assert.Equal(t, ecrRepoName, aws.StringValue(repo1.RepositoryName)) + repo2, err := GetECRRepoE(t, region, ecrRepoName) require.NoError(t, err) - assert.Equal(t, "terratest", aws.StringValue(repo2.RepositoryName)) + assert.Equal(t, ecrRepoName, aws.StringValue(repo2.RepositoryName)) } diff --git a/modules/docker/build.go b/modules/docker/build.go index c1025d2b9..c31db19ed 100644 --- a/modules/docker/build.go +++ b/modules/docker/build.go @@ -1,9 +1,12 @@ package docker import ( + "strings" + "github.com/gruntwork-io/terratest/modules/logger" "github.com/gruntwork-io/terratest/modules/shell" "github.com/gruntwork-io/terratest/modules/testing" + "github.com/hashicorp/go-multierror" "github.com/stretchr/testify/require" ) @@ -18,6 +21,27 @@ type BuildOptions struct { // Target build arg to pass to the 'docker build' command Target string + // All architectures to target in a multiarch build. Configuring this variable will cause terratest to use docker + // buildx to construct multiarch images. + // You can read more about multiarch docker builds in the official documentation for buildx: + // https://docs.docker.com/buildx/working-with-buildx/ + // NOTE: This list does not automatically include the current platform. For example, if you are building images on + // an Apple Silicon based MacBook, and you configure this variable to []string{"linux/amd64"} to build an amd64 + // image, the buildx command will not automatically include linux/arm64 - you must include that explicitly. + Architectures []string + + // Whether or not to push images directly to the registry on build. Note that for multiarch images (Architectures is + // not empty), this must be true to ensure availability of all architectures - only the image for the current + // platform will be loaded into the daemon (due to a limitation of the docker daemon), so you won't be able to run a + // `docker push` command later to push the multiarch image. + // See https://github.com/moby/moby/pull/38738 for more info on the limitation of multiarch images in docker daemon. + Push bool + + // Whether or not to load the image into the docker daemon at the end of a multiarch build so that it can be used + // locally. Note that this is only used when Architectures is set, and assumes the current architecture is already + // included in the Architectures list. + Load bool + // Custom CLI options that will be passed as-is to the 'docker build' command. This is an "escape hatch" that allows // Terratest to not have to support every single command-line option offered by the 'docker build' command, and // solely focus on the most important ones. @@ -37,25 +61,77 @@ func Build(t testing.TestingT, path string, options *BuildOptions) { func BuildE(t testing.TestingT, path string, options *BuildOptions) error { options.Logger.Logf(t, "Running 'docker build' in %s", path) - args, err := formatDockerBuildArgs(path, options) - if err != nil { - return err - } - cmd := shell.Command{ Command: "docker", - Args: args, + Args: formatDockerBuildArgs(path, options), Logger: options.Logger, } - _, buildErr := shell.RunCommandAndGetOutputE(t, cmd) - return buildErr + if err := shell.RunCommandE(t, cmd); err != nil { + return err + } + + // For non multiarch images, we need to call docker push for each tag since build does not have a push option like + // buildx. + if len(options.Architectures) == 0 && options.Push { + var errorsOccurred = new(multierror.Error) + for _, tag := range options.Tags { + if err := PushE(t, options.Logger, tag); err != nil { + options.Logger.Logf(t, "ERROR: error pushing tag %s", tag) + errorsOccurred = multierror.Append(err) + } + } + return errorsOccurred.ErrorOrNil() + } + + // For multiarch images, if a load is requested call the load command to export the built image into the daemon. + if len(options.Architectures) > 0 && options.Load { + loadCmd := shell.Command{ + Command: "docker", + Args: formatDockerBuildxLoadArgs(path, options), + Logger: options.Logger, + } + return shell.RunCommandE(t, loadCmd) + } + + return nil } // formatDockerBuildArgs formats the arguments for the 'docker build' command. -func formatDockerBuildArgs(path string, options *BuildOptions) ([]string, error) { - args := []string{"build"} +func formatDockerBuildArgs(path string, options *BuildOptions) []string { + args := []string{} + + if len(options.Architectures) > 0 { + args = append( + args, + "buildx", + "build", + "--platform", + strings.Join(options.Architectures, ","), + ) + if options.Push { + args = append(args, "--push") + } + } else { + args = append(args, "build") + } + + return append(args, formatDockerBuildBaseArgs(path, options)...) +} +// formatDockerBuildxLoadArgs formats the arguments for calling load on the 'docker buildx' command. +func formatDockerBuildxLoadArgs(path string, options *BuildOptions) []string { + args := []string{ + "buildx", + "build", + "--load", + } + return append(args, formatDockerBuildBaseArgs(path, options)...) +} + +// formatDockerBuildBaseArgs formats the common args for the build command, both for `build` and `buildx`. +func formatDockerBuildBaseArgs(path string, options *BuildOptions) []string { + args := []string{} for _, tag := range options.Tags { args = append(args, "--tag", tag) } @@ -71,6 +147,5 @@ func formatDockerBuildArgs(path string, options *BuildOptions) ([]string, error) args = append(args, options.OtherOptions...) args = append(args, path) - - return args, nil + return args } diff --git a/modules/docker/build_test.go b/modules/docker/build_test.go index 396ef7bd6..94319c7a0 100644 --- a/modules/docker/build_test.go +++ b/modules/docker/build_test.go @@ -24,6 +24,24 @@ func TestBuild(t *testing.T) { require.Contains(t, out, text) } +func TestBuildMultiArch(t *testing.T) { + t.Parallel() + + tag := "gruntwork-io/test-image:v1" + text := "Hello, World!" + + options := &BuildOptions{ + Tags: []string{tag}, + BuildArgs: []string{fmt.Sprintf("text=%s", text)}, + Architectures: []string{"linux/arm64", "linux/amd64"}, + Load: true, + } + + Build(t, "../../test/fixtures/docker", options) + out := Run(t, tag, &RunOptions{Remove: true}) + require.Contains(t, out, text) +} + func TestBuildWithTarget(t *testing.T) { t.Parallel() diff --git a/modules/docker/push.go b/modules/docker/push.go new file mode 100644 index 000000000..af0659b54 --- /dev/null +++ b/modules/docker/push.go @@ -0,0 +1,25 @@ +package docker + +import ( + "github.com/gruntwork-io/terratest/modules/logger" + "github.com/gruntwork-io/terratest/modules/shell" + "github.com/gruntwork-io/terratest/modules/testing" + "github.com/stretchr/testify/require" +) + +// Push runs the 'docker push' command to push the given tag. This will fail the test if there are any errors. +func Push(t testing.TestingT, logger *logger.Logger, tag string) { + require.NoError(t, PushE(t, logger, tag)) +} + +// PushE runs the 'docker push' command to push the given tag. +func PushE(t testing.TestingT, logger *logger.Logger, tag string) error { + logger.Logf(t, "Running 'docker push' for tag %s", tag) + + cmd := shell.Command{ + Command: "docker", + Args: []string{"push", tag}, + Logger: logger, + } + return shell.RunCommandE(t, cmd) +}