Skip to content

feat: support for replacing images with custom substitutions #1719

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

Merged
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions container.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,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