From b56e66abfeed6dccf6c7fcd9c9991cb08812ddfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20de=20la=20Pe=C3=B1a?= Date: Tue, 7 Mar 2023 18:36:59 +0100 Subject: [PATCH] feat: support for reading auth credentials from docker credential helpers (#869) * feat: expose a way to retrieve credentials from the credentials helpers * chore: support for retrieving the config file from the DOCKER_CONFIG env var * docs: document how to retrieve Docker credentials * chore: support reading from DOCKER_AUTH_CONFIG * feat: populate the auth struct from the Docker credentials helper if user and password are empty in the configuration file * chore: simplify * chore: load from credentials helper properly * docs: typo * chore: move to constants * chor: do not expose helper method * chore: support getting auth for default registry * chore: friendlier func names * chore: extract registry from a Docker image * chore: move constant to internal package * chore: return default Docker registry if no registry is found * chore: define a fallback * chore: include protocol of the registry * chore: refactor func to get the auth from a Docker image * chore: do not hardcode ports in tests * fix: apply credentials to the right struct * feart: pull image using the registry credentials of the image * feat: support extracting all images from a Dockerfile * chore: deprecated AuthConfigs from BuildFormDockerfile They will automatically discovered extracting all the FROM images in the Dockerfiles * Revert "chore: do not hardcode ports in tests" This reverts commit 24ac700676d9c3cb0247a60f854dcbdaf416b818. * chore: do not recalculate base64 if it exists * chore: set custom Auth config for the registry tests * chore: remove deprecated AuthConfigs usage * chore: set proper docker config for tests involving a private registry * chore: avoid double call to extract registry * chore: extract expected registries to constants * fix: use the right format for the registry auth * chore: improve test names * chore: better test names * chore: move docker auth tests to another test file * docs: update docs * chore: interpolate build args when extracting images from dockerfile * chore: remove logs from tests * chore: extract message to constant * fix: remove whitespaces from each line * chore: verify that Docker's default config file exist --- container.go | 25 +- container_test.go | 45 --- docker.go | 15 +- docker_auth.go | 107 +++++++ docker_auth_test.go | 297 ++++++++++++++++++ docker_test.go | 145 --------- docs/features/build_from_dockerfile.md | 12 +- docs/features/docker_auth.md | 38 +++ go.mod | 1 + go.sum | 2 + internal/testcontainersdocker/images.go | 126 ++++++++ internal/testcontainersdocker/images_test.go | 228 ++++++++++++++ .../testresources/Dockerfile | 4 + .../testresources/Dockerfile.multistage | 4 + .../Dockerfile.multistage.multiBuildArgs | 7 + .../Dockerfile.multistage.singleBuildArgs | 5 + mkdocs.yml | 1 + reaper.go | 1 - reaper_test.go | 12 - testresources/.docker/config.json | 8 + 20 files changed, 865 insertions(+), 218 deletions(-) create mode 100644 docker_auth.go create mode 100644 docker_auth_test.go create mode 100644 docs/features/docker_auth.md create mode 100644 internal/testcontainersdocker/images.go create mode 100644 internal/testcontainersdocker/images_test.go create mode 100644 internal/testcontainersdocker/testresources/Dockerfile create mode 100644 internal/testcontainersdocker/testresources/Dockerfile.multistage create mode 100644 internal/testcontainersdocker/testresources/Dockerfile.multistage.multiBuildArgs create mode 100644 internal/testcontainersdocker/testresources/Dockerfile.multistage.singleBuildArgs create mode 100644 testresources/.docker/config.json diff --git a/container.go b/container.go index c2a7c9f35e..9f01f033ce 100644 --- a/container.go +++ b/container.go @@ -15,6 +15,7 @@ import ( "github.com/docker/go-connections/nat" tcexec "github.com/testcontainers/testcontainers-go/exec" + "github.com/testcontainers/testcontainers-go/internal/testcontainersdocker" "github.com/testcontainers/testcontainers-go/wait" ) @@ -84,7 +85,7 @@ type FromDockerfile struct { Dockerfile string // the path from the context to the Dockerfile for the image, defaults to "Dockerfile" BuildArgs map[string]*string // enable user to pass build args to docker daemon PrintBuildLog bool // enable user to print build log - AuthConfigs map[string]types.AuthConfig // enable auth configs to be able to pull from an authenticated docker registry + AuthConfigs map[string]types.AuthConfig // Deprecated. Testcontainers will detect registry credentials automatically. Enable auth configs to be able to pull from an authenticated docker registry } type ContainerFile struct { @@ -104,7 +105,7 @@ type ContainerRequest struct { Labels map[string]string Mounts ContainerMounts Tmpfs map[string]string - RegistryCred string + RegistryCred string // Deprecated: Testcontainers will detect registry credentials automatically WaitingFor wait.Strategy Name string // for specifying container name Hostname string @@ -158,7 +159,7 @@ func (f GenericProviderOptionFunc) ApplyGenericTo(opts *GenericProviderOptions) // containerOptions functional options for a container type containerOptions struct { ImageName string - RegistryCredentials string + RegistryCredentials string // Deprecated: Testcontainers will detect registry credentials automatically } // functional option for setting the reaper image @@ -171,6 +172,7 @@ func WithImageName(imageName string) ContainerOption { } } +// Deprecated: Testcontainers will detect registry credentials automatically // WithRegistryCredentials sets the reaper registry credentials func WithRegistryCredentials(registryCredentials string) ContainerOption { return func(o *containerOptions) { @@ -271,7 +273,22 @@ func (c *ContainerRequest) GetDockerfile() string { // GetAuthConfigs returns the auth configs to be able to pull from an authenticated docker registry func (c *ContainerRequest) GetAuthConfigs() map[string]types.AuthConfig { - return c.FromDockerfile.AuthConfigs + images, err := testcontainersdocker.ExtractImagesFromDockerfile(filepath.Join(c.Context, c.GetDockerfile()), c.GetBuildArgs()) + if err != nil { + return map[string]types.AuthConfig{} + } + + authConfigs := map[string]types.AuthConfig{} + for _, image := range images { + registry, authConfig, err := DockerImageAuth(context.Background(), image) + if err != nil { + continue + } + + authConfigs[registry] = authConfig + } + + return authConfigs } func (c *ContainerRequest) ShouldBuildImage() bool { diff --git a/container_test.go b/container_test.go index 383a913327..033a13d3c7 100644 --- a/container_test.go +++ b/container_test.go @@ -11,7 +11,6 @@ import ( "testing" "time" - "github.com/docker/docker/api/types" "github.com/stretchr/testify/assert" "github.com/testcontainers/testcontainers-go/wait" @@ -127,50 +126,6 @@ func Test_GetDockerfile(t *testing.T) { } } -func Test_GetAuthConfigs(t *testing.T) { - type TestCase struct { - name string - ExpectedAuthConfigs map[string]types.AuthConfig - ContainerRequest ContainerRequest - } - - testTable := []TestCase{ - { - name: "defaults to no auth", - ExpectedAuthConfigs: nil, - ContainerRequest: ContainerRequest{ - FromDockerfile: FromDockerfile{}, - }, - }, - { - name: "will specify credentials", - ExpectedAuthConfigs: map[string]types.AuthConfig{ - "https://myregistry.com/": { - Username: "username", - Password: "password", - }, - }, - ContainerRequest: ContainerRequest{ - FromDockerfile: FromDockerfile{ - AuthConfigs: map[string]types.AuthConfig{ - "https://myregistry.com/": { - Username: "username", - Password: "password", - }, - }, - }, - }, - }, - } - - for _, testCase := range testTable { - t.Run(testCase.name, func(t *testing.T) { - cfgs := testCase.ContainerRequest.GetAuthConfigs() - assert.Equal(t, testCase.ExpectedAuthConfigs, cfgs) - }) - } -} - func Test_BuildImageWithContexts(t *testing.T) { type TestCase struct { Name string diff --git a/docker.go b/docker.go index 436c72a2cc..7ae381246d 100644 --- a/docker.go +++ b/docker.go @@ -5,7 +5,9 @@ import ( "bufio" "bytes" "context" + "encoding/base64" "encoding/binary" + "encoding/json" "errors" "fmt" "io" @@ -1046,8 +1048,17 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque Platform: req.ImagePlatform, // may be empty } - if req.RegistryCred != "" { - pullOpt.RegistryAuth = req.RegistryCred + registry, imageAuth, err := DockerImageAuth(ctx, req.Image) + 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) + } 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) + } else { + pullOpt.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON) + } } if err := p.attemptToPullImage(ctx, tag, pullOpt); err != nil { diff --git a/docker_auth.go b/docker_auth.go new file mode 100644 index 0000000000..c04477f8c2 --- /dev/null +++ b/docker_auth.go @@ -0,0 +1,107 @@ +package testcontainers + +import ( + "context" + "encoding/base64" + "encoding/json" + "os" + + "github.com/cpuguy83/dockercfg" + "github.com/docker/docker/api/types" + "github.com/testcontainers/testcontainers-go/internal/testcontainersdocker" +) + +// DockerImageAuth returns the auth config for the given Docker image, extracting first its Docker registry. +// Finally, it will use the credential helpers to extract the information from the docker config file +// for that registry, if it exists. +func DockerImageAuth(ctx context.Context, image string) (string, types.AuthConfig, error) { + defaultRegistry := defaultRegistry(ctx) + registry := testcontainersdocker.ExtractRegistry(image, defaultRegistry) + + cfgs, err := getDockerAuthConfigs() + if err != nil { + return registry, types.AuthConfig{}, err + } + + if cfg, ok := cfgs[registry]; ok { + return registry, cfg, nil + } + + return registry, types.AuthConfig{}, dockercfg.ErrCredentialsNotFound +} + +// defaultRegistry returns the default registry to use when pulling images +// It will use the docker daemon to get the default registry, returning "https://index.docker.io/v1/" if +// it fails to get the information from the daemon +func defaultRegistry(ctx context.Context) string { + p, err := NewDockerProvider() + if err != nil { + return testcontainersdocker.IndexDockerIO + } + + info, err := p.client.Info(ctx) + if err != nil { + return testcontainersdocker.IndexDockerIO + } + + return info.IndexServerAddress +} + +// getDockerAuthConfigs returns a map with the auth configs from the docker config file +// using the registry as the key +func getDockerAuthConfigs() (map[string]types.AuthConfig, error) { + cfg, err := getDockerConfig() + if err != nil { + return nil, err + } + + cfgs := map[string]types.AuthConfig{} + for k, v := range cfg.AuthConfigs { + ac := types.AuthConfig{ + Auth: v.Auth, + Email: v.Email, + IdentityToken: v.IdentityToken, + Password: v.Password, + RegistryToken: v.RegistryToken, + ServerAddress: v.ServerAddress, + Username: v.Username, + } + + if v.Username == "" && v.Password == "" { + u, p, _ := dockercfg.GetRegistryCredentials(k) + ac.Username = u + ac.Password = p + } + + if v.Auth == "" { + ac.Auth = base64.StdEncoding.EncodeToString([]byte(ac.Username + ":" + ac.Password)) + } + + cfgs[k] = ac + } + + return cfgs, nil +} + +// getDockerConfig returns the docker config file. It will internally check, in this particular order: +// 1. the DOCKER_AUTH_CONFIG environment variable, unmarshalling it into a dockercfg.Config +// 2. the DOCKER_CONFIG environment variable, as the path to the config file +// 3. else it will load the default config file, which is ~/.docker/config.json +func getDockerConfig() (dockercfg.Config, error) { + dockerAuthConfig := os.Getenv("DOCKER_AUTH_CONFIG") + if dockerAuthConfig != "" { + cfg := dockercfg.Config{} + err := json.Unmarshal([]byte(dockerAuthConfig), &cfg) + if err == nil { + return cfg, nil + } + + } + + cfg, err := dockercfg.LoadDefaultConfig() + if err != nil { + return cfg, err + } + + return cfg, nil +} diff --git a/docker_auth_test.go b/docker_auth_test.go new file mode 100644 index 0000000000..e1ee9af029 --- /dev/null +++ b/docker_auth_test.go @@ -0,0 +1,297 @@ +package testcontainers + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/cpuguy83/dockercfg" + "github.com/docker/docker/api/types" + "github.com/docker/docker/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/internal/testcontainersdocker" + "github.com/testcontainers/testcontainers-go/wait" +) + +const exampleAuth = "https://example-auth.com" + +var testDockerConfigDirPath = filepath.Join("testresources", ".docker") + +var indexDockerIO = testcontainersdocker.IndexDockerIO + +func TestGetDockerConfig(t *testing.T) { + const expectedErrorMessage = "Expected to find %s in auth configs" + + // Verify that the default docker config file exists before any test in this suite runs. + // Then, we can safely run the tests that rely on it. + cfg, err := dockercfg.LoadDefaultConfig() + require.Nil(t, err) + require.NotNil(t, cfg) + + t.Run("without DOCKER_CONFIG env var retrieves default", func(t *testing.T) { + cfg, err := getDockerConfig() + require.Nil(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, 1, len(cfg.AuthConfigs)) + + authCfgs := cfg.AuthConfigs + + if _, ok := authCfgs[indexDockerIO]; !ok { + t.Errorf(expectedErrorMessage, indexDockerIO) + } + }) + + t.Run("with DOCKER_CONFIG env var pointing to a non-existing file raises error", func(t *testing.T) { + t.Setenv("DOCKER_CONFIG", filepath.Join(testDockerConfigDirPath, "non-existing")) + + cfg, err := getDockerConfig() + require.NotNil(t, err) + require.Empty(t, cfg) + }) + + t.Run("with DOCKER_CONFIG env var", func(t *testing.T) { + t.Setenv("DOCKER_CONFIG", testDockerConfigDirPath) + + cfg, err := getDockerConfig() + require.Nil(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, 3, len(cfg.AuthConfigs)) + + authCfgs := cfg.AuthConfigs + + if _, ok := authCfgs[indexDockerIO]; !ok { + t.Errorf(expectedErrorMessage, indexDockerIO) + } + if _, ok := authCfgs["https://example.com"]; !ok { + t.Errorf(expectedErrorMessage, "https://example.com") + } + if _, ok := authCfgs["https://my.private.registry"]; !ok { + t.Errorf(expectedErrorMessage, "https://my.private.registry") + } + }) + + t.Run("DOCKER_AUTH_CONFIG env var takes precedence", func(t *testing.T) { + t.Setenv("DOCKER_AUTH_CONFIG", `{ + "auths": { + "`+exampleAuth+`": {} + }, + "credsStore": "desktop" + }`) + t.Setenv("DOCKER_CONFIG", testDockerConfigDirPath) + + cfg, err := getDockerConfig() + require.Nil(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, 1, len(cfg.AuthConfigs)) + + authCfgs := cfg.AuthConfigs + + if _, ok := authCfgs[indexDockerIO]; ok { + t.Errorf("Not expected to find %s in auth configs", indexDockerIO) + } + if _, ok := authCfgs[exampleAuth]; !ok { + t.Errorf(expectedErrorMessage, exampleAuth) + } + }) + + t.Run("retrieve auth with DOCKER_AUTH_CONFIG env var", func(t *testing.T) { + base64 := "Z29waGVyOnNlY3JldA==" // gopher:secret + + t.Setenv("DOCKER_AUTH_CONFIG", `{ + "auths": { + "`+exampleAuth+`": { "username": "gopher", "password": "secret", "auth": "`+base64+`" } + }, + "credsStore": "desktop" + }`) + + registry, cfg, err := DockerImageAuth(context.Background(), exampleAuth+"/my/image:latest") + require.Nil(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, exampleAuth, registry) + assert.Equal(t, "gopher", cfg.Username) + assert.Equal(t, "secret", cfg.Password) + assert.Equal(t, base64, cfg.Auth) + }) +} + +func TestBuildContainerFromDockerfile(t *testing.T) { + ctx := context.Background() + req := ContainerRequest{ + FromDockerfile: FromDockerfile{ + Context: "./testresources", + }, + ExposedPorts: []string{"6379/tcp"}, + WaitingFor: wait.ForLog("Ready to accept connections"), + } + + redisC, err := prepareRedisImage(ctx, req, t) + require.NoError(t, err) + terminateContainerOnEnd(t, ctx, redisC) +} + +func TestBuildContainerFromDockerfileWithDockerAuthConfig(t *testing.T) { + // using the same credentials as in the Docker Registry + base64 := "dGVzdHVzZXI6dGVzdHBhc3N3b3Jk" // testuser:testpassword + t.Setenv("DOCKER_AUTH_CONFIG", `{ + "auths": { + "localhost:5000": { "username": "testuser", "password": "testpassword", "auth": "`+base64+`" } + }, + "credsStore": "desktop" + }`) + + prepareLocalRegistryWithAuth(t) + defer func() { + ctx := context.Background() + testcontainersClient, err := client.NewClientWithOpts(client.WithVersion(daemonMaxVersion)) + if err != nil { + t.Log("could not create client to cleanup registry: ", err) + } + + _, err = testcontainersClient.ImageRemove(ctx, "localhost:5000/redis:5.0-alpine", types.ImageRemoveOptions{ + Force: true, + PruneChildren: true, + }) + if err != nil { + t.Log("could not remove image: ", err) + } + + }() + + ctx := context.Background() + + req := ContainerRequest{ + FromDockerfile: FromDockerfile{ + Context: "./testresources", + Dockerfile: "auth.Dockerfile", + }, + + ExposedPorts: []string{"6379/tcp"}, + WaitingFor: wait.ForLog("Ready to accept connections"), + } + + redisC, err := prepareRedisImage(ctx, req, t) + require.NoError(t, err) + terminateContainerOnEnd(t, ctx, redisC) +} + +func TestBuildContainerFromDockerfileShouldFailWithWrongDockerAuthConfig(t *testing.T) { + // using different credentials than in the Docker Registry + base64 := "Zm9vOmJhcg==" // foo:bar + t.Setenv("DOCKER_AUTH_CONFIG", `{ + "auths": { + "localhost:5000": { "username": "foo", "password": "bar", "auth": "`+base64+`" } + }, + "credsStore": "desktop" + }`) + + prepareLocalRegistryWithAuth(t) + + ctx := context.Background() + + req := ContainerRequest{ + FromDockerfile: FromDockerfile{ + Context: "./testresources", + Dockerfile: "auth.Dockerfile", + }, + ExposedPorts: []string{"6379/tcp"}, + WaitingFor: wait.ForLog("Ready to accept connections"), + } + + redisC, err := prepareRedisImage(ctx, req, t) + require.Error(t, err) + terminateContainerOnEnd(t, ctx, redisC) +} + +func TestCreateContainerFromPrivateRegistry(t *testing.T) { + // using the same credentials as in the Docker Registry + base64 := "dGVzdHVzZXI6dGVzdHBhc3N3b3Jk" // testuser:testpassword + t.Setenv("DOCKER_AUTH_CONFIG", `{ + "auths": { + "localhost:5000": { "username": "testuser", "password": "testpassword", "auth": "`+base64+`" } + }, + "credsStore": "desktop" + }`) + + prepareLocalRegistryWithAuth(t) + + ctx := context.Background() + req := ContainerRequest{ + Image: "localhost:5000/redis:5.0-alpine", + AlwaysPullImage: true, // make sure the authentication takes place + ExposedPorts: []string{"6379/tcp"}, + WaitingFor: wait.ForLog("Ready to accept connections"), + } + + redisContainer, err := GenericContainer(ctx, GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + require.Nil(t, err) + terminateContainerOnEnd(t, ctx, redisContainer) +} + +func prepareLocalRegistryWithAuth(t *testing.T) { + ctx := context.Background() + wd, err := os.Getwd() + assert.NoError(t, err) + req := ContainerRequest{ + Image: "registry:2", + ExposedPorts: []string{"5000:5000/tcp"}, + Env: map[string]string{ + "REGISTRY_AUTH": "htpasswd", + "REGISTRY_AUTH_HTPASSWD_REALM": "Registry", + "REGISTRY_AUTH_HTPASSWD_PATH": "/auth/htpasswd", + "REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY": "/data", + }, + Mounts: ContainerMounts{ + ContainerMount{ + Source: GenericBindMountSource{ + HostPath: fmt.Sprintf("%s/testresources/auth", wd), + }, + Target: "/auth", + }, + ContainerMount{ + Source: GenericBindMountSource{ + HostPath: fmt.Sprintf("%s/testresources/data", wd), + }, + Target: "/data", + }, + }, + WaitingFor: wait.ForExposedPort(), + } + + genContainerReq := GenericContainerRequest{ + ProviderType: providerType, + ContainerRequest: req, + Started: true, + } + + registryC, err := GenericContainer(ctx, genContainerReq) + assert.NoError(t, err) + + t.Cleanup(func() { + assert.NoError(t, registryC.Terminate(context.Background())) + }) + + _, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) +} + +func prepareRedisImage(ctx context.Context, req ContainerRequest, t *testing.T) (Container, error) { + genContainerReq := GenericContainerRequest{ + ProviderType: providerType, + ContainerRequest: req, + Started: true, + } + + redisC, err := GenericContainer(ctx, genContainerReq) + + return redisC, err +} diff --git a/docker_test.go b/docker_test.go index 6dd72a0005..f2074c5ef9 100644 --- a/docker_test.go +++ b/docker_test.go @@ -1073,151 +1073,6 @@ func TestContainerCreationWaitsForLog(t *testing.T) { terminateContainerOnEnd(t, ctx, mysqlC) } -func Test_BuildContainerFromDockerfile(t *testing.T) { - t.Log("getting context") - ctx := context.Background() - t.Log("got context, creating container request") - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ - Context: "./testresources", - }, - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - } - - redisC, err := prepareRedisImage(ctx, req, t) - require.NoError(t, err) - terminateContainerOnEnd(t, ctx, redisC) -} - -func Test_BuildContainerFromDockerfileWithAuthConfig_ShouldSucceedWithAuthConfigs(t *testing.T) { - prepareLocalRegistryWithAuth(t) - defer func() { - ctx := context.Background() - testcontainersClient, err := client.NewClientWithOpts(client.WithVersion(daemonMaxVersion)) - if err != nil { - t.Log("could not create client to cleanup registry: ", err) - } - - _, err = testcontainersClient.ImageRemove(ctx, "localhost:5000/redis:5.0-alpine", types.ImageRemoveOptions{ - Force: true, - PruneChildren: true, - }) - if err != nil { - t.Log("could not remove image: ", err) - } - - }() - - t.Log("getting context") - ctx := context.Background() - t.Log("got context, creating container request") - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ - Context: "./testresources", - Dockerfile: "auth.Dockerfile", - AuthConfigs: map[string]types.AuthConfig{ - "localhost:5000": { - Username: "testuser", - Password: "testpassword", - }, - }, - }, - - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - } - - redisC, err := prepareRedisImage(ctx, req, t) - require.NoError(t, err) - terminateContainerOnEnd(t, ctx, redisC) -} - -func Test_BuildContainerFromDockerfileWithAuthConfig_ShouldFailWithoutAuthConfigs(t *testing.T) { - prepareLocalRegistryWithAuth(t) - - t.Log("getting context") - ctx := context.Background() - t.Log("got context, creating container request") - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ - Context: "./testresources", - Dockerfile: "auth.Dockerfile", - }, - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - } - - redisC, err := prepareRedisImage(ctx, req, t) - require.Error(t, err) - terminateContainerOnEnd(t, ctx, redisC) -} - -func prepareLocalRegistryWithAuth(t *testing.T) { - ctx := context.Background() - wd, err := os.Getwd() - assert.NoError(t, err) - req := ContainerRequest{ - Image: "registry:2", - ExposedPorts: []string{"5000:5000/tcp"}, - Env: map[string]string{ - "REGISTRY_AUTH": "htpasswd", - "REGISTRY_AUTH_HTPASSWD_REALM": "Registry", - "REGISTRY_AUTH_HTPASSWD_PATH": "/auth/htpasswd", - "REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY": "/data", - }, - Mounts: ContainerMounts{ - ContainerMount{ - Source: GenericBindMountSource{ - HostPath: fmt.Sprintf("%s/testresources/auth", wd), - }, - Target: "/auth", - }, - ContainerMount{ - Source: GenericBindMountSource{ - HostPath: fmt.Sprintf("%s/testresources/data", wd), - }, - Target: "/data", - }, - }, - WaitingFor: wait.ForExposedPort(), - } - - genContainerReq := GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - } - - t.Log("creating registry container") - - registryC, err := GenericContainer(ctx, genContainerReq) - assert.NoError(t, err) - - t.Cleanup(func() { - assert.NoError(t, registryC.Terminate(context.Background())) - }) - - _, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) -} - -func prepareRedisImage(ctx context.Context, req ContainerRequest, t *testing.T) (Container, error) { - genContainerReq := GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - } - - t.Log("creating redis container") - - redisC, err := GenericContainer(ctx, genContainerReq) - - t.Log("created redis container") - - return redisC, err -} - func Test_BuildContainerFromDockerfileWithBuildArgs(t *testing.T) { t.Log("getting ctx") ctx := context.Background() diff --git a/docs/features/build_from_dockerfile.md b/docs/features/build_from_dockerfile.md index bd146c0a30..c395d7a2ce 100644 --- a/docs/features/build_from_dockerfile.md +++ b/docs/features/build_from_dockerfile.md @@ -64,20 +64,14 @@ in to `Context`. ## Images requiring auth If you are building a local Docker image that is fetched from a Docker image in a registry requiring authentication -(e.g., assuming you are fetching from a custom registry such as `myregistry.com`), you will need to specify the -credentials to succeed, as follows: +(e.g., assuming you are fetching from a custom registry such as `myregistry.com`), _Testcontainers for Go_ will automatically +discover the credentials for the given Docker image from the Docker config, as described [here](./docker_auth.md). ```go req := ContainerRequest{ FromDockerfile: testcontainers.FromDockerfile{ Context: "/path/to/build/context", Dockerfile: "CustomDockerfile", - AuthConfigs: map[string]types.AuthConfig{ - "https://myregistry.com": { - Username: "myusername", - Password: "mypassword", - }, - }, }, } -``` \ No newline at end of file +``` diff --git a/docs/features/docker_auth.md b/docs/features/docker_auth.md new file mode 100644 index 0000000000..d6f1ed5a25 --- /dev/null +++ b/docs/features/docker_auth.md @@ -0,0 +1,38 @@ +# Authentication with Docker + +Sometimes the Docker images you use live in a private Docker registry. For that reason, _Testcontainers for Go_ gives you the ability to read the Docker configuration +and retrieve the authentication for a given registry. To achieve it, _Testcontainers for Go_ will internally check, in this particular order: + +1. the `DOCKER_AUTH_CONFIG` environment variable, unmarshalling the string value from its JSON representation and using it as the Docker config. +2. the `DOCKER_CONFIG` environment variable, as an alternative path to the Docker config file. +3. else it will load the default Docker config file, which lives in the user's home, e.g. `~/.docker/config.json` +4. it will use the right Docker credential helper to retrieve the authentication (user, password and base64 representation) for the given registry. + +To understand how the Docker credential helpers work, please refer to the [official documentation](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers). + +!!! info + _Testcontainers for Go_ uses [https://github.com/cpuguy83/dockercfg](https://github.com/cpuguy83/dockercfg) to retrieve the authentication from the credential helpers. + +_Testcontainers for Go_ will automatically discover the credentials for a given Docker image from the Docker config, as described above. For that, it will extract the Docker registry from the image name, and for that registry will try to locate the authentication in the Docker config, returning an empty string if the registry is not found. As a consequence, all the fields to pass credentials to the container request will be deprecated. + +```go +req := ContainerRequest{ + FromDockerfile: testcontainers.FromDockerfile{ + Image: "myregistry.com/myimage:latest", + }, +} +``` + +In the case you are building an image from the Dockerfile, the authentication will be automatically retrieved from the Docker config, so you don't need to pass it explicitly: + +```go +req := ContainerRequest{ + FromDockerfile: testcontainers.FromDockerfile{ + Context: "/path/to/build/context", + Dockerfile: "CustomDockerfile", + BuildArgs: map[string]*string { + "FOO": "BAR", + }, + }, +} +``` diff --git a/go.mod b/go.mod index ae439ce732..2750120f2f 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.18 require ( github.com/cenkalti/backoff/v4 v4.2.0 github.com/containerd/containerd v1.6.19 + github.com/cpuguy83/dockercfg v0.3.1 github.com/docker/docker v23.0.1+incompatible github.com/docker/go-connections v0.4.0 github.com/docker/go-units v0.5.0 diff --git a/go.sum b/go.sum index 06d7a6f462..af84b87028 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/containerd/containerd v1.6.19 h1:F0qgQPrG0P2JPgwpxWxYavrVeXAG0ezUIB9Z github.com/containerd/containerd v1.6.19/go.mod h1:HZCDMn4v/Xl2579/MvtOC2M206i+JJ6VxFWU/NetrGY= github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= +github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= diff --git a/internal/testcontainersdocker/images.go b/internal/testcontainersdocker/images.go new file mode 100644 index 0000000000..3dc10c2a45 --- /dev/null +++ b/internal/testcontainersdocker/images.go @@ -0,0 +1,126 @@ +package testcontainersdocker + +import ( + "bufio" + "net/url" + "os" + "regexp" + "strings" + "unicode/utf8" +) + +const ( + IndexDockerIO = "https://index.docker.io/v1/" + maxURLRuneCount = 2083 + minURLRuneCount = 3 + URLSchema = `((ftp|tcp|udp|wss?|https?):\/\/)` + URLUsername = `(\S+(:\S*)?@)` + URLIP = `([1-9]\d?|1\d\d|2[01]\d|22[0-3]|24\d|25[0-5])(\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])){2}(?:\.([0-9]\d?|1\d\d|2[0-4]\d|25[0-5]))` + IP = `(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))` + URLSubdomain = `((www\.)|([a-zA-Z0-9]+([-_\.]?[a-zA-Z0-9])*[a-zA-Z0-9]\.[a-zA-Z0-9]+))` + URLPath = `((\/|\?|#)[^\s]*)` + URLPort = `(:(\d{1,5}))` + URL = `^` + URLSchema + `?` + URLUsername + `?` + `((` + URLIP + `|(\[` + IP + `\])|(([a-zA-Z0-9]([a-zA-Z0-9-_]+)?[a-zA-Z0-9]([-\.][a-zA-Z0-9]+)*)|(` + URLSubdomain + `?))?(([a-zA-Z\x{00a1}-\x{ffff}0-9]+-?-?)*[a-zA-Z\x{00a1}-\x{ffff}0-9]+)(?:\.([a-zA-Z\x{00a1}-\x{ffff}]{1,}))?))\.?` + URLPort + `?` + URLPath + `?$` +) + +var rxURL = regexp.MustCompile(URL) + +func ExtractImagesFromDockerfile(dockerfile string, buildArgs map[string]*string) ([]string, error) { + var images []string + + file, err := os.Open(dockerfile) + if err != nil { + return nil, err + } + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if scanner.Err() != nil { + return nil, scanner.Err() + } + + // extract images from dockerfile + for _, line := range lines { + line = strings.TrimSpace(line) + if !strings.HasPrefix(strings.ToUpper(line), "FROM") { + continue + } + + // remove FROM + line = strings.TrimPrefix(line, "FROM") + parts := strings.Split(strings.TrimSpace(line), " ") + if len(parts) == 0 { + continue + } + + // interpolate build args + for k, v := range buildArgs { + if v != nil { + parts[0] = strings.Replace(parts[0], "${"+k+"}", *v, -1) + } + } + images = append(images, parts[0]) + } + + return images, nil +} + +// ExtractRegistry extracts the registry from the image name, using a regular expression to extract the registry from the image name. +// regular expression to extract the registry from the image name +// the regular expression is based on the grammar defined in +// - image:tag +// - image +// - repository/image:tag +// - repository/image +// - registry/image:tag +// - registry/image +// - registry/repository/image:tag +// - registry/repository/image +// - registry:port/repository/image:tag +// - registry:port/repository/image +// - registry:port/image:tag +// - registry:port/image +// Once extracted the registry, it is validated to check if it is a valid URL or an IP address. +func ExtractRegistry(image string, fallback string) string { + exp := regexp.MustCompile(`^(?:(?P(https?://)?[^/]+)(?::(?P\d+))?/)?(?:(?P[^/]+)/)?(?P[^:]+)(?::(?P.+))?$`).FindStringSubmatch(image) + if len(exp) == 0 { + return "" + } + + registry := exp[1] + + if IsURL(registry) { + return registry + } + + return fallback +} + +// IsURL checks if the string is an URL. +// Extracted from https://github.com/asaskevich/govalidator/blob/f21760c49a8d/validator.go#L104 +func IsURL(str string) bool { + if str == "" || utf8.RuneCountInString(str) >= maxURLRuneCount || len(str) <= minURLRuneCount || strings.HasPrefix(str, ".") { + return false + } + strTemp := str + if strings.Contains(str, ":") && !strings.Contains(str, "://") { + // support no indicated urlscheme but with colon for port number + // http:// is appended so url.Parse will succeed, strTemp used so it does not impact rxURL.MatchString + strTemp = "http://" + str + } + u, err := url.Parse(strTemp) + if err != nil { + return false + } + if strings.HasPrefix(u.Host, ".") { + return false + } + if u.Host == "" && (u.Path != "" && !strings.Contains(u.Path, ".")) { + return false + } + return rxURL.MatchString(str) +} diff --git a/internal/testcontainersdocker/images_test.go b/internal/testcontainersdocker/images_test.go new file mode 100644 index 0000000000..56f01f4056 --- /dev/null +++ b/internal/testcontainersdocker/images_test.go @@ -0,0 +1,228 @@ +package testcontainersdocker + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + localhost5000 = "localhost:5000" + httpLocalhost5000 = "http://" + localhost5000 + loopback5000 = "127.0.0.1:5000" + httpLoopback5000 = "http://" + loopback5000 + dockerElasticCo = "docker.elastic.co" +) + +func TestExtractImagesFromDockerfile(t *testing.T) { + var baseImage string = "scratch" + var registryHost string = "localhost" + var registryPort string = "5000" + var nginxImage string = "nginx:latest" + + tests := []struct { + name string + dockerfile string + buildArgs map[string]*string + expected []string + expectedError bool + }{ + { + name: "Wrong file", + dockerfile: "", + buildArgs: nil, + expected: []string{}, + expectedError: true, + }, + { + name: "Single Image", + dockerfile: filepath.Join("testresources", "Dockerfile"), + buildArgs: nil, + expected: []string{"nginx:${tag}"}, + }, + { + name: "Multiple Images", + dockerfile: filepath.Join("testresources", "Dockerfile.multistage"), + buildArgs: nil, + expected: []string{"nginx:a", "nginx:b", "nginx:c", "scratch"}, + }, + { + name: "Multiple Images with one build arg", + dockerfile: filepath.Join("testresources", "Dockerfile.multistage.singleBuildArgs"), + buildArgs: map[string]*string{"BASE_IMAGE": &baseImage}, + expected: []string{"nginx:a", "nginx:b", "nginx:c", "scratch"}, + }, + { + name: "Multiple Images with multiple build args", + dockerfile: filepath.Join("testresources", "Dockerfile.multistage.multiBuildArgs"), + buildArgs: map[string]*string{"BASE_IMAGE": &baseImage, "REGISTRY_HOST": ®istryHost, "REGISTRY_PORT": ®istryPort, "NGINX_IMAGE": &nginxImage}, + expected: []string{"nginx:latest", "localhost:5000/nginx:latest", "scratch"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + images, err := ExtractImagesFromDockerfile(tt.dockerfile, tt.buildArgs) + if tt.expectedError { + require.Error(t, err) + assert.Empty(t, images) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, images) + } + }) + } +} + +func TestExtractRegistry(t *testing.T) { + tests := []struct { + name string + image string + expected string + }{ + { + name: "Empty", + image: "", + expected: "", + }, + { + name: "Numbers", + image: "1234567890", + expected: IndexDockerIO, + }, + { + name: "Malformed Image", + image: "--malformed--", + expected: IndexDockerIO, + }, + { + name: "Repository + Image + Tag", + image: "testcontainers/ryuk:latest", + expected: IndexDockerIO, + }, + { + name: "Repository + Image", + image: "testcontainers/ryuk", + expected: IndexDockerIO, + }, + { + name: "Image + Tag", + image: "nginx:latest", + expected: IndexDockerIO, + }, + { + name: "Image", + image: "nginx", + expected: IndexDockerIO, + }, + { + name: "Local Registry with Port + Repository + Image + Tag", + image: "localhost:5000/testcontainers/ryuk:latest", + expected: localhost5000, + }, + { + name: "Local Registry with Port + Repository + Image", + image: "localhost:5000/testcontainers/ryuk", + expected: localhost5000, + }, + { + name: "Local Registry with Port + Image + Tag", + image: "localhost:5000/ryuk:latest", + expected: localhost5000, + }, + { + name: "Local Registry with Port + Image", + image: "localhost:5000/nginx", + expected: localhost5000, + }, + { + name: "Local Registry with Protocol and Port + Repository + Image + Tag", + image: "http://localhost:5000/testcontainers/ryuk:latest", + expected: httpLocalhost5000, + }, + { + name: "Local Registry with Protocol and Port + Repository + Image", + image: "http://localhost:5000/testcontainers/ryuk", + expected: httpLocalhost5000, + }, + { + name: "Local Registry with Protocol and Port + Image + Tag", + image: "http://localhost:5000/ryuk:latest", + expected: httpLocalhost5000, + }, + { + name: "Local Registry with Protocol and Port + Image", + image: "http://localhost:5000/nginx", + expected: httpLocalhost5000, + }, + { + name: "IP Registry with Port + Repository + Image + Tag", + image: "127.0.0.1:5000/testcontainers/ryuk:latest", + expected: loopback5000, + }, + { + name: "IP Registry with Port + Repository + Image", + image: "127.0.0.1:5000/testcontainers/ryuk", + expected: loopback5000, + }, + { + name: "IP Registry with Port + Image + Tag", + image: "127.0.0.1:5000/ryuk:latest", + expected: loopback5000, + }, + { + name: "IP Registry with Port + Image", + image: "127.0.0.1:5000/nginx", + expected: loopback5000, + }, + { + name: "IP Registry with Protocol and Port + Repository + Image + Tag", + image: "http://127.0.0.1:5000/testcontainers/ryuk:latest", + expected: httpLoopback5000, + }, + { + name: "IP Registry with Protocol and Port + Repository + Image", + image: "http://127.0.0.1:5000/testcontainers/ryuk", + expected: httpLoopback5000, + }, + { + name: "IP Registry with Protocol and Port + Image + Tag", + image: "http://127.0.0.1:5000/ryuk:latest", + expected: httpLoopback5000, + }, + { + name: "IP Registry with Protocol and Port + Image", + image: "http://127.0.0.1:5000/nginx", + expected: httpLoopback5000, + }, + { + name: "DNS Registry + Repository + Image + Tag", + image: "docker.elastic.co/elasticsearch/elasticsearch:8.6.2", + expected: dockerElasticCo, + }, + { + name: "DNS Registry + Repository + Image", + image: "docker.elastic.co/elasticsearch/elasticsearch", + expected: dockerElasticCo, + }, + { + name: "DNS Registry + Image + Tag", + image: "docker.elastic.co/elasticsearch:latest", + expected: dockerElasticCo, + }, + { + name: "DNS Registry + Image", + image: "docker.elastic.co/elasticsearch", + expected: dockerElasticCo, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := ExtractRegistry(test.image, IndexDockerIO) + assert.Equal(t, test.expected, actual, "expected %s, got %s", test.expected, actual) + }) + } +} diff --git a/internal/testcontainersdocker/testresources/Dockerfile b/internal/testcontainersdocker/testresources/Dockerfile new file mode 100644 index 0000000000..23b462126d --- /dev/null +++ b/internal/testcontainersdocker/testresources/Dockerfile @@ -0,0 +1,4 @@ +ARG tag=latest + FROM nginx:${tag} +COPY . . +CMD ["pwd"] diff --git a/internal/testcontainersdocker/testresources/Dockerfile.multistage b/internal/testcontainersdocker/testresources/Dockerfile.multistage new file mode 100644 index 0000000000..168c1b85b6 --- /dev/null +++ b/internal/testcontainersdocker/testresources/Dockerfile.multistage @@ -0,0 +1,4 @@ +FROM nginx:a as builderA +FROM nginx:b as builderB +FROM nginx:c as builderC +FROM scratch diff --git a/internal/testcontainersdocker/testresources/Dockerfile.multistage.multiBuildArgs b/internal/testcontainersdocker/testresources/Dockerfile.multistage.multiBuildArgs new file mode 100644 index 0000000000..0260630bdc --- /dev/null +++ b/internal/testcontainersdocker/testresources/Dockerfile.multistage.multiBuildArgs @@ -0,0 +1,7 @@ +ARG BASE_IMAGE=scratch +ARG NGINX_IMAGE=nginx:latest +ARG REGISTRY_HOST=localhost +ARG REGISTRY_PORT=5000 +FROM ${NGINX_IMAGE} as builderA +FROM ${REGISTRY_HOST}:${REGISTRY_PORT}/${NGINX_IMAGE} as builderB +FROM ${BASE_IMAGE} diff --git a/internal/testcontainersdocker/testresources/Dockerfile.multistage.singleBuildArgs b/internal/testcontainersdocker/testresources/Dockerfile.multistage.singleBuildArgs new file mode 100644 index 0000000000..06820189a6 --- /dev/null +++ b/internal/testcontainersdocker/testresources/Dockerfile.multistage.singleBuildArgs @@ -0,0 +1,5 @@ +ARG BASE_IMAGE=scratch +FROM nginx:a as builderA +FROM nginx:b as builderB +FROM nginx:c as builderC +FROM ${BASE_IMAGE} diff --git a/mkdocs.yml b/mkdocs.yml index 15158c1591..1df1f1851e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -33,6 +33,7 @@ nav: - features/networking.md - features/garbage_collector.md - features/build_from_dockerfile.md + - features/docker_auth.md - features/docker_compose.md - features/follow_logs.md - features/override_container_command.md diff --git a/reaper.go b/reaper.go index cf74d6aaba..5d5c912e0a 100644 --- a/reaper.go +++ b/reaper.go @@ -98,7 +98,6 @@ func newReaper(ctx context.Context, sessionID string, provider ReaperProvider, o testcontainersdocker.LabelReaper: "true", }, SkipReaper: true, - RegistryCred: reaperOpts.RegistryCredentials, Mounts: Mounts(BindMount(dockerHost, "/var/run/docker.sock")), Privileged: tcConfig.RyukPrivileged, WaitingFor: wait.ForListeningPort(listeningPort), diff --git a/reaper_test.go b/reaper_test.go index b05b2ec0b1..1032c88ee4 100644 --- a/reaper_test.go +++ b/reaper_test.go @@ -107,16 +107,6 @@ func Test_NewReaper(t *testing.T) { config: TestContainersConfig{}, ctx: context.WithValue(context.TODO(), testcontainersdocker.DockerHostContextKey, "unix:///value/in/context.sock"), }, - { - name: "with registry credentials", - req: createContainerRequest(func(req ContainerRequest) ContainerRequest { - creds := "registry-creds" - req.RegistryCred = creds - req.ReaperOptions = append(req.ReaperOptions, WithRegistryCredentials(creds)) - return req - }), - config: TestContainersConfig{}, - }, } for _, test := range tests { @@ -157,7 +147,6 @@ func Test_ReaperForNetwork(t *testing.T) { Name: networkName, CheckDuplicate: true, ReaperOptions: []ContainerOption{ - WithRegistryCredentials("credentials"), WithImageName("reaperImage"), }, }, @@ -170,7 +159,6 @@ func Test_ReaperForNetwork(t *testing.T) { _, err := newReaper(ctx, "sessionId", provider, req.ReaperOptions...) assert.EqualError(t, err, "expected") - assert.Equal(t, "credentials", provider.req.RegistryCred) assert.Equal(t, "reaperImage", provider.req.Image) assert.Equal(t, "reaperImage", provider.req.ReaperImage) } diff --git a/testresources/.docker/config.json b/testresources/.docker/config.json new file mode 100644 index 0000000000..af4b84ef1c --- /dev/null +++ b/testresources/.docker/config.json @@ -0,0 +1,8 @@ +{ + "auths": { + "https://index.docker.io/v1/": {}, + "https://example.com": {}, + "https://my.private.registry": {} + }, + "credsStore": "desktop" +} \ No newline at end of file