Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support customizing the Docker build command #1931

Merged
merged 7 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
59 changes: 58 additions & 1 deletion container.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,15 @@ type Container interface {

// ImageBuildInfo defines what is needed to build an image
type ImageBuildInfo interface {
BuildOptions() (types.ImageBuildOptions, error) // converts the ImageBuildInfo to a types.ImageBuildOptions
GetContext() (io.Reader, error) // the path to the build context
GetDockerfile() string // the relative path to the Dockerfile, including the fileitself
GetRepo() string // get repo label for image
GetTag() string // get tag label for image
ShouldPrintBuildLog() bool // allow build log to be printed to stdout
ShouldBuildImage() bool // return true if the image needs to be built
GetBuildArgs() map[string]*string // return the environment args used to build the from Dockerfile
GetAuthConfigs() map[string]registry.AuthConfig // return the auth configs to be able to pull from an authenticated docker registry
GetAuthConfigs() map[string]registry.AuthConfig // Deprecated. Testcontainers will detect registry credentials automatically. Return the auth configs to be able to pull from an authenticated docker registry
}

// FromDockerfile represents the parameters needed to build an image from a Dockerfile
Expand All @@ -90,6 +91,10 @@ type FromDockerfile struct {
// container image. Useful for images that are built from a Dockerfile and take a
// long time to build. Keeping the image also Docker to reuse it.
KeepImage bool
// BuildOptionsModifier Modifier for the build options before image build. Use it for
// advanced configurations while building the image. Please consider that the modifier
// is called after the default build options are set.
BuildOptionsModifier func(*types.ImageBuildOptions)
}

type ContainerFile struct {
Expand Down Expand Up @@ -259,8 +264,14 @@ func (c *ContainerRequest) GetTag() string {
return strings.ToLower(t)
}

// Deprecated: Testcontainers will detect registry credentials automatically, and it will be removed in the next major release
// GetAuthConfigs returns the auth configs to be able to pull from an authenticated docker registry
func (c *ContainerRequest) GetAuthConfigs() map[string]registry.AuthConfig {
return getAuthConfigsFromDockerfile(c)
}

// getAuthConfigsFromDockerfile returns the auth configs to be able to pull from an authenticated docker registry
func getAuthConfigsFromDockerfile(c *ContainerRequest) map[string]registry.AuthConfig {
images, err := testcontainersdocker.ExtractImagesFromDockerfile(filepath.Join(c.Context, c.GetDockerfile()), c.GetBuildArgs())
if err != nil {
return map[string]registry.AuthConfig{}
Expand Down Expand Up @@ -291,6 +302,52 @@ func (c *ContainerRequest) ShouldPrintBuildLog() bool {
return c.FromDockerfile.PrintBuildLog
}

// BuildOptions returns the image build options when building a Docker image from a Dockerfile.
// It will apply some defaults and finally call the BuildOptionsModifier from the FromDockerfile struct,
// if set.
func (c *ContainerRequest) BuildOptions() (types.ImageBuildOptions, error) {
buildOptions := types.ImageBuildOptions{
Remove: true,
ForceRemove: true,
}

if c.FromDockerfile.BuildOptionsModifier != nil {
c.FromDockerfile.BuildOptionsModifier(&buildOptions)
}

// apply mandatory values after the modifier
buildOptions.BuildArgs = c.GetBuildArgs()
buildOptions.Dockerfile = c.GetDockerfile()

buildContext, err := c.GetContext()
if err != nil {
return buildOptions, err
}
buildOptions.Context = buildContext

// Make sure the auth configs from the Dockerfile are set right after the user-defined build options.
authsFromDockerfile := getAuthConfigsFromDockerfile(c)

if buildOptions.AuthConfigs == nil {
buildOptions.AuthConfigs = map[string]registry.AuthConfig{}
}

for registry, authConfig := range authsFromDockerfile {
buildOptions.AuthConfigs[registry] = authConfig
}

// make sure the first tag is the one defined in the ContainerRequest
tag := fmt.Sprintf("%s:%s", c.GetRepo(), c.GetTag())
if len(buildOptions.Tags) > 0 {
// prepend the tag
buildOptions.Tags = append([]string{tag}, buildOptions.Tags...)
} else {
buildOptions.Tags = []string{tag}
}

return buildOptions, nil
}

func (c *ContainerRequest) validateContextAndImage() error {
if c.FromDockerfile.Context != "" && c.Image != "" {
return errors.New("you cannot specify both an Image and Context in a ContainerRequest")
Expand Down
26 changes: 7 additions & 19 deletions docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -782,27 +782,14 @@ var _ ContainerProvider = (*DockerProvider)(nil)

// BuildImage will build and image from context and Dockerfile, then return the tag
func (p *DockerProvider) BuildImage(ctx context.Context, img ImageBuildInfo) (string, error) {
repoTag := fmt.Sprintf("%s:%s", img.GetRepo(), img.GetTag())

buildContext, err := img.GetContext()
if err != nil {
return "", err
}

buildOptions := types.ImageBuildOptions{
BuildArgs: img.GetBuildArgs(),
Dockerfile: img.GetDockerfile(),
AuthConfigs: img.GetAuthConfigs(),
Context: buildContext,
Tags: []string{repoTag},
Remove: true,
ForceRemove: true,
}
buildOptions, err := img.BuildOptions()

var buildError error
var resp types.ImageBuildResponse
err = backoff.Retry(func() error {
resp, err = p.client.ImageBuild(ctx, buildContext, buildOptions)
resp, err = p.client.ImageBuild(ctx, buildOptions.Context, buildOptions)
if err != nil {
buildError = errors.Join(buildError, err)
var enf errdefs.ErrNotFound
if errors.As(err, &enf) {
return backoff.Permanent(err)
Expand All @@ -815,7 +802,7 @@ func (p *DockerProvider) BuildImage(ctx context.Context, img ImageBuildInfo) (st
return nil
}, backoff.WithContext(backoff.NewExponentialBackOff(), ctx))
if err != nil {
return "", err
return "", errors.Join(buildError, err)
}

if img.ShouldPrintBuildLog() {
Expand All @@ -836,7 +823,8 @@ func (p *DockerProvider) BuildImage(ctx context.Context, img ImageBuildInfo) (st

_ = resp.Body.Close()

return repoTag, nil
// the first tag is the one we want
return buildOptions.Tags[0], nil
}

// CreateContainer fulfills a request for a container without starting it
Expand Down
11 changes: 11 additions & 0 deletions docs/features/build_from_dockerfile.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,14 @@ req := ContainerRequest{
},
}
```

## Advanced usage

In the case you need to pass additional arguments to the `docker build` command, you can use the `BuildOptionsModifier` attribute in the `FromDockerfile` struct.

This field holds a function that has access to Docker's ImageBuildOptions type, which is used to build the image. You can use this modifier **on your own risk** to modify the build options with as many options as you need.

<!--codeinclude-->
[Building From a Dockerfile including build options modifier](../../from_dockerfile_test.go) inside_block:buildFromDockerfileWithModifier
[Dockerfile including target](../../testdata/target.Dockerfile)
<!--/codeinclude-->
100 changes: 100 additions & 0 deletions from_dockerfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ package testcontainers

import (
"context"
"fmt"
"io"
"strings"
"testing"
"time"

"github.com/docker/docker/api/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBuildImageFromDockerfile(t *testing.T) {
Expand Down Expand Up @@ -116,3 +120,99 @@ func TestBuildImageFromDockerfile_NoTag(t *testing.T) {
}
})
}

func TestBuildImageFromDockerfile_Target(t *testing.T) {
// there are thre targets: target0, target1 and target2.
for i := 0; i < 3; i++ {
ctx := context.Background()
c, err := GenericContainer(ctx, GenericContainerRequest{
ContainerRequest: ContainerRequest{
FromDockerfile: FromDockerfile{
Context: "testdata",
Dockerfile: "target.Dockerfile",
PrintBuildLog: true,
KeepImage: false,
BuildOptionsModifier: func(buildOptions *types.ImageBuildOptions) {
buildOptions.Target = fmt.Sprintf("target%d", i)
},
},
},
Started: true,
})
require.NoError(t, err)

r, err := c.Logs(ctx)
require.NoError(t, err)

logs, err := io.ReadAll(r)
require.NoError(t, err)

assert.Equal(t, fmt.Sprintf("target%d\n\n", i), string(logs))

t.Cleanup(func() {
require.NoError(t, c.Terminate(ctx))
})
}
}

func ExampleGenericContainer_buildFromDockerfile() {
ctx := context.Background()

// buildFromDockerfileWithModifier {
c, err := GenericContainer(ctx, GenericContainerRequest{
ContainerRequest: ContainerRequest{
FromDockerfile: FromDockerfile{
Context: "testdata",
Dockerfile: "target.Dockerfile",
PrintBuildLog: true,
KeepImage: false,
BuildOptionsModifier: func(buildOptions *types.ImageBuildOptions) {
buildOptions.Target = "target2"
},
},
},
Started: true,
})
// }
if err != nil {
panic(err)
}

r, err := c.Logs(ctx)
if err != nil {
panic(err)
}

logs, err := io.ReadAll(r)
if err != nil {
panic(err)
}

fmt.Println(string(logs))

// Output: target2
}

func TestBuildImageFromDockerfile_TargetDoesNotExist(t *testing.T) {
// the context cancellation will happen with enough time for the build to fail.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

_, err := GenericContainer(ctx, GenericContainerRequest{
ContainerRequest: ContainerRequest{
FromDockerfile: FromDockerfile{
Context: "testdata",
Dockerfile: "target.Dockerfile",
PrintBuildLog: true,
KeepImage: false,
BuildOptionsModifier: func(buildOptions *types.ImageBuildOptions) {
buildOptions.Target = "target-foo"
},
},
},
Started: true,
})
require.Error(t, err)

assert.Contains(t, err.Error(), "failed to reach build target target-foo in Dockerfile")
}
8 changes: 8 additions & 0 deletions testdata/target.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM docker.io/alpine AS target0
CMD ["echo", "target0"]

FROM target0 AS target1
CMD ["echo", "target1"]

FROM target1 AS target2
CMD ["echo", "target2"]