Skip to content

Commit

Permalink
feat: support for replacing images with custom substitutions (testcon…
Browse files Browse the repository at this point in the history
…tainers#1719)

* feat: support for replacing images with custom substitutions

* chore: support handling errors in image substitutor

* chore: add test for substitution errors

* chore: use a more expressive variable name
  • Loading branch information
mdelapenya authored Oct 20, 2023
1 parent 4ec7fa4 commit d49ecc2
Show file tree
Hide file tree
Showing 7 changed files with 167 additions and 12 deletions.
1 change: 1 addition & 0 deletions container.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ type ContainerFile struct {
type ContainerRequest struct {
FromDockerfile
Image string
ImageSubstitutors []ImageSubstitutor
Entrypoint []string
Env map[string]string
ExposedPorts []string // allow specifying protocol info
Expand Down
108 changes: 108 additions & 0 deletions container_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/testcontainers/testcontainers-go/wait"
)
Expand Down Expand Up @@ -324,6 +325,113 @@ func Test_GetLogsFromFailedContainer(t *testing.T) {
}
}

type dockerImageSubstitutor struct{}

func (s dockerImageSubstitutor) Description() string {
return "DockerImageSubstitutor (prepends docker.io)"
}

func (s dockerImageSubstitutor) Substitute(image string) (string, error) {
return "docker.io/" + image, nil
}

// noopImageSubstitutor {
type NoopImageSubstitutor struct{}

// Description returns a description of what is expected from this Substitutor,
// which is used in logs.
func (s NoopImageSubstitutor) Description() string {
return "NoopImageSubstitutor (noop)"
}

// Substitute returns the original image, without any change
func (s NoopImageSubstitutor) Substitute(image string) (string, error) {
return image, nil
}

// }

type errorSubstitutor struct{}

var errSubstitution = errors.New("substitution error")

// Description returns a description of what is expected from this Substitutor,
// which is used in logs.
func (s errorSubstitutor) Description() string {
return "errorSubstitutor"
}

// Substitute returns the original image, but returns an error
func (s errorSubstitutor) Substitute(image string) (string, error) {
return image, errSubstitution
}

func TestImageSubstitutors(t *testing.T) {
tests := []struct {
name string
image string // must be a valid image, as the test will try to create a container from it
substitutors []ImageSubstitutor
expectedImage string
expectedError error
}{
{
name: "No substitutors",
image: "alpine",
expectedImage: "alpine",
},
{
name: "Noop substitutor",
image: "alpine",
substitutors: []ImageSubstitutor{NoopImageSubstitutor{}},
expectedImage: "alpine",
},
{
name: "Prepend namespace",
image: "alpine",
substitutors: []ImageSubstitutor{dockerImageSubstitutor{}},
expectedImage: "docker.io/alpine",
},
{
name: "Substitution with error",
image: "alpine",
substitutors: []ImageSubstitutor{errorSubstitutor{}},
expectedImage: "alpine",
expectedError: errSubstitution,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
req := ContainerRequest{
Image: test.image,
ImageSubstitutors: test.substitutors,
}

container, err := GenericContainer(ctx, GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if test.expectedError != nil {
require.ErrorIs(t, err, test.expectedError)
return
}

if err != nil {
t.Fatal(err)
}
defer func() {
terminateContainerOnEnd(t, ctx, container)
}()

// enforce the concrete type, as GenericContainer returns an interface,
// which will be changed in future implementations of the library
dockerContainer := container.(*DockerContainer)
assert.Equal(t, test.expectedImage, dockerContainer.Image)
})
}
}

func TestShouldStartContainersInParallel(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
t.Cleanup(cancel)
Expand Down
33 changes: 21 additions & 12 deletions docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,8 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque
}
}

imageName := req.Image

env := []string{}
for envKey, envVar := range req.Env {
env = append(env, envKey+"="+envVar)
Expand All @@ -881,7 +883,7 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque

var termSignal chan bool
// the reaper does not need to start a reaper for itself
isReaperContainer := strings.EqualFold(req.Image, reaperImage(reaperOpts.ImageName))
isReaperContainer := strings.EqualFold(imageName, reaperImage(reaperOpts.ImageName))
if !tcConfig.RyukDisabled && !isReaperContainer {
r, err := reuseOrCreateReaper(context.WithValue(ctx, testcontainersdocker.DockerHostContextKey, p.host), testcontainerssession.SessionID(), p, req.ReaperOptions...)
if err != nil {
Expand All @@ -904,17 +906,24 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque
return nil, err
}

var tag string
for _, is := range req.ImageSubstitutors {
modifiedTag, err := is.Substitute(imageName)
if err != nil {
return nil, fmt.Errorf("failed to substitute image %s with %s: %w", imageName, is.Description(), err)
}

p.Logger.Printf("✍🏼 Replacing image with %s. From: %s to %s\n", is.Description(), imageName, modifiedTag)
imageName = modifiedTag
}

var platform *specs.Platform

if req.ShouldBuildImage() {
tag, err = p.BuildImage(ctx, &req)
imageName, err = p.BuildImage(ctx, &req)
if err != nil {
return nil, err
}
} else {
tag = req.Image

if req.ImagePlatform != "" {
p, err := platforms.Parse(req.ImagePlatform)
if err != nil {
Expand All @@ -928,7 +937,7 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque
if req.AlwaysPullImage {
shouldPullImage = true // If requested always attempt to pull image
} else {
image, _, err := p.client.ImageInspectWithRaw(ctx, tag)
image, _, err := p.client.ImageInspectWithRaw(ctx, imageName)
if err != nil {
if client.IsErrNotFound(err) {
shouldPullImage = true
Expand All @@ -946,20 +955,20 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque
Platform: req.ImagePlatform, // may be empty
}

registry, imageAuth, err := DockerImageAuth(ctx, req.Image)
registry, imageAuth, err := DockerImageAuth(ctx, imageName)
if err != nil {
p.Logger.Printf("Failed to get image auth for %s. Setting empty credentials for the image: %s. Error is:%s", registry, req.Image, err)
p.Logger.Printf("Failed to get image auth for %s. Setting empty credentials for the image: %s. Error is:%s", registry, imageName, err)
} else {
// see https://github.com/docker/docs/blob/e8e1204f914767128814dca0ea008644709c117f/engine/api/sdk/examples.md?plain=1#L649-L657
encodedJSON, err := json.Marshal(imageAuth)
if err != nil {
p.Logger.Printf("Failed to marshal image auth. Setting empty credentials for the image: %s. Error is:%s", req.Image, err)
p.Logger.Printf("Failed to marshal image auth. Setting empty credentials for the image: %s. Error is:%s", imageName, err)
} else {
pullOpt.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
}
}

if err := p.attemptToPullImage(ctx, tag, pullOpt); err != nil {
if err := p.attemptToPullImage(ctx, imageName, pullOpt); err != nil {
return nil, err
}
}
Expand All @@ -974,7 +983,7 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque

dockerInput := &container.Config{
Entrypoint: req.Entrypoint,
Image: tag,
Image: imageName,
Env: env,
Labels: req.Labels,
Cmd: req.Cmd,
Expand Down Expand Up @@ -1070,7 +1079,7 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque
c := &DockerContainer{
ID: resp.ID,
WaitingFor: req.WaitingFor,
Image: tag,
Image: imageName,
imageWasBuilt: req.ShouldBuildImage(),
sessionID: testcontainerssession.SessionID(),
provider: p,
Expand Down
8 changes: 8 additions & 0 deletions docs/features/common_functional_options.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
#### Image Substitutions

- Not available until the next release of testcontainers-go <a href="https://github.com/testcontainers/testcontainers-go"><span class="tc-version">:material-tag: main</span></a>

{% include "./image_name_substitution.md" %}

Using the `WithImageSubstitutors` options, you could define your own substitutions to the container images. E.g. adding a prefix to the images so that they can be pulled from a Docker registry other than Docker Hub. This is the usual mechanism for using Docker image proxies, caches, etc.

#### Wait Strategies

If you need to set a different wait strategy for the container, you can use `testcontainers.WithWaitStrategy` with a valid wait strategy.
Expand Down
10 changes: 10 additions & 0 deletions docs/features/image_name_substitution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
In more locked down / secured environments, it can be problematic to pull images from Docker Hub and run them without additional precautions.

An image name substitutor converts a Docker image name, as may be specified in code, to an alternative name. This is intended to provide a way to override image names, for example to enforce pulling of images from a private registry.

_Testcontainers for Go_ exposes an interface to perform this operations: `ImageSubstitutor`, and a No-operation implementation to be used as reference for custom implementations:

<!--codeinclude-->
[Image Substitutor Interface](../../generic.go) inside_block:imageSubstitutor
[Noop Image Substitutor](../../container_test.go) inside_block:noopImageSubstitutor
<!--/codeinclude-->
18 changes: 18 additions & 0 deletions generic.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,24 @@ func WithImage(image string) CustomizeRequestOption {
}
}

// imageSubstitutor {
// ImageSubstitutor represents a way to substitute container image names
type ImageSubstitutor interface {
// Description returns the name of the type and a short description of how it modifies the image.
// Useful to be printed in logs
Description() string
Substitute(image string) (string, error)
}

// }

// WithImageSubstitutors sets the image substitutors for a container
func WithImageSubstitutors(fn ...ImageSubstitutor) CustomizeRequestOption {
return func(req *GenericContainerRequest) {
req.ImageSubstitutors = fn
}
}

// WithConfigModifier allows to override the default container config
func WithConfigModifier(modifier func(config *container.Config)) CustomizeRequestOption {
return func(req *GenericContainerRequest) {
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ nav:
- Quickstart: quickstart.md
- Features:
- features/creating_container.md
- features/image_name_substitution.md
- features/files_and_mounts.md
- features/creating_networks.md
- features/configuration.md
Expand Down

0 comments on commit d49ecc2

Please sign in to comment.