From d49ecc2c8616c11eea257df7c7b5c63e5972c371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20de=20la=20Pe=C3=B1a?= Date: Fri, 20 Oct 2023 08:05:37 +0200 Subject: [PATCH] feat: support for replacing images with custom substitutions (#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 --- container.go | 1 + container_test.go | 108 +++++++++++++++++++++ docker.go | 33 ++++--- docs/features/common_functional_options.md | 8 ++ docs/features/image_name_substitution.md | 10 ++ generic.go | 18 ++++ mkdocs.yml | 1 + 7 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 docs/features/image_name_substitution.md diff --git a/container.go b/container.go index 52660f5af4..a2e802930f 100644 --- a/container.go +++ b/container.go @@ -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 diff --git a/container_test.go b/container_test.go index 1187984d6d..d7fb9310ef 100644 --- a/container_test.go +++ b/container_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go/wait" ) @@ -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) diff --git a/docker.go b/docker.go index 2734bfe144..f4458bd3fe 100644 --- a/docker.go +++ b/docker.go @@ -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) @@ -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 { @@ -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 { @@ -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 @@ -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 } } @@ -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, @@ -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, diff --git a/docs/features/common_functional_options.md b/docs/features/common_functional_options.md index 6e71ae18f2..d94a59076e 100644 --- a/docs/features/common_functional_options.md +++ b/docs/features/common_functional_options.md @@ -1,3 +1,11 @@ +#### Image Substitutions + +- Not available until the next release of testcontainers-go :material-tag: main + +{% 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. diff --git a/docs/features/image_name_substitution.md b/docs/features/image_name_substitution.md new file mode 100644 index 0000000000..ebf9bb1cda --- /dev/null +++ b/docs/features/image_name_substitution.md @@ -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: + + +[Image Substitutor Interface](../../generic.go) inside_block:imageSubstitutor +[Noop Image Substitutor](../../container_test.go) inside_block:noopImageSubstitutor + diff --git a/generic.go b/generic.go index cbcdeedd3d..bd6423c6d8 100644 --- a/generic.go +++ b/generic.go @@ -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) { diff --git a/mkdocs.yml b/mkdocs.yml index 69b7992229..902e77b39d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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