Skip to content

Commit

Permalink
fix!: docker authentication setup (#2727)
Browse files Browse the repository at this point in the history
Check and return errors in the process of determining authentication
configs so that unexpected failures don't occur latter in the process
of build an image or creating a container.

BuildOptions will now return an empty result on error instead of an
incomplete one, to ensure that consumers don't use partial data.

Fix builds with different config or override environments failing when
the authentication configuration changes, which was introduced by #2646.

Report errors from GetRegistryCredentials calls to avoid unexpected
failures latter on in the authentication process.

Split out the functionality to read a Dockerfile from an io.Reader into
ExtractImagesFromReader, as required when processing from a tar archive.

Deprecated function ContainerRequest.GetAuthConfigs will now panic if
an error occurs, so that callers understand that an failure occurred.

Remove unused parameter t from prepareRedisImage.

BREAKING CHANGE Add support for determining the required authentication
in when building an image from a ContextArchive, this requires ContextArchive
support io.Seeker.
  • Loading branch information
stevenh authored Aug 16, 2024
1 parent c8ec455 commit 5024e26
Show file tree
Hide file tree
Showing 6 changed files with 262 additions and 55 deletions.
90 changes: 78 additions & 12 deletions container.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package testcontainers

import (
"archive/tar"
"context"
"errors"
"fmt"
Expand All @@ -10,6 +11,7 @@ import (
"strings"
"time"

"github.com/cpuguy83/dockercfg"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
Expand Down Expand Up @@ -85,7 +87,7 @@ type ImageBuildInfo interface {
// rather than using a pre-built one
type FromDockerfile struct {
Context string // the path to the context of the docker build
ContextArchive io.Reader // the tar archive file to send to docker that contains the build context
ContextArchive io.ReadSeeker // the tar archive file to send to docker that contains the build context
Dockerfile string // the path from the context to the Dockerfile for the image, defaults to "Dockerfile"
Repo string // the repo label for image, defaults to UUID
Tag string // the tag label for image, defaults to UUID
Expand Down Expand Up @@ -305,30 +307,90 @@ 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
// 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.
// Panics if an error occurs.
func (c *ContainerRequest) GetAuthConfigs() map[string]registry.AuthConfig {
return getAuthConfigsFromDockerfile(c)
auth, err := getAuthConfigsFromDockerfile(c)
if err != nil {
panic(fmt.Sprintf("failed to get auth configs from Dockerfile: %v", err))
}
return auth
}

// dockerFileImages returns the images from the request Dockerfile.
func (c *ContainerRequest) dockerFileImages() ([]string, error) {
if c.ContextArchive == nil {
// Source is a directory, we can read the Dockerfile directly.
images, err := core.ExtractImagesFromDockerfile(filepath.Join(c.Context, c.GetDockerfile()), c.GetBuildArgs())
if err != nil {
return nil, fmt.Errorf("extract images from Dockerfile: %w", err)
}

return images, nil
}

// Source is an archive, we need to read it to get the Dockerfile.
dockerFile := c.GetDockerfile()
tr := tar.NewReader(c.FromDockerfile.ContextArchive)

for {
hdr, err := tr.Next()
if err != nil {
if errors.Is(err, io.EOF) {
return nil, fmt.Errorf("Dockerfile %q not found in context archive", dockerFile)
}

return nil, fmt.Errorf("reading tar archive: %w", err)
}

if hdr.Name != dockerFile {
continue
}

images, err := core.ExtractImagesFromReader(tr, c.GetBuildArgs())
if err != nil {
return nil, fmt.Errorf("extract images from Dockerfile: %w", err)
}

// Reset the archive to the beginning.
if _, err := c.ContextArchive.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("seek context archive to start: %w", err)
}

return images, nil
}
}

// 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 := core.ExtractImagesFromDockerfile(filepath.Join(c.Context, c.GetDockerfile()), c.GetBuildArgs())
func getAuthConfigsFromDockerfile(c *ContainerRequest) (map[string]registry.AuthConfig, error) {
images, err := c.dockerFileImages()
if err != nil {
return map[string]registry.AuthConfig{}
return nil, fmt.Errorf("docker file images: %w", err)
}

// Get the auth configs once for all images as it can be a time-consuming operation.
configs, err := getDockerAuthConfigs()
if err != nil {
return nil, err
}

authConfigs := map[string]registry.AuthConfig{}
for _, image := range images {
registry, authConfig, err := DockerImageAuth(context.Background(), image)
registry, authConfig, err := dockerImageAuth(context.Background(), image, configs)
if err != nil {
if !errors.Is(err, dockercfg.ErrCredentialsNotFound) {
return nil, fmt.Errorf("docker image auth %q: %w", image, err)
}

// Credentials not found no config to add.
continue
}

authConfigs[registry] = authConfig
}

return authConfigs
return authConfigs, nil
}

func (c *ContainerRequest) ShouldBuildImage() bool {
Expand Down Expand Up @@ -361,7 +423,10 @@ func (c *ContainerRequest) BuildOptions() (types.ImageBuildOptions, error) {
buildOptions.Dockerfile = c.GetDockerfile()

// Make sure the auth configs from the Dockerfile are set right after the user-defined build options.
authsFromDockerfile := getAuthConfigsFromDockerfile(c)
authsFromDockerfile, err := getAuthConfigsFromDockerfile(c)
if err != nil {
return types.ImageBuildOptions{}, fmt.Errorf("auth configs from Dockerfile: %w", err)
}

if buildOptions.AuthConfigs == nil {
buildOptions.AuthConfigs = map[string]registry.AuthConfig{}
Expand All @@ -378,7 +443,7 @@ func (c *ContainerRequest) BuildOptions() (types.ImageBuildOptions, error) {
for _, is := range c.ImageSubstitutors {
modifiedTag, err := is.Substitute(tag)
if err != nil {
return buildOptions, fmt.Errorf("failed to substitute image %s with %s: %w", tag, is.Description(), err)
return types.ImageBuildOptions{}, fmt.Errorf("failed to substitute image %s with %s: %w", tag, is.Description(), err)
}

if modifiedTag != tag {
Expand All @@ -401,8 +466,9 @@ func (c *ContainerRequest) BuildOptions() (types.ImageBuildOptions, error) {
// Do this as late as possible to ensure we don't leak the context on error/panic.
buildContext, err := c.GetContext()
if err != nil {
return buildOptions, err
return types.ImageBuildOptions{}, err
}

buildOptions.Context = buildContext

return buildOptions, nil
Expand Down
15 changes: 7 additions & 8 deletions container_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func Test_BuildImageWithContexts(t *testing.T) {
type TestCase struct {
Name string
ContextPath string
ContextArchive func() (io.Reader, error)
ContextArchive func() (io.ReadSeeker, error)
ExpectedEchoOutput string
Dockerfile string
ExpectedError string
Expand All @@ -157,7 +157,7 @@ func Test_BuildImageWithContexts(t *testing.T) {
{
Name: "test build from context archive",
// fromDockerfileWithContextArchive {
ContextArchive: func() (io.Reader, error) {
ContextArchive: func() (io.ReadSeeker, error) {
var buf bytes.Buffer
tarWriter := tar.NewWriter(&buf)
files := []struct {
Expand Down Expand Up @@ -202,7 +202,7 @@ func Test_BuildImageWithContexts(t *testing.T) {
},
{
Name: "test build from context archive and be able to use files in it",
ContextArchive: func() (io.Reader, error) {
ContextArchive: func() (io.ReadSeeker, error) {
var buf bytes.Buffer
tarWriter := tar.NewWriter(&buf)
files := []struct {
Expand Down Expand Up @@ -255,14 +255,14 @@ func Test_BuildImageWithContexts(t *testing.T) {
ContextPath: "./testdata",
Dockerfile: "echo.Dockerfile",
ExpectedEchoOutput: "this is from the echo test Dockerfile",
ContextArchive: func() (io.Reader, error) {
ContextArchive: func() (io.ReadSeeker, error) {
return nil, nil
},
},
{
Name: "it should error if neither a context nor a context archive are specified",
ContextPath: "",
ContextArchive: func() (io.Reader, error) {
ContextArchive: func() (io.ReadSeeker, error) {
return nil, nil
},
ExpectedError: "create container: you must specify either a build context or an image",
Expand All @@ -275,9 +275,8 @@ func Test_BuildImageWithContexts(t *testing.T) {
t.Parallel()
ctx := context.Background()
a, err := testCase.ContextArchive()
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)

req := testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
ContextArchive: a,
Expand Down
6 changes: 3 additions & 3 deletions docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -1114,7 +1114,7 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque
// forward the host ports to the container ports.
sshdForwardPortsHook, err := exposeHostPorts(ctx, &req, req.HostAccessPorts...)
if err != nil {
return nil, fmt.Errorf("failed to expose host ports: %w", err)
return nil, fmt.Errorf("expose host ports: %w", err)
}

defaultHooks = append(defaultHooks, sshdForwardPortsHook)
Expand Down Expand Up @@ -1292,12 +1292,12 @@ func (p *DockerProvider) ReuseOrCreateContainer(ctx context.Context, req Contain
func (p *DockerProvider) attemptToPullImage(ctx context.Context, tag string, pullOpt image.PullOptions) error {
registry, imageAuth, err := DockerImageAuth(ctx, tag)
if err != nil {
p.Logger.Printf("Failed to get image auth for %s. Setting empty credentials for the image: %s. Error is:%s", registry, tag, err)
p.Logger.Printf("Failed to get image auth for %s. Setting empty credentials for the image: %s. Error is: %s", registry, tag, 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", tag, err)
p.Logger.Printf("Failed to marshal image auth. Setting empty credentials for the image: %s. Error is: %s", tag, err)
} else {
pullOpt.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
}
Expand Down
Loading

0 comments on commit 5024e26

Please sign in to comment.