Skip to content

Commit

Permalink
feat: support customizing the Docker build command (#1931)
Browse files Browse the repository at this point in the history
* break: add BuildOptions method in order to support advanced customisations at build time

* chore: add a test for not finding the given target in the Dockerfile

* chore: make sure the auth configs are always added

* docs: document the advanced usage

* chore: make sure the first tag is not overriden by the user

* chore: make sure the build context and dockerfile is not overriden

* fix: lint
  • Loading branch information
mdelapenya committed Nov 29, 2023
1 parent 6d1dd3e commit 3a513f3
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 20 deletions.
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"]

0 comments on commit 3a513f3

Please sign in to comment.