diff --git a/container_test.go b/container_test.go index 2e7aebce41..953b9efd71 100644 --- a/container_test.go +++ b/container_test.go @@ -278,7 +278,7 @@ func Test_BuildImageWithContexts(t *testing.T) { ContextArchive: func() (io.ReadSeeker, error) { return nil, nil }, - ExpectedError: "create container: you must specify either a build context or an image", + ExpectedError: "generic container: create container: you must specify either a build context or an image", }, } @@ -290,19 +290,15 @@ func Test_BuildImageWithContexts(t *testing.T) { a, err := testCase.ContextArchive() require.NoError(t, err) - req := testcontainers.ContainerRequest{ - FromDockerfile: testcontainers.FromDockerfile{ + c, err := testcontainers.Run( + ctx, "", + testcontainers.WithDockerfile(testcontainers.FromDockerfile{ ContextArchive: a, Context: testCase.ContextPath, Dockerfile: testCase.Dockerfile, - }, - WaitingFor: wait.ForLog(testCase.ExpectedEchoOutput).WithStartupTimeout(1 * time.Minute), - } - - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + }), + testcontainers.WithWaitStrategy(wait.ForLog(testCase.ExpectedEchoOutput).WithStartupTimeout(1*time.Minute)), + ) testcontainers.CleanupContainer(t, c) if testCase.ExpectedError != "" { @@ -322,17 +318,10 @@ func TestCustomLabelsImage(t *testing.T) { ) ctx := context.Background() - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "alpine:latest", - Labels: map[string]string{myLabelName: myLabelValue}, - }, - } - - ctr, err := testcontainers.GenericContainer(ctx, req) + ctr, err := testcontainers.Run(ctx, "alpine:latest", testcontainers.WithLabels(map[string]string{myLabelName: myLabelValue})) + testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, ctr.Terminate(ctx)) }) ctrJSON, err := ctr.Inspect(ctx) require.NoError(t, err) @@ -348,22 +337,20 @@ func TestCustomLabelsBuildOptionsModifier(t *testing.T) { ) ctx := context.Background() - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - FromDockerfile: testcontainers.FromDockerfile{ - Context: "./testdata", - Dockerfile: "Dockerfile", - BuildOptionsModifier: func(opts *build.ImageBuildOptions) { - opts.Labels = map[string]string{ - myBuildOptionLabel: myBuildOptionValue, - } - }, - }, - Labels: map[string]string{myLabelName: myLabelValue}, - }, - } - ctr, err := testcontainers.GenericContainer(ctx, req) + ctr, err := testcontainers.Run( + ctx, "", + testcontainers.WithDockerfile(testcontainers.FromDockerfile{ + Context: "./testdata", + Dockerfile: "Dockerfile", + BuildOptionsModifier: func(opts *build.ImageBuildOptions) { + opts.Labels = map[string]string{ + myBuildOptionLabel: myBuildOptionValue, + } + }, + }), + testcontainers.WithLabels(map[string]string{myLabelName: myLabelValue}), + ) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) @@ -376,17 +363,13 @@ func TestCustomLabelsBuildOptionsModifier(t *testing.T) { func Test_GetLogsFromFailedContainer(t *testing.T) { ctx := context.Background() // directDockerHubReference { - req := testcontainers.ContainerRequest{ - Image: "alpine", - Cmd: []string{"echo", "-n", "I was not expecting this"}, - WaitingFor: wait.ForLog("I was expecting this").WithStartupTimeout(5 * time.Second), - } + c, err := testcontainers.Run( + ctx, "alpine", + testcontainers.WithCmd("echo", "-n", "I was not expecting this"), + testcontainers.WithWaitStrategy(wait.ForLog("I was expecting this").WithStartupTimeout(5*time.Second)), + ) // } - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) testcontainers.CleanupContainer(t, c) require.ErrorContains(t, err, "container exited with code 0") @@ -481,15 +464,7 @@ func TestImageSubstitutors(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { ctx := context.Background() - req := testcontainers.ContainerRequest{ - Image: test.image, - ImageSubstitutors: test.substitutors, - } - - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + ctr, err := testcontainers.Run(ctx, test.image, testcontainers.WithImageSubstitutors(test.substitutors...)) testcontainers.CleanupContainer(t, ctr) if test.expectedError != nil { require.ErrorIs(t, err, test.expectedError) @@ -500,8 +475,7 @@ func TestImageSubstitutors(t *testing.T) { // enforce the concrete type, as GenericContainer returns an interface, // which will be changed in future implementations of the library - dockerContainer := ctr.(*testcontainers.DockerContainer) - assert.Equal(t, test.expectedImage, dockerContainer.Image) + assert.Equal(t, test.expectedImage, ctr.Image) }) } } @@ -515,15 +489,11 @@ func TestShouldStartContainersInParallel(t *testing.T) { t.Run(fmt.Sprintf("iteration_%d", i), func(t *testing.T) { t.Parallel() - req := testcontainers.ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForHTTP("/").WithStartupTimeout(10 * time.Second), - } - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + ctr, err := testcontainers.Run( + ctx, nginxAlpineImage, + testcontainers.WithExposedPorts(nginxDefaultPort), + testcontainers.WithWaitStrategy(wait.ForHTTP("/").WithStartupTimeout(10*time.Second)), + ) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) @@ -541,13 +511,7 @@ func ExampleGenericContainer_withSubstitutors() { ctx := context.Background() // applyImageSubstitutors { - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "alpine:latest", - ImageSubstitutors: []testcontainers.ImageSubstitutor{dockerImageSubstitutor{}}, - }, - Started: true, - }) + ctr, err := testcontainers.Run(ctx, "alpine:latest", testcontainers.WithImageSubstitutors(dockerImageSubstitutor{})) defer func() { if err := testcontainers.TerminateContainer(ctr); err != nil { log.Printf("failed to terminate container: %s", err) @@ -560,11 +524,7 @@ func ExampleGenericContainer_withSubstitutors() { return } - // enforce the concrete type, as GenericContainer returns an interface, - // which will be changed in future implementations of the library - dockerContainer := ctr.(*testcontainers.DockerContainer) - - fmt.Println(dockerContainer.Image) + fmt.Println(ctr.Image) // Output: registry.hub.docker.com/library/alpine:latest } diff --git a/docker_auth_test.go b/docker_auth_test.go index 6005eb853a..6d0bc7568f 100644 --- a/docker_auth_test.go +++ b/docker_auth_test.go @@ -159,16 +159,15 @@ func TestDockerImageAuth(t *testing.T) { func TestBuildContainerFromDockerfile(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ - Context: "./testdata", - }, - AlwaysPullImage: true, // make sure the authentication takes place - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - } - redisC, err := prepareRedisImage(ctx, req) + redisC, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ + Context: "./testdata", + }), + WithAlwaysPull(), + WithExposedPorts("6379/tcp"), + WithWaitStrategy(wait.ForLog("Ready to accept connections")), + ) CleanupContainer(t, redisC) require.NoError(t, err) } @@ -201,21 +200,19 @@ func TestBuildContainerFromDockerfileWithDockerAuthConfig(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ + redisC, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ Context: "./testdata", Dockerfile: "auth.Dockerfile", BuildArgs: map[string]*string{ "REGISTRY_HOST": ®istryHost, }, Repo: "localhost", - }, - AlwaysPullImage: true, // make sure the authentication takes place - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - } - - redisC, err := prepareRedisImage(ctx, req) + }), + WithAlwaysPull(), + WithExposedPorts("6379/tcp"), + WithWaitStrategy(wait.ForLog("Ready to accept connections")), + ) CleanupContainer(t, redisC) require.NoError(t, err) } @@ -228,20 +225,18 @@ func TestBuildContainerFromDockerfileShouldFailWithWrongDockerAuthConfig(t *test ctx := context.Background() - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ + redisC, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ Context: "./testdata", Dockerfile: "auth.Dockerfile", BuildArgs: map[string]*string{ "REGISTRY_HOST": ®istryHost, }, - }, - AlwaysPullImage: true, // make sure the authentication takes place - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - } - - redisC, err := prepareRedisImage(ctx, req) + }), + WithAlwaysPull(), + WithExposedPorts("6379/tcp"), + WithWaitStrategy(wait.ForLog("Ready to accept connections")), + ) CleanupContainer(t, redisC) require.Error(t, err) } @@ -253,17 +248,8 @@ func TestCreateContainerFromPrivateRegistry(t *testing.T) { setAuthConfig(t, registryHost, "testuser", "testpassword") ctx := context.Background() - req := ContainerRequest{ - Image: registryHost + "/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, - }) + redisContainer, err := Run(ctx, registryHost+"/redis:5.0-alpine", WithAlwaysPull(), WithExposedPorts("6379/tcp"), WithWaitStrategy(wait.ForLog("Ready to accept connections"))) CleanupContainer(t, redisContainer) require.NoError(t, err) } @@ -273,37 +259,31 @@ func prepareLocalRegistryWithAuth(t *testing.T) string { ctx := context.Background() wd, err := os.Getwd() require.NoError(t, err) + // copyDirectoryToContainer { - req := ContainerRequest{ - Image: "registry:2", - ExposedPorts: []string{"5000/tcp"}, - Env: map[string]string{ + registryC, err := Run(ctx, "registry:2", + WithAlwaysPull(), + WithEnv(map[string]string{ "REGISTRY_AUTH": "htpasswd", "REGISTRY_AUTH_HTPASSWD_REALM": "Registry", "REGISTRY_AUTH_HTPASSWD_PATH": "/auth/htpasswd", "REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY": "/data", - }, - Files: []ContainerFile{ - { + }), + WithFiles( + ContainerFile{ HostFilePath: wd + "/testdata/auth", ContainerFilePath: "/auth", }, - { + ContainerFile{ HostFilePath: wd + "/testdata/data", ContainerFilePath: "/data", }, - }, - WaitingFor: wait.ForHTTP("/").WithPort("5000/tcp"), - } + ), + WithExposedPorts("5000/tcp"), + WithWaitStrategy(wait.ForHTTP("/").WithPort("5000/tcp")), + ) // } - genContainerReq := GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - } - - registryC, err := GenericContainer(ctx, genContainerReq) CleanupContainer(t, registryC) require.NoError(t, err) @@ -321,16 +301,6 @@ func prepareLocalRegistryWithAuth(t *testing.T) string { return addr } -func prepareRedisImage(ctx context.Context, req ContainerRequest) (Container, error) { - genContainerReq := GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - } - - return GenericContainer(ctx, genContainerReq) -} - // setAuthConfig sets the DOCKER_AUTH_CONFIG environment variable with // authentication for with the given host, username and password. // It returns the base64 encoded credentials. diff --git a/docker_exec_test.go b/docker_exec_test.go index 65f9e71e07..542d59d85b 100644 --- a/docker_exec_test.go +++ b/docker_exec_test.go @@ -47,14 +47,7 @@ func TestExecWithOptions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: nginxAlpineImage, - } - - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + ctr, err := Run(ctx, nginxAlpineImage) CleanupContainer(t, ctr) require.NoError(t, err) @@ -79,14 +72,7 @@ func TestExecWithOptions(t *testing.T) { func TestExecWithMultiplexedResponse(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: nginxAlpineImage, - } - - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + ctr, err := Run(ctx, nginxAlpineImage) CleanupContainer(t, ctr) require.NoError(t, err) @@ -106,14 +92,7 @@ func TestExecWithMultiplexedResponse(t *testing.T) { func TestExecWithNonMultiplexedResponse(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: nginxAlpineImage, - } - - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + ctr, err := Run(ctx, nginxAlpineImage) CleanupContainer(t, ctr) require.NoError(t, err) diff --git a/docker_files_test.go b/docker_files_test.go index 6b32168081..1ab7897c35 100644 --- a/docker_files_test.go +++ b/docker_files_test.go @@ -26,22 +26,16 @@ func TestCopyFileToContainer(t *testing.T) { r, err := os.Open(absPath) require.NoError(t, err) - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: testBashImage, - Files: []testcontainers.ContainerFile{ - { - Reader: r, - HostFilePath: absPath, // will be discarded internally - ContainerFilePath: "/hello.sh", - FileMode: 0o700, - }, - }, - Cmd: []string{"bash", "/hello.sh"}, - WaitingFor: wait.ForLog("done"), - }, - Started: true, - }) + ctr, err := testcontainers.Run(ctx, testBashImage, + testcontainers.WithFiles(testcontainers.ContainerFile{ + Reader: r, + HostFilePath: absPath, // will be discarded internally + ContainerFilePath: "/hello.sh", + FileMode: 0o700, + }), + testcontainers.WithCmd("bash", "/hello.sh"), + testcontainers.WithWaitStrategy(wait.ForLog("done")), + ) // } testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) @@ -58,20 +52,14 @@ func TestCopyFileToRunningContainer(t *testing.T) { helloPath, err := filepath.Abs(filepath.Join(".", "testdata", "hello.sh")) require.NoError(t, err) - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: testBashImage, - Files: []testcontainers.ContainerFile{ - { - HostFilePath: waitForPath, - ContainerFilePath: "/waitForHello.sh", - FileMode: 0o700, - }, - }, - Cmd: []string{"bash", "/waitForHello.sh"}, - }, - Started: true, - }) + ctr, err := testcontainers.Run(ctx, testBashImage, + testcontainers.WithFiles(testcontainers.ContainerFile{ + HostFilePath: waitForPath, + ContainerFilePath: "/waitForHello.sh", + FileMode: 0o700, + }), + testcontainers.WithCmd("bash", "/waitForHello.sh"), + ) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) @@ -94,24 +82,18 @@ func TestCopyDirectoryToContainer(t *testing.T) { dataDirectory, err := filepath.Abs(filepath.Join(".", "testdata")) require.NoError(t, err) - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: testBashImage, - Files: []testcontainers.ContainerFile{ - { - HostFilePath: dataDirectory, - // ContainerFile cannot create the parent directory, so we copy the scripts - // to the root of the container instead. Make sure to create the container directory - // before you copy a host directory on create. - ContainerFilePath: "/", - FileMode: 0o700, - }, - }, - Cmd: []string{"bash", "/testdata/hello.sh"}, - WaitingFor: wait.ForLog("done"), - }, - Started: true, - }) + ctr, err := testcontainers.Run(ctx, testBashImage, + testcontainers.WithFiles(testcontainers.ContainerFile{ + HostFilePath: dataDirectory, + // ContainerFile cannot create the parent directory, so we copy the scripts + // to the root of the container instead. Make sure to create the container directory + // before you copy a host directory on create. + ContainerFilePath: "/", + FileMode: 0o700, + }), + testcontainers.WithCmd("bash", "/testdata/hello.sh"), + testcontainers.WithWaitStrategy(wait.ForLog("done")), + ) // } testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) @@ -127,20 +109,14 @@ func TestCopyDirectoryToRunningContainerAsFile(t *testing.T) { waitForPath, err := filepath.Abs(filepath.Join(dataDirectory, "waitForHello.sh")) require.NoError(t, err) - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: testBashImage, - Files: []testcontainers.ContainerFile{ - { - HostFilePath: waitForPath, - ContainerFilePath: "/waitForHello.sh", - FileMode: 0o700, - }, - }, - Cmd: []string{"bash", "/waitForHello.sh"}, - }, - Started: true, - }) + ctr, err := testcontainers.Run(ctx, testBashImage, + testcontainers.WithFiles(testcontainers.ContainerFile{ + HostFilePath: waitForPath, + ContainerFilePath: "/waitForHello.sh", + FileMode: 0o700, + }), + testcontainers.WithCmd("bash", "/waitForHello.sh"), + ) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) @@ -165,20 +141,14 @@ func TestCopyDirectoryToRunningContainerAsDir(t *testing.T) { dataDirectory, err := filepath.Abs(filepath.Join(".", "testdata")) require.NoError(t, err) - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: testBashImage, - Files: []testcontainers.ContainerFile{ - { - HostFilePath: waitForPath, - ContainerFilePath: "/waitForHello.sh", - FileMode: 0o700, - }, - }, - Cmd: []string{"bash", "/waitForHello.sh"}, - }, - Started: true, - }) + ctr, err := testcontainers.Run(ctx, testBashImage, + testcontainers.WithFiles(testcontainers.ContainerFile{ + HostFilePath: waitForPath, + ContainerFilePath: "/waitForHello.sh", + FileMode: 0o700, + }), + testcontainers.WithCmd("bash", "/waitForHello.sh"), + ) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) diff --git a/docker_test.go b/docker_test.go index 9d3a16eb61..88ecdd2af6 100644 --- a/docker_test.go +++ b/docker_test.go @@ -22,7 +22,6 @@ import ( "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/strslice" "github.com/docker/docker/client" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go/internal/core" @@ -60,29 +59,19 @@ func TestContainerWithHostNetworkOptions(t *testing.T) { absPath, err := filepath.Abs(filepath.Join("testdata", "nginx-highport.conf")) require.NoError(t, err) - gcr := GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - Files: []ContainerFile{ - { - HostFilePath: absPath, - ContainerFilePath: "/etc/nginx/conf.d/default.conf", - }, - }, - ExposedPorts: []string{ - nginxHighPort, - }, - WaitingFor: wait.ForHTTP("/").WithPort(nginxHighPort), - HostConfigModifier: func(hc *container.HostConfig) { - hc.NetworkMode = "host" - hc.Privileged = true - }, - }, - Started: true, - } - - nginxC, err := GenericContainer(ctx, gcr) + nginxC, err := Run( + ctx, nginxAlpineImage, + WithExposedPorts(nginxHighPort), + WithWaitStrategy(wait.ForHTTP("/").WithPort(nginxHighPort)), + WithFiles(ContainerFile{ + HostFilePath: absPath, + ContainerFilePath: "/etc/nginx/conf.d/default.conf", + }), + WithHostConfigModifier(func(hc *container.HostConfig) { + hc.NetworkMode = "host" + hc.Privileged = true + }), + ) CleanupContainer(t, nginxC) require.NoError(t, err) @@ -95,18 +84,13 @@ func TestContainerWithHostNetworkOptions(t *testing.T) { func TestContainerWithHostNetworkOptions_UseExposePortsFromImageConfigs(t *testing.T) { ctx := context.Background() - gcr := GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - Image: "nginx", - WaitingFor: wait.ForExposedPort(), - HostConfigModifier: func(hc *container.HostConfig) { - hc.Privileged = true - }, - }, - Started: true, - } - - nginxC, err := GenericContainer(ctx, gcr) + nginxC, err := Run( + ctx, "nginx", + WithWaitStrategy(wait.ForExposedPort()), + WithHostConfigModifier(func(hc *container.HostConfig) { + hc.Privileged = true + }), + ) CleanupContainer(t, nginxC) require.NoError(t, err) @@ -120,36 +104,6 @@ func TestContainerWithHostNetworkOptions_UseExposePortsFromImageConfigs(t *testi require.Equalf(t, http.StatusOK, resp.StatusCode, "Expected status code %d. Got %d.", http.StatusOK, resp.StatusCode) } -func TestContainerWithNetworkModeAndNetworkTogether(t *testing.T) { - if os.Getenv("XDG_RUNTIME_DIR") != "" { - t.Skip("Skipping test that requires host network access when running in a container") - } - - // skipIfDockerDesktop { - ctx := context.Background() - SkipIfDockerDesktop(t, ctx) - // } - - gcr := GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxImage, - Networks: []string{"new-network"}, - HostConfigModifier: func(hc *container.HostConfig) { - hc.NetworkMode = "host" - }, - }, - Started: true, - } - - nginx, err := GenericContainer(ctx, gcr) - CleanupContainer(t, nginx) - if err != nil { - // Error when NetworkMode = host and Network = []string{"bridge"} - t.Logf("Can't use Network and NetworkMode together, %s\n", err) - } -} - func TestContainerWithHostNetwork(t *testing.T) { if os.Getenv("XDG_RUNTIME_DIR") != "" { t.Skip("Skipping test that requires host network access when running in a container") @@ -161,25 +115,17 @@ func TestContainerWithHostNetwork(t *testing.T) { absPath, err := filepath.Abs(filepath.Join("testdata", "nginx-highport.conf")) require.NoError(t, err) - gcr := GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - WaitingFor: wait.ForHTTP("/").WithPort(nginxHighPort), - Files: []ContainerFile{ - { - HostFilePath: absPath, - ContainerFilePath: "/etc/nginx/conf.d/default.conf", - }, - }, - HostConfigModifier: func(hc *container.HostConfig) { - hc.NetworkMode = "host" - }, - }, - Started: true, - } - - nginxC, err := GenericContainer(ctx, gcr) + nginxC, err := Run( + ctx, nginxAlpineImage, + WithWaitStrategy(wait.ForHTTP("/").WithPort(nginxHighPort)), + WithFiles(ContainerFile{ + HostFilePath: absPath, + ContainerFilePath: "/etc/nginx/conf.d/default.conf", + }), + WithHostConfigModifier(func(hc *container.HostConfig) { + hc.NetworkMode = "host" + }), + ) CleanupContainer(t, nginxC) require.NoError(t, err) @@ -194,24 +140,15 @@ func TestContainerWithHostNetwork(t *testing.T) { require.NoErrorf(t, err, "Expected host %s", host) _, err = http.Get("http://" + host + ":8080") - assert.NoErrorf(t, err, "Expected OK response") + require.NoErrorf(t, err, "Expected OK response") } func TestContainerReturnItsContainerID(t *testing.T) { ctx := context.Background() - nginxA, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - }, - }) + nginxA, err := Run(ctx, nginxAlpineImage, WithExposedPorts(nginxDefaultPort)) CleanupContainer(t, nginxA) require.NoError(t, err) - - assert.NotEmptyf(t, nginxA.GetContainerID(), "expected a containerID but we got an empty string.") + require.NotEmptyf(t, nginxA.GetContainerID(), "expected a containerID but we got an empty string.") } // testLogConsumer is a simple implementation of LogConsumer that logs to the test output. @@ -230,19 +167,13 @@ func (l *testLogConsumer) Accept(log Log) { func TestContainerTerminationResetsState(t *testing.T) { ctx := context.Background() - nginxA, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - LogConsumerCfg: &LogConsumerConfig{ - Consumers: []LogConsumer{&testLogConsumer{t: t}}, - }, - }, - Started: true, - }) + nginxA, err := Run( + ctx, nginxAlpineImage, + WithExposedPorts(nginxDefaultPort), + WithLogConsumerConfig(&LogConsumerConfig{ + Consumers: []LogConsumer{&testLogConsumer{t: t}}, + }), + ) CleanupContainer(t, nginxA) require.NoError(t, err) @@ -257,22 +188,16 @@ func TestContainerTerminationResetsState(t *testing.T) { func TestContainerStateAfterTermination(t *testing.T) { createContainerFn := func(ctx context.Context) (Container, error) { - return GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - LogConsumerCfg: &LogConsumerConfig{ - Consumers: []LogConsumer{&testLogConsumer{t: t}}, - }, - }, - Started: true, - }) + return Run( + ctx, nginxAlpineImage, + WithExposedPorts(nginxDefaultPort), + WithLogConsumerConfig(&LogConsumerConfig{ + Consumers: []LogConsumer{&testLogConsumer{t: t}}, + }), + ) } - t.Run("Nil State after termination", func(t *testing.T) { + t.Run("nil-state-after-termination", func(t *testing.T) { ctx := context.Background() nginx, err := createContainerFn(ctx) CleanupContainer(t, nginx) @@ -300,7 +225,7 @@ func TestContainerStateAfterTermination(t *testing.T) { require.NoError(t, err) }) - t.Run("Nil State after termination if raw as already set", func(t *testing.T) { + t.Run("nil-state-after-termination/raw-already-set", func(t *testing.T) { ctx := context.Background() nginx, err := createContainerFn(ctx) CleanupContainer(t, nginx) @@ -321,22 +246,14 @@ func TestContainerStateAfterTermination(t *testing.T) { } func TestContainerTerminationRemovesDockerImage(t *testing.T) { - t.Run("if not built from Dockerfile", func(t *testing.T) { + clientCtx := context.Background() + dockerClient, err := NewDockerClientWithOpts(clientCtx) + require.NoError(t, err) + defer dockerClient.Close() + + t.Run("not-built-from-dockerfile", func(t *testing.T) { ctx := context.Background() - dockerClient, err := NewDockerClientWithOpts(ctx) - require.NoError(t, err) - defer dockerClient.Close() - - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - }, - Started: true, - }) + ctr, err := Run(ctx, nginxAlpineImage, WithExposedPorts(nginxDefaultPort)) CleanupContainer(t, ctr) require.NoError(t, err) @@ -347,24 +264,16 @@ func TestContainerTerminationRemovesDockerImage(t *testing.T) { require.NoErrorf(t, err, "nginx image should not have been removed") }) - t.Run("if built from Dockerfile", func(t *testing.T) { + t.Run("built-from-dockerfile", func(t *testing.T) { ctx := context.Background() - dockerClient, err := NewDockerClientWithOpts(ctx) - require.NoError(t, err) - defer dockerClient.Close() - - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ + ctr, err := Run( + ctx, "", + WithDockerfile(FromDockerfile{ Context: filepath.Join(".", "testdata"), - }, - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - } - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) + }), + WithExposedPorts("6379/tcp"), + WithWaitStrategy(wait.ForLog("Ready to accept connections")), + ) CleanupContainer(t, ctr) require.NoError(t, err) containerID := ctr.GetContainerID() @@ -382,31 +291,20 @@ func TestContainerTerminationRemovesDockerImage(t *testing.T) { func TestTwoContainersExposingTheSamePort(t *testing.T) { ctx := context.Background() - nginxA, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - WaitingFor: wait.ForHTTP("/").WithPort(nginxDefaultPort), - }, - Started: true, - }) + + nginxA, err := Run( + ctx, nginxAlpineImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForHTTP("/").WithPort(nginxDefaultPort)), + ) CleanupContainer(t, nginxA) require.NoError(t, err) - nginxB, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - WaitingFor: wait.ForHTTP("/").WithPort(nginxDefaultPort), - }, - Started: true, - }) + nginxB, err := Run( + ctx, nginxAlpineImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForHTTP("/").WithPort(nginxDefaultPort)), + ) CleanupContainer(t, nginxB) require.NoError(t, err) @@ -432,17 +330,11 @@ func TestTwoContainersExposingTheSamePort(t *testing.T) { func TestContainerCreation(t *testing.T) { ctx := context.Background() - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - WaitingFor: wait.ForHTTP("/").WithPort(nginxDefaultPort), - }, - Started: true, - }) + nginxC, err := Run( + ctx, nginxAlpineImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForHTTP("/").WithPort(nginxDefaultPort)), + ) CleanupContainer(t, nginxC) require.NoError(t, err) @@ -457,11 +349,12 @@ func TestContainerCreation(t *testing.T) { networkIP, err := nginxC.ContainerIP(ctx) require.NoError(t, err) require.NotEmptyf(t, networkIP, "Expected an IP address, got %v", networkIP) + networkAliases, err := nginxC.NetworkAliases(ctx) require.NoError(t, err) require.Lenf(t, networkAliases, 1, "Expected number of connected networks %d. Got %d.", 0, len(networkAliases)) require.Contains(t, networkAliases, "bridge") - assert.Emptyf(t, networkAliases["bridge"], "Expected number of aliases for 'bridge' network %d. Got %d.", 0, len(networkAliases["bridge"])) + require.Emptyf(t, networkAliases["bridge"], "Expected number of aliases for 'bridge' network %d. Got %d.", 0, len(networkAliases["bridge"])) } func TestContainerCreationWithName(t *testing.T) { @@ -470,19 +363,12 @@ func TestContainerCreationWithName(t *testing.T) { creationName := fmt.Sprintf("%s_%d", "test_container", time.Now().Unix()) expectedName := "/" + creationName // inspect adds '/' in the beginning - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - WaitingFor: wait.ForHTTP("/").WithPort(nginxDefaultPort), - Name: creationName, - Networks: []string{"bridge"}, - }, - Started: true, - }) + nginxC, err := Run( + ctx, nginxAlpineImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForHTTP("/").WithPort(nginxDefaultPort)), + WithName(creationName), + ) CleanupContainer(t, nginxC) require.NoError(t, err) @@ -490,16 +376,18 @@ func TestContainerCreationWithName(t *testing.T) { require.NoError(t, err) name := inspect.Name - assert.Equalf(t, expectedName, name, "Expected container name '%s'. Got '%s'.", expectedName, name) + require.Equalf(t, expectedName, name, "Expected container name '%s'. Got '%s'.", expectedName, name) + networks, err := nginxC.Networks(ctx) require.NoError(t, err) require.Lenf(t, networks, 1, "Expected networks 1. Got '%d'.", len(networks)) + network := networks[0] switch providerType { case ProviderDocker: - assert.Equalf(t, Bridge, network, "Expected network name '%s'. Got '%s'.", Bridge, network) + require.Equalf(t, Bridge, network, "Expected network name '%s'. Got '%s'.", Bridge, network) case ProviderPodman: - assert.Equalf(t, Podman, network, "Expected network name '%s'. Got '%s'.", Podman, network) + require.Equalf(t, Podman, network, "Expected network name '%s'. Got '%s'.", Podman, network) } endpoint, err := nginxC.PortEndpoint(ctx, nginxDefaultPort, "http") @@ -516,17 +404,11 @@ func TestContainerCreationAndWaitForListeningPortLongEnough(t *testing.T) { ctx := context.Background() // delayed-nginx will wait 2s before opening port - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxDelayedImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - WaitingFor: wait.ForHTTP("/").WithPort(nginxDefaultPort), // default startupTimeout is 60s - }, - Started: true, - }) + nginxC, err := Run( + ctx, nginxDelayedImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForHTTP("/").WithPort(nginxDefaultPort)), + ) CleanupContainer(t, nginxC) require.NoError(t, err) @@ -542,36 +424,23 @@ func TestContainerCreationAndWaitForListeningPortLongEnough(t *testing.T) { func TestContainerCreationTimesOut(t *testing.T) { ctx := context.Background() // delayed-nginx will wait 2s before opening port - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxDelayedImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - WaitingFor: wait.ForListeningPort(nginxDefaultPort).WithStartupTimeout(1 * time.Second), - }, - Started: true, - }) + nginxC, err := Run( + ctx, nginxDelayedImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort).WithStartupTimeout(1*time.Second)), + ) CleanupContainer(t, nginxC) - - assert.Errorf(t, err, "Expected timeout") + require.Errorf(t, err, "Expected timeout") } func TestContainerRespondsWithHttp200ForIndex(t *testing.T) { ctx := context.Background() - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - WaitingFor: wait.ForHTTP("/").WithStartupTimeout(10 * time.Second), - }, - Started: true, - }) + nginxC, err := Run( + ctx, nginxAlpineImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForHTTP("/").WithStartupTimeout(10*time.Second)), + ) CleanupContainer(t, nginxC) require.NoError(t, err) @@ -587,57 +456,41 @@ func TestContainerRespondsWithHttp200ForIndex(t *testing.T) { func TestContainerCreationTimesOutWithHttp(t *testing.T) { ctx := context.Background() // delayed-nginx will wait 2s before opening port - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxDelayedImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - WaitingFor: wait.ForHTTP("/").WithStartupTimeout(time.Millisecond * 500), - }, - Started: true, - }) + nginxC, err := Run( + ctx, nginxDelayedImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForHTTP("/").WithStartupTimeout(500*time.Millisecond)), + ) CleanupContainer(t, nginxC) require.Error(t, err, "expected timeout") } func TestContainerCreationWaitsForLogContextTimeout(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: mysqlImage, - ExposedPorts: []string{"3306/tcp", "33060/tcp"}, - Env: map[string]string{ + c, err := Run( + ctx, mysqlImage, + WithExposedPorts("3306/tcp", "33060/tcp"), + WithEnv(map[string]string{ "MYSQL_ROOT_PASSWORD": "password", "MYSQL_DATABASE": "database", - }, - WaitingFor: wait.ForLog("test context timeout").WithStartupTimeout(1 * time.Second), - } - c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) + }), + WithWaitStrategy(wait.ForLog("test context timeout").WithStartupTimeout(1*time.Second)), + ) CleanupContainer(t, c) - assert.Errorf(t, err, "Expected timeout") + require.Errorf(t, err, "Expected timeout") } func TestContainerCreationWaitsForLog(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: mysqlImage, - ExposedPorts: []string{"3306/tcp", "33060/tcp"}, - Env: map[string]string{ + mysqlC, err := Run( + ctx, mysqlImage, + WithExposedPorts("3306/tcp", "33060/tcp"), + WithEnv(map[string]string{ "MYSQL_ROOT_PASSWORD": "password", "MYSQL_DATABASE": "database", - }, - WaitingFor: wait.ForLog("port: 3306 MySQL Community Server - GPL"), - } - mysqlC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) + }), + WithWaitStrategy(wait.ForLog("port: 3306 MySQL Community Server - GPL")), + ) CleanupContainer(t, mysqlC) require.NoError(t, err) } @@ -647,26 +500,19 @@ func Test_BuildContainerFromDockerfileWithBuildArgs(t *testing.T) { // fromDockerfileWithBuildArgs { ba := "build args value" - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ + c, err := Run( + ctx, "", + WithDockerfile(FromDockerfile{ Context: filepath.Join(".", "testdata"), Dockerfile: "args.Dockerfile", BuildArgs: map[string]*string{ "FOO": &ba, }, - }, - ExposedPorts: []string{"8080/tcp"}, - WaitingFor: wait.ForLog("ready"), - } + }), + WithExposedPorts("8080/tcp"), + WithWaitStrategy(wait.ForLog("ready")), + ) // } - - genContainerReq := GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - } - - c, err := GenericContainer(ctx, genContainerReq) CleanupContainer(t, c) require.NoError(t, err) @@ -696,22 +542,15 @@ func Test_BuildContainerFromDockerfileWithBuildLog(t *testing.T) { ctx := context.Background() // fromDockerfile { - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ + c, err := Run( + ctx, "", + WithDockerfile(FromDockerfile{ Context: filepath.Join(".", "testdata"), Dockerfile: "buildlog.Dockerfile", PrintBuildLog: true, - }, - } + }), + ) // } - - genContainerReq := GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - } - - c, err := GenericContainer(ctx, genContainerReq) CleanupContainer(t, c) require.NoError(t, err) @@ -723,7 +562,7 @@ func Test_BuildContainerFromDockerfileWithBuildLog(t *testing.T) { temp := strings.Split(string(out), "\n") require.NotEmpty(t, temp) - assert.Regexpf(t, `^Step\s*1/\d+\s*:\s*FROM alpine$`, temp[0], "Expected stdout first line to be %s. Got '%s'.", "Step 1/* : FROM alpine", temp[0]) + require.Regexpf(t, `^Step\s*1/\d+\s*:\s*FROM alpine$`, temp[0], "Expected stdout first line to be %s. Got '%s'.", "Step 1/* : FROM alpine", temp[0]) } func Test_BuildContainerFromDockerfileWithBuildLogWriter(t *testing.T) { @@ -731,23 +570,14 @@ func Test_BuildContainerFromDockerfileWithBuildLogWriter(t *testing.T) { ctx := context.Background() - // fromDockerfile { - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ + c, err := Run( + ctx, "", + WithDockerfile(FromDockerfile{ Context: filepath.Join(".", "testdata"), Dockerfile: "buildlog.Dockerfile", BuildLogWriter: &buffer, - }, - } - // } - - genContainerReq := GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - } - - c, err := GenericContainer(ctx, genContainerReq) + }), + ) CleanupContainer(t, c) require.NoError(t, err) @@ -759,23 +589,18 @@ func Test_BuildContainerFromDockerfileWithBuildLogWriter(t *testing.T) { func TestContainerCreationWaitsForLogAndPortContextTimeout(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: mysqlImage, - ExposedPorts: []string{"3306/tcp", "33060/tcp"}, - Env: map[string]string{ + c, err := Run( + ctx, mysqlImage, + WithExposedPorts("3306/tcp", "33060/tcp"), + WithEnv(map[string]string{ "MYSQL_ROOT_PASSWORD": "password", "MYSQL_DATABASE": "database", - }, - WaitingFor: wait.ForAll( - wait.ForLog("I love testcontainers-go"), + }), + WithWaitStrategy( + wait.ForLog("I love testcontainers-go").WithStartupTimeout(10*time.Second), wait.ForListeningPort("3306/tcp"), ), - } - c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) + ) CleanupContainer(t, c) require.Errorf(t, err, "Expected timeout") } @@ -783,33 +608,23 @@ func TestContainerCreationWaitsForLogAndPortContextTimeout(t *testing.T) { func TestContainerCreationWaitingForHostPort(t *testing.T) { ctx := context.Background() // exposePorts { - req := ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForListeningPort(nginxDefaultPort), - } + nginx, err := Run( + ctx, nginxAlpineImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + ) // } - nginx, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) CleanupContainer(t, nginx) require.NoError(t, err) } func TestContainerCreationWaitingForHostPortWithoutBashThrowsAnError(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForListeningPort(nginxDefaultPort), - } - nginx, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) + nginx, err := Run( + ctx, nginxAlpineImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + ) CleanupContainer(t, nginx) require.NoError(t, err) } @@ -823,19 +638,10 @@ func TestCMD(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: "alpine", - WaitingFor: wait.ForAll( - wait.ForLog("command override!"), - ), - Cmd: []string{"echo", "command override!"}, - } - - c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) + c, err := Run(ctx, "alpine", + WithWaitStrategy(wait.ForLog("command override!")), + WithCmd("echo", "command override!"), + ) CleanupContainer(t, c) require.NoError(t, err) } @@ -849,19 +655,10 @@ func TestEntrypoint(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: "alpine", - WaitingFor: wait.ForAll( - wait.ForLog("entrypoint override!"), - ), - Entrypoint: []string{"echo", "entrypoint override!"}, - } - - c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) + c, err := Run(ctx, "alpine", + WithWaitStrategy(wait.ForLog("entrypoint override!")), + WithEntrypoint("echo", "entrypoint override!"), + ) CleanupContainer(t, c) require.NoError(t, err) } @@ -875,37 +672,24 @@ func TestWorkingDir(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: "alpine", - WaitingFor: wait.ForAll( - wait.ForLog("/var/tmp/test"), - ), - Entrypoint: []string{"pwd"}, - ConfigModifier: func(c *container.Config) { + c, err := Run(ctx, "alpine", + WithEntrypoint("pwd"), + WithWaitStrategy(wait.ForLog("/var/tmp/test")), + WithConfigModifier(func(c *container.Config) { c.WorkingDir = "/var/tmp/test" - }, - } - - c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) + }), + ) CleanupContainer(t, c) require.NoError(t, err) } -func ExampleDockerProvider_CreateContainer() { +func ExampleRun_containerMethods() { ctx := context.Background() - req := ContainerRequest{ - Image: "nginx:alpine", - ExposedPorts: []string{"80/tcp"}, - WaitingFor: wait.ForHTTP("/").WithStartupTimeout(10 * time.Second), - } - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + nginxC, err := Run( + ctx, "nginx:alpine", + WithExposedPorts("80/tcp"), + WithWaitStrategy(wait.ForHTTP("/").WithStartupTimeout(10*time.Second)), + ) defer func() { if err := TerminateContainer(nginxC); err != nil { log.Printf("failed to terminate container: %s", err) @@ -924,62 +708,29 @@ func ExampleDockerProvider_CreateContainer() { fmt.Println(state.Running) - // Output: - // true -} - -func ExampleContainer_Host() { - ctx := context.Background() - req := ContainerRequest{ - Image: "nginx:alpine", - ExposedPorts: []string{"80/tcp"}, - WaitingFor: wait.ForHTTP("/").WithStartupTimeout(10 * time.Second), - } - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - defer func() { - if err := TerminateContainer(nginxC); err != nil { - log.Printf("failed to terminate container: %s", err) - } - }() - if err != nil { - log.Printf("failed to create container: %s", err) - return - } // containerHost { ip, err := nginxC.Host(ctx) if err != nil { log.Printf("failed to create container: %s", err) return } + port, _ := nginxC.MappedPort(ctx, "80") + _, _ = http.Get(fmt.Sprintf("http://%s:%s", ip, port.Port())) // } fmt.Println(ip) - state, err := nginxC.State(ctx) - if err != nil { - log.Printf("failed to get container state: %s", err) - return - } - - fmt.Println(state.Running) - // Output: - // localhost // true + // localhost } func ExampleContainer_Start() { ctx := context.Background() - req := ContainerRequest{ - Image: "nginx:alpine", - ExposedPorts: []string{"80/tcp"}, - WaitingFor: wait.ForHTTP("/").WithStartupTimeout(10 * time.Second), - } - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: req, - }) + nginxC, err := Run(ctx, "nginx:alpine", + WithExposedPorts("80/tcp"), + WithWaitStrategy(wait.ForHTTP("/").WithStartupTimeout(10*time.Second)), + WithNoStart(), + ) defer func() { if err := TerminateContainer(nginxC); err != nil { log.Printf("failed to terminate container: %s", err) @@ -1009,14 +760,11 @@ func ExampleContainer_Start() { func ExampleContainer_Stop() { ctx := context.Background() - req := ContainerRequest{ - Image: "nginx:alpine", - ExposedPorts: []string{"80/tcp"}, - WaitingFor: wait.ForHTTP("/").WithStartupTimeout(10 * time.Second), - } - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: req, - }) + nginxC, err := Run(ctx, "nginx:alpine", + WithExposedPorts("80/tcp"), + WithWaitStrategy(wait.ForHTTP("/").WithStartupTimeout(10*time.Second)), + WithNoStart(), + ) defer func() { if err := TerminateContainer(nginxC); err != nil { log.Printf("failed to terminate container: %s", err) @@ -1041,45 +789,6 @@ func ExampleContainer_Stop() { // Container has been stopped } -func ExampleContainer_MappedPort() { - ctx := context.Background() - req := ContainerRequest{ - Image: "nginx:alpine", - ExposedPorts: []string{"80/tcp"}, - WaitingFor: wait.ForHTTP("/").WithStartupTimeout(10 * time.Second), - } - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - defer func() { - if err := TerminateContainer(nginxC); err != nil { - log.Printf("failed to terminate container: %s", err) - } - }() - if err != nil { - log.Printf("failed to create and start container: %s", err) - return - } - - // buildingAddresses { - ip, _ := nginxC.Host(ctx) - port, _ := nginxC.MappedPort(ctx, "80") - _, _ = http.Get(fmt.Sprintf("http://%s:%s", ip, port.Port())) - // } - - state, err := nginxC.State(ctx) - if err != nil { - log.Printf("failed to get container state: %s", err) - return - } - - fmt.Println(state.Running) - - // Output: - // true -} - func TestContainerCreationWithVolumeAndFileWritingToIt(t *testing.T) { absPath, err := filepath.Abs(filepath.Join(".", "testdata", "hello.sh")) require.NoError(t, err) @@ -1090,23 +799,17 @@ func TestContainerCreationWithVolumeAndFileWritingToIt(t *testing.T) { volumeName := "volumeName" // Create the container that writes into the mounted volume. - bashC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: "bash:5.2.26", - Files: []ContainerFile{ - { - HostFilePath: absPath, - ContainerFilePath: "/hello.sh", - FileMode: 700, - }, - }, - Mounts: Mounts(VolumeMount(volumeName, "/data")), - Cmd: []string{"bash", "/hello.sh"}, - WaitingFor: wait.ForLog("done"), - }, - Started: true, - }) + bashC, err := Run( + ctx, "bash:5.2.26", + WithCmd("bash", "/hello.sh"), + WithFiles(ContainerFile{ + HostFilePath: absPath, + ContainerFilePath: "/hello.sh", + FileMode: 0o700, + }), + WithMounts(VolumeMount(volumeName, "/data")), + WithWaitStrategy(wait.ForLog("done")), + ) CleanupContainer(t, bashC, RemoveVolumes(volumeName)) require.NoError(t, err) } @@ -1121,23 +824,17 @@ func TestContainerCreationWithVolumeCleaning(t *testing.T) { volumeName := "volumeName" // Create the container that writes into the mounted volume. - bashC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: "bash:5.2.26", - Files: []ContainerFile{ - { - HostFilePath: absPath, - ContainerFilePath: "/hello.sh", - FileMode: 700, - }, - }, - Mounts: Mounts(VolumeMount(volumeName, "/data")), - Cmd: []string{"bash", "/hello.sh"}, - WaitingFor: wait.ForLog("done"), - }, - Started: true, - }) + bashC, err := Run( + ctx, "bash:5.2.26", + WithCmd("bash", "/hello.sh"), + WithFiles(ContainerFile{ + HostFilePath: absPath, + ContainerFilePath: "/hello.sh", + FileMode: 0o700, + }), + WithMounts(VolumeMount(volumeName, "/data")), + WithWaitStrategy(wait.ForLog("done")), + ) require.NoError(t, err) err = bashC.Terminate(ctx, RemoveVolumes(volumeName)) CleanupContainer(t, bashC, RemoveVolumes(volumeName)) @@ -1175,17 +872,10 @@ func TestContainerTerminationOptions(t *testing.T) { func TestContainerWithTmpFs(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: "busybox", - Cmd: []string{"sleep", "10"}, - Tmpfs: map[string]string{"/testtmpfs": "rw"}, - } - - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) + ctr, err := Run(ctx, "busybox", + WithCmd("sleep", "10"), + WithTmpfs(map[string]string{"/testtmpfs": "rw"}), + ) CleanupContainer(t, ctr) require.NoError(t, err) @@ -1216,29 +906,17 @@ func TestContainerWithTmpFs(t *testing.T) { } func TestContainerNonExistentImage(t *testing.T) { - t.Run("if the image not found don't propagate the error", func(t *testing.T) { - ctr, err := GenericContainer(context.Background(), GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - Image: "postgres:nonexistent-version", - }, - Started: true, - }) + t.Run("image-not-found/no-propagate-error", func(t *testing.T) { + ctr, err := Run(context.Background(), "postgres:nonexistent-version") CleanupContainer(t, ctr) require.ErrorIs(t, err, errdefs.ErrNotFound, "the error should have been an errdefs.ErrNotFound: %v", err) }) - t.Run("the context cancellation is propagated to container creation", func(t *testing.T) { + t.Run("context-cancellation-propagated-to-container-creation", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() - c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: "postgres:12", - WaitingFor: wait.ForLog("log"), - }, - Started: true, - }) + c, err := Run(ctx, "postgres:12", WithWaitStrategy(wait.ForLog("log"))) CleanupContainer(t, c) require.ErrorIsf(t, err, ctx.Err(), "err should be a ctx cancelled error %v", err) }) @@ -1248,35 +926,21 @@ func TestContainerCustomPlatformImage(t *testing.T) { if providerType == ProviderPodman { t.Skip("Incompatible Docker API version for Podman") } - t.Run("error with a non-existent platform", func(t *testing.T) { + t.Run("error/non-existent-platform", func(t *testing.T) { t.Parallel() nonExistentPlatform := "windows/arm12" ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() - c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: "redis:latest", - ImagePlatform: nonExistentPlatform, - }, - Started: false, - }) + c, err := Run(ctx, "redis:latest", WithImagePlatform(nonExistentPlatform)) CleanupContainer(t, c) require.Error(t, err) }) - t.Run("specific platform should be propagated", func(t *testing.T) { + t.Run("platform-should-be-propagated", func(t *testing.T) { t.Parallel() ctx := context.Background() - c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: "mysql:8.0.36", - ImagePlatform: "linux/amd64", - }, - Started: false, - }) + c, err := Run(ctx, "mysql:8.0.36", WithImagePlatform("linux/amd64"), WithNoStart()) CleanupContainer(t, c) require.NoError(t, err) @@ -1289,8 +953,8 @@ func TestContainerCustomPlatformImage(t *testing.T) { img, err := dockerCli.ImageInspect(ctx, ctr.Image) require.NoError(t, err) - assert.Equal(t, "linux", img.Os) - assert.Equal(t, "amd64", img.Architecture) + require.Equal(t, "linux", img.Os) + require.Equal(t, "amd64", img.Architecture) }) } @@ -1298,18 +962,14 @@ func TestContainerWithCustomHostname(t *testing.T) { ctx := context.Background() name := fmt.Sprintf("some-nginx-%s-%d", t.Name(), rand.Int()) hostname := fmt.Sprintf("my-nginx-%s-%d", t.Name(), rand.Int()) - req := ContainerRequest{ - Name: name, - Image: nginxImage, - ConfigModifier: func(c *container.Config) { + + ctr, err := Run( + ctx, nginxImage, + WithName(name), + WithConfigModifier(func(c *container.Config) { c.Hostname = hostname - }, - } - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) + }), + ) CleanupContainer(t, ctr) require.NoError(t, err) @@ -1318,12 +978,7 @@ func TestContainerWithCustomHostname(t *testing.T) { } func TestContainerInspect_RawInspectIsCleanedOnStop(t *testing.T) { - ctr, err := GenericContainer(context.Background(), GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - Image: nginxImage, - }, - Started: true, - }) + ctr, err := Run(context.Background(), nginxImage) CleanupContainer(t, ctr) require.NoError(t, err) @@ -1366,15 +1021,10 @@ func TestDockerContainerCopyFileToContainer(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForListeningPort(nginxDefaultPort), - }, - Started: true, - }) + nginxC, err := Run(ctx, nginxImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + ) CleanupContainer(t, nginxC) require.NoError(t, err) @@ -1389,15 +1039,11 @@ func TestDockerContainerCopyFileToContainer(t *testing.T) { func TestDockerContainerCopyDirToContainer(t *testing.T) { ctx := context.Background() - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForListeningPort(nginxDefaultPort), - }, - Started: true, - }) + nginxC, err := Run( + ctx, nginxImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + ) CleanupContainer(t, nginxC) require.NoError(t, err) @@ -1447,15 +1093,13 @@ func TestDockerCreateContainerWithFiles(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - Image: "nginx:1.17.6", - ExposedPorts: []string{"80/tcp"}, - WaitingFor: wait.ForListeningPort("80/tcp"), - Files: tc.files, - }, - Started: false, - }) + nginxC, err := Run( + ctx, nginxImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + WithFiles(tc.files...), + WithNoStart(), + ) CleanupContainer(t, nginxC) if err != nil { @@ -1532,15 +1176,13 @@ func TestDockerCreateContainerWithDirs(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - Image: "nginx:1.17.6", - ExposedPorts: []string{"80/tcp"}, - WaitingFor: wait.ForListeningPort("80/tcp"), - Files: []ContainerFile{tc.dir}, - }, - Started: false, - }) + nginxC, err := Run( + ctx, nginxImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + WithFiles(tc.dir), + WithNoStart(), + ) CleanupContainer(t, nginxC) require.Equal(t, (err != nil), tc.hasError) @@ -1572,15 +1214,11 @@ func TestDockerContainerCopyToContainer(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForListeningPort(nginxDefaultPort), - }, - Started: true, - }) + nginxC, err := Run( + ctx, nginxImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + ) CleanupContainer(t, nginxC) require.NoError(t, err) @@ -1600,15 +1238,11 @@ func TestDockerContainerCopyFileFromContainer(t *testing.T) { require.NoError(t, err) ctx := context.Background() - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForListeningPort(nginxDefaultPort), - }, - Started: true, - }) + nginxC, err := Run( + ctx, nginxImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + ) CleanupContainer(t, nginxC) require.NoError(t, err) @@ -1624,21 +1258,17 @@ func TestDockerContainerCopyFileFromContainer(t *testing.T) { fileContentFromContainer, err := io.ReadAll(reader) require.NoError(t, err) - assert.Equal(t, fileContent, fileContentFromContainer) + require.Equal(t, fileContent, fileContentFromContainer) } func TestDockerContainerCopyEmptyFileFromContainer(t *testing.T) { ctx := context.Background() - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForListeningPort(nginxDefaultPort), - }, - Started: true, - }) + nginxC, err := Run( + ctx, nginxImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + ) CleanupContainer(t, nginxC) require.NoError(t, err) @@ -1680,20 +1310,16 @@ func TestDockerContainerResources(t *testing.T) { }, } - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForListeningPort(nginxDefaultPort), - HostConfigModifier: func(hc *container.HostConfig) { - hc.Resources = container.Resources{ - Ulimits: expected, - } - }, - }, - Started: true, - }) + nginxC, err := Run( + ctx, nginxImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + WithHostConfigModifier(func(hc *container.HostConfig) { + hc.Resources = container.Resources{ + Ulimits: expected, + } + }), + ) CleanupContainer(t, nginxC) require.NoError(t, err) @@ -1706,7 +1332,7 @@ func TestDockerContainerResources(t *testing.T) { resp, err := c.ContainerInspect(ctx, containerID) require.NoError(t, err) - assert.Equal(t, expected, resp.HostConfig.Ulimits) + require.Equal(t, expected, resp.HostConfig.Ulimits) } func TestContainerCapAdd(t *testing.T) { @@ -1718,18 +1344,14 @@ func TestContainerCapAdd(t *testing.T) { expected := "CAP_IPC_LOCK" - nginx, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForListeningPort(nginxDefaultPort), - HostConfigModifier: func(hc *container.HostConfig) { - hc.CapAdd = []string{expected} - }, - }, - Started: true, - }) + nginx, err := Run( + ctx, nginxImage, + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + WithHostConfigModifier(func(hc *container.HostConfig) { + hc.CapAdd = []string{expected} + }), + ) CleanupContainer(t, nginx) require.NoError(t, err) @@ -1741,28 +1363,20 @@ func TestContainerCapAdd(t *testing.T) { resp, err := dockerClient.ContainerInspect(ctx, containerID) require.NoError(t, err) - assert.Equal(t, strslice.StrSlice{expected}, resp.HostConfig.CapAdd) + require.Equal(t, strslice.StrSlice{expected}, resp.HostConfig.CapAdd) } func TestContainerRunningCheckingStatusCode(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: "influxdb:1.8.10-alpine", - ExposedPorts: []string{"8086/tcp"}, - ImagePlatform: "linux/amd64", // influxdb doesn't provide an alpine+arm build (https://github.com/influxdata/influxdata-docker/issues/335) - WaitingFor: wait.ForAll( - wait.ForHTTP("/ping").WithPort("8086/tcp").WithStatusCodeMatcher( - func(status int) bool { - return status == http.StatusNoContent - }, - ), - ), - } - - influx, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + influx, err := Run( + ctx, "influxdb:1.8.10-alpine", + WithExposedPorts("8086/tcp"), + WithWaitStrategy(wait.ForHTTP("/ping").WithPort("8086/tcp").WithStatusCodeMatcher( + func(status int) bool { + return status == http.StatusNoContent + }, + )), + ) CleanupContainer(t, influx) require.NoError(t, err) } @@ -1771,19 +1385,14 @@ func TestContainerWithUserID(t *testing.T) { const expectedUserID = "60125" ctx := context.Background() - req := ContainerRequest{ - Image: "alpine:latest", - Cmd: []string{"sh", "-c", "id -u"}, - WaitingFor: wait.ForExit(), - ConfigModifier: func(c *container.Config) { + ctr, err := Run( + ctx, "alpine:latest", + WithCmd("sh", "-c", "id -u"), + WithWaitStrategy(wait.ForExit()), + WithConfigModifier(func(c *container.Config) { c.User = expectedUserID - }, - } - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) + }), + ) CleanupContainer(t, ctr) require.NoError(t, err) @@ -1798,16 +1407,11 @@ func TestContainerWithUserID(t *testing.T) { func TestContainerWithNoUserID(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: "alpine:latest", - Cmd: []string{"sh", "-c", "id -u"}, - WaitingFor: wait.ForExit(), - } - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) + ctr, err := Run( + ctx, "alpine:latest", + WithCmd("sh", "-c", "id -u"), + WithWaitStrategy(wait.ForExit()), + ) CleanupContainer(t, ctr) require.NoError(t, err) @@ -1817,7 +1421,7 @@ func TestContainerWithNoUserID(t *testing.T) { b, err := io.ReadAll(r) require.NoError(t, err) actual := regexp.MustCompile(`\D+`).ReplaceAllString(string(b), "") - assert.Equal(t, "0", actual) + require.Equal(t, "0", actual) } func TestGetGatewayIP(t *testing.T) { @@ -1839,27 +1443,17 @@ func TestGetGatewayIP(t *testing.T) { func TestNetworkModeWithContainerReference(t *testing.T) { ctx := context.Background() - nginxA, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - }, - Started: true, - }) + nginxA, err := Run(ctx, nginxAlpineImage) CleanupContainer(t, nginxA) require.NoError(t, err) networkMode := fmt.Sprintf("container:%v", nginxA.GetContainerID()) - nginxB, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - HostConfigModifier: func(hc *container.HostConfig) { - hc.NetworkMode = container.NetworkMode(networkMode) - }, - }, - Started: true, - }) + nginxB, err := Run( + ctx, nginxAlpineImage, + WithHostConfigModifier(func(hc *container.HostConfig) { + hc.NetworkMode = container.NetworkMode(networkMode) + }), + ) CleanupContainer(t, nginxB) require.NoError(t, err) } @@ -1906,7 +1500,7 @@ func assertExtractedFiles(t *testing.T, ctx context.Context, container Container if err != nil { require.NoError(t, err) } - assert.Equal(t, srcBytes, untarBytes) + require.Equal(t, srcBytes, untarBytes) } } @@ -1916,15 +1510,11 @@ func TestDockerProviderFindContainerByName(t *testing.T) { require.NoError(t, err) defer provider.Close() - c1, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Name: "test", - Image: "nginx:1.17.6", - WaitingFor: wait.ForExposedPort(), - }, - Started: true, - }) + c1, err := Run( + ctx, nginxAlpineImage, + WithName("test"), + WithWaitStrategy(wait.ForExposedPort()), + ) CleanupContainer(t, c1) require.NoError(t, err) @@ -1934,22 +1524,18 @@ func TestDockerProviderFindContainerByName(t *testing.T) { c1Name := c1Inspect.Name - c2, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Name: "test2", - Image: "nginx:1.17.6", - WaitingFor: wait.ForExposedPort(), - }, - Started: true, - }) + c2, err := Run( + ctx, nginxAlpineImage, + WithName("test2"), + WithWaitStrategy(wait.ForExposedPort()), + ) CleanupContainer(t, c2) require.NoError(t, err) c, err := provider.findContainerByName(ctx, "test") require.NoError(t, err) require.NotNil(t, c) - assert.Contains(t, c.Names, c1Name) + require.Contains(t, c.Names, c1Name) } func TestImageBuiltFromDockerfile_KeepBuiltImage(t *testing.T) { @@ -1969,16 +1555,14 @@ func TestImageBuiltFromDockerfile_KeepBuiltImage(t *testing.T) { defer func() { _ = provider.Close() }() cli := provider.Client() // Create container. - c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - FromDockerfile: FromDockerfile{ - Context: "testdata", - Dockerfile: "echo.Dockerfile", - KeepImage: tt.keepBuiltImage, - }, - }, - }) + c, err := Run( + ctx, "", + WithDockerfile(FromDockerfile{ + Context: "testdata", + Dockerfile: "echo.Dockerfile", + KeepImage: tt.keepBuiltImage, + }), + ) CleanupContainer(t, c) require.NoError(t, err, "create container should not fail") // Get the image ID. @@ -2107,8 +1691,8 @@ func TestDockerProvider_BuildImage_Retries(t *testing.T) { require.NoError(t, err) } - assert.Positive(t, m.imageBuildCount) - assert.Equal(t, tt.shouldRetry, m.imageBuildCount > 1) + require.Positive(t, m.imageBuildCount) + require.Equal(t, tt.shouldRetry, m.imageBuildCount > 1) }) } } @@ -2158,8 +1742,8 @@ func TestDockerProvider_waitContainerCreation_retries(t *testing.T) { defer cancel() _, _ = p.waitContainerCreation(ctx, "someID") - assert.Positive(t, m.containerListCount) - assert.Equal(t, tt.shouldRetry, m.containerListCount > 1) + require.Positive(t, m.containerListCount) + require.Equal(t, tt.shouldRetry, m.containerListCount > 1) }) } } @@ -2219,8 +1803,8 @@ func TestDockerProvider_attemptToPullImage_retries(t *testing.T) { defer cancel() _ = p.attemptToPullImage(ctx, "someTag", image.PullOptions{}) - assert.Positive(t, m.imagePullCount) - assert.Equal(t, tt.shouldRetry, m.imagePullCount > 1) + require.Positive(t, m.imagePullCount) + require.Equal(t, tt.shouldRetry, m.imagePullCount > 1) }) } } @@ -2230,22 +1814,12 @@ func TestCustomPrefixTrailingSlashIsProperlyRemovedIfPresent(t *testing.T) { dockerImage := "amazonlinux/amazonlinux:2023" ctx := context.Background() - req := ContainerRequest{ - Image: dockerImage, - ImageSubstitutors: []ImageSubstitutor{newPrependHubRegistry(hubPrefixWithTrailingSlash)}, - } - - c, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + c, err := Run(ctx, dockerImage, + WithImageSubstitutors(newPrependHubRegistry(hubPrefixWithTrailingSlash)), + ) CleanupContainer(t, c) require.NoError(t, err) - - // enforce the concrete type, as GenericContainer returns an interface, - // which will be changed in future implementations of the library - dockerContainer := c.(*DockerContainer) - require.Equal(t, fmt.Sprintf("%s%s", hubPrefixWithTrailingSlash, dockerImage), dockerContainer.Image) + require.Equal(t, fmt.Sprintf("%s%s", hubPrefixWithTrailingSlash, dockerImage), c.Image) } // TODO: remove this skip check when context rework is merged alongside [core.DockerEnvFile] removal. diff --git a/docs/features/creating_container.md b/docs/features/creating_container.md index 543c7a174f..ee441a7bb1 100644 --- a/docs/features/creating_container.md +++ b/docs/features/creating_container.md @@ -67,7 +67,9 @@ import ( "net/http" "testing" + "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/network" "github.com/testcontainers/testcontainers-go/wait" ) @@ -76,18 +78,13 @@ type nginxContainer struct { URI string } - -func setupNginx(ctx context.Context, networkName string) (*nginxContainer, error) { - req := testcontainers.ContainerRequest{ - Image: "nginx", - ExposedPorts: []string{"80/tcp"}, - Networks: []string{"bridge", networkName}, - WaitingFor: wait.ForHTTP("/"), - } - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) +func setupNginx(ctx context.Context, nw *testcontainers.DockerNetwork) (*nginxContainer, error) { + container, err := testcontainers.Run( + ctx, "nginx", + testcontainers.WithExposedPorts("80/tcp"), + network.WithNetwork([]string{"nginx-alias"}, nw), + testcontainers.WithWaitStrategy(wait.ForHTTP("/")), + ) var nginxC *nginxContainer if container != nil { nginxC = &nginxContainer{Container: container} @@ -122,13 +119,15 @@ func TestIntegrationNginxLatestReturn(t *testing.T) { require.NoError(t, err) testcontainers.CleanupNetwork(t, nw) - nginxC, err := setupNginx(ctx, nw.Name) + nginxC, err := setupNginx(ctx, nw) testcontainers.CleanupContainer(t, nginxC) require.NoError(t, err) resp, err := http.Get(nginxC.URI) + require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) } + ``` @@ -221,15 +220,12 @@ const ( func main() { ctx := context.Background() - n1, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "nginx:1.17.6", - ExposedPorts: []string{"80/tcp"}, - WaitingFor: wait.ForListeningPort("80/tcp"), - Name: reusableContainerName, - }, - Started: true, - }) + n1, err := testcontainers.Run( + ctx, "nginx:1.17.6", + testcontainers.WithExposedPorts("80/tcp"), + testcontainers.WithReuseByName(reusableContainerName), + testcontainers.WithWaitStrategy(wait.ForListeningPort("80/tcp")), + ) defer func() { if err := testcontainers.TerminateContainer(n1); err != nil { log.Printf("failed to terminate container: %s", err) @@ -248,16 +244,12 @@ func main() { return } - n2, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "nginx:1.17.6", - ExposedPorts: []string{"80/tcp"}, - WaitingFor: wait.ForListeningPort("80/tcp"), - Name: reusableContainerName, - }, - Started: true, - Reuse: true, - }) + n2, err := testcontainers.Run( + ctx, "nginx:1.17.6", + testcontainers.WithExposedPorts("80/tcp"), + testcontainers.WithReuseByName(reusableContainerName), + testcontainers.WithWaitStrategy(wait.ForListeningPort("80/tcp")), + ) defer func() { if err := testcontainers.TerminateContainer(n2); err != nil { log.Printf("failed to terminate container: %s", err) diff --git a/docs/features/image_name_substitution.md b/docs/features/image_name_substitution.md index 4272d35c1e..8a98380c10 100644 --- a/docs/features/image_name_substitution.md +++ b/docs/features/image_name_substitution.md @@ -16,7 +16,7 @@ This page describes two approaches for image name substitution: * [Using an Image Name Substitutor](#developing-a-custom-function-for-transforming-image-names-on-the-fly), developing a custom function for transforming image names on the fly. !!!warning - It is assumed that you have already set up a private registry hosting [all the Docker images your build requires](../supported_docker_environment/image_registry_rate_limiting.md#which-images-are-used-by-testcontainers). + It is assumed that you have already set up a private registry hosting [all the Docker images your build requires](#images-used-by-testcontainers). ## Automatically modifying Docker Hub image names diff --git a/docs/features/networking.md b/docs/features/networking.md index 3c758e019f..d948e5f750 100644 --- a/docs/features/networking.md +++ b/docs/features/networking.md @@ -29,7 +29,7 @@ It's important to mention that the name of the network is automatically generate [Creating a network](../../network/examples_test.go) inside_block:createNetwork [Creating a network with options](../../network/examples_test.go) inside_block:newNetworkWithOptions - + ## Exposing container ports to the host @@ -72,7 +72,7 @@ As such, Testcontainers provides a convenience function to obtain an address on It is normally advisable to use `Host` and `MappedPort` together when constructing addresses - for example: -[Getting the container host and mapped port](../../docker_test.go) inside_block:buildingAddresses +[Getting the container host and mapped port](../../docker_test.go) inside_block:containerHost !!! info @@ -129,7 +129,7 @@ But according to those docs, it's supported only for Linux hosts: In the case you need to skip a test on non-Linux hosts, you can use the `SkipIfDockerDesktop` function: -[Skipping tests on non-Linux hosts](../../docker_test.go) inside_block:skipIfDockerDesktop +[Skipping tests on non-Linux hosts](../../network/network_test.go) inside_block:skipIfDockerDesktop It will try to get a Docker client and obtain its Info. In the case the Operating System is "Docker Desktop", it will skip the test. diff --git a/docs/modules/index.md b/docs/modules/index.md index 6f0edadc08..f5dfb3ba7b 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -135,27 +135,15 @@ type Config struct { func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { cfg := Config{} - req := testcontainers.ContainerRequest{ - Image: img, - ... - } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } ... for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customise: %w", err) - } - // If you need to transfer some state from the options to the container, you can do it here if myCustomizer, ok := opt.(MyCustomizer); ok { config.data = customizer.data } } ... - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + container, err := testcontainers.Run(ctx, img, opts...) ... moduleContainer := &Container{Container: container} moduleContainer.initializeState(ctx, cfg) diff --git a/docs/modules/mssql.md b/docs/modules/mssql.md index a8a2a01394..9f6a04929c 100644 --- a/docs/modules/mssql.md +++ b/docs/modules/mssql.md @@ -14,6 +14,20 @@ Please run the following command to add the MS SQL Server module to your Go depe go get github.com/testcontainers/testcontainers-go/modules/mssql ``` +!!!info + In order to use this module, you must set the `GODEBUG=x509negativeserial=1` environment variable. See https://github.com/microsoft/mssql-docker/issues/895 for more details. + +This is happening because: +- The MSSQL Docker image uses a self-signed certificate with a negative serial number +- Go 1.23+ has stricter certificate validation that rejects certificates with negative serial numbers by default +- The `x509negativeserial=1` flag tells Go to accept certificates with negative serial numbers + +The error you're seeing is a security feature in Go 1.23+ that was introduced to prevent potential certificate-related attacks. The MSSQL Docker image hasn't been updated to use certificates with positive serial numbers yet, which is why we need to use this workaround. + +!!!info + This is fixed in SQL2019 CU32 and SQL2022 CU18: +https://learn.microsoft.com/en-us/troubleshoot/sql/releases/sqlserver-2022/cumulativeupdate18#3867855 + ## Usage example diff --git a/docs/quickstart.md b/docs/quickstart.md index ed6bbfcd4a..53249d5d85 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -30,15 +30,11 @@ import ( func TestWithRedis(t *testing.T) { ctx := context.Background() - req := testcontainers.ContainerRequest{ - Image: "redis:latest", - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - } - redisC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + redisC, err := testcontainers.Run( + ctx, "redis:latest", + testcontainers.WithExposedPorts("6379/tcp"), + testcontainers.WithWaitStrategy(wait.ForLog("Ready to accept connections")), + ) testcontainers.CleanupContainer(t, redisC) require.NoError(t, err) } diff --git a/examples/nginx/nginx.go b/examples/nginx/nginx.go index 78ab6291a2..45a61a1b80 100644 --- a/examples/nginx/nginx.go +++ b/examples/nginx/nginx.go @@ -14,24 +14,20 @@ type nginxContainer struct { } func startContainer(ctx context.Context) (*nginxContainer, error) { - req := testcontainers.ContainerRequest{ - Image: "nginx", - ExposedPorts: []string{"80/tcp"}, - WaitingFor: wait.ForHTTP("/").WithStartupTimeout(10 * time.Second), - } - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + ctr, err := testcontainers.Run(ctx, "nginx", + testcontainers.WithExposedPorts("80/tcp"), + testcontainers.WithReuseByName("nginx"), + testcontainers.WithWaitStrategy(wait.ForHTTP("/").WithStartupTimeout(10*time.Second)), + ) var nginxC *nginxContainer - if container != nil { - nginxC = &nginxContainer{Container: container} + if ctr != nil { + nginxC = &nginxContainer{Container: ctr} } if err != nil { return nginxC, err } - endpoint, err := container.PortEndpoint(ctx, "80", "http") + endpoint, err := ctr.PortEndpoint(ctx, "80", "http") if err != nil { return nginxC, err } diff --git a/from_dockerfile_test.go b/from_dockerfile_test.go index 0d8088eb42..285fa2c01d 100644 --- a/from_dockerfile_test.go +++ b/from_dockerfile_test.go @@ -83,24 +83,14 @@ func TestBuildImageFromDockerfile_NoRepo(t *testing.T) { func TestBuildImageFromDockerfile_BuildError(t *testing.T) { ctx := context.Background() - dockerClient, err := NewDockerClientWithOpts(ctx) - require.NoError(t, err) - - defer dockerClient.Close() - - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ + ctr, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ Dockerfile: "error.Dockerfile", Context: filepath.Join(".", "testdata"), - }, - } - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Started: true, - }) + }), + ) CleanupContainer(t, ctr) - require.EqualError(t, err, `create container: build image: The command '/bin/sh -c exit 1' returned a non-zero code: 1`) + require.EqualError(t, err, `generic container: create container: build image: The command '/bin/sh -c exit 1' returned a non-zero code: 1`) } func TestBuildImageFromDockerfile_NoTag(t *testing.T) { @@ -138,19 +128,17 @@ func TestBuildImageFromDockerfile_Target(t *testing.T) { // there are three targets: target0, target1 and target2. for i := range 3 { ctx := context.Background() - c, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - FromDockerfile: FromDockerfile{ - Context: "testdata", - Dockerfile: "target.Dockerfile", - KeepImage: false, - BuildOptionsModifier: func(buildOptions *build.ImageBuildOptions) { - buildOptions.Target = fmt.Sprintf("target%d", i) - }, + + c, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ + Context: "testdata", + Dockerfile: "target.Dockerfile", + KeepImage: false, + BuildOptionsModifier: func(buildOptions *build.ImageBuildOptions) { + buildOptions.Target = fmt.Sprintf("target%d", i) }, - }, - Started: true, - }) + }), + ) CleanupContainer(t, c) require.NoError(t, err) @@ -210,19 +198,16 @@ func TestBuildImageFromDockerfile_TargetDoesNotExist(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - FromDockerfile: FromDockerfile{ - Context: "testdata", - Dockerfile: "target.Dockerfile", - KeepImage: false, - BuildOptionsModifier: func(buildOptions *build.ImageBuildOptions) { - buildOptions.Target = "target-foo" - }, + ctr, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ + Context: "testdata", + Dockerfile: "target.Dockerfile", + KeepImage: false, + BuildOptionsModifier: func(buildOptions *build.ImageBuildOptions) { + buildOptions.Target = "target-foo" }, - }, - Started: true, - }) + }), + ) CleanupContainer(t, ctr) require.Error(t, err) } diff --git a/generic.go b/generic.go index dc5ee1ccb1..d4f69f4bcf 100644 --- a/generic.go +++ b/generic.go @@ -49,7 +49,12 @@ func GenericNetwork(ctx context.Context, req GenericNetworkRequest) (Network, er } // GenericContainer creates a generic container with parameters +// Deprecated: use [Run] instead func GenericContainer(ctx context.Context, req GenericContainerRequest) (Container, error) { + return genericContainer(ctx, req) +} + +func genericContainer(ctx context.Context, req GenericContainerRequest) (Container, error) { if req.Reuse && req.Name == "" { return nil, ErrReuseEmptyName } @@ -122,6 +127,7 @@ func AddGenericLabels(target map[string]string) { func Run(ctx context.Context, img string, opts ...ContainerCustomizer) (*DockerContainer, error) { req := ContainerRequest{ Image: img, + Env: make(map[string]string), } genericContainerReq := GenericContainerRequest{ @@ -135,7 +141,7 @@ func Run(ctx context.Context, img string, opts ...ContainerCustomizer) (*DockerC } } - ctr, err := GenericContainer(ctx, genericContainerReq) + ctr, err := genericContainer(ctx, genericContainerReq) var c *DockerContainer if ctr != nil { c = ctr.(*DockerContainer) diff --git a/generic_test.go b/generic_test.go index 2c05e49a5d..aedadf7d40 100644 --- a/generic_test.go +++ b/generic_test.go @@ -25,16 +25,13 @@ func TestGenericReusableContainer(t *testing.T) { reusableContainerName := reusableContainerName + "_" + time.Now().Format("20060102150405") - n1, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForListeningPort(nginxDefaultPort), - Name: reusableContainerName, - }, - Started: true, - }) + options1 := []ContainerCustomizer{ + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + WithReuseByName(reusableContainerName), + } + + n1, err := Run(ctx, nginxAlpineImage, options1...) require.NoError(t, err) require.True(t, n1.IsRunning()) CleanupContainer(t, n1) @@ -80,20 +77,21 @@ func TestGenericReusableContainer(t *testing.T) { }, } + optionsBase := []ContainerCustomizer{ + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + } + for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - n2, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForListeningPort(nginxDefaultPort), - Name: tc.containerName, - }, - Started: true, - Reuse: tc.reuseOption, - }) + opts := optionsBase + if tc.reuseOption { + opts = append(opts, WithReuseByName(tc.containerName)) + } else { + opts = append(opts, WithName(tc.containerName)) + } + n2, err := Run(ctx, nginxAlpineImage, opts...) require.NoError(t, tc.errorMatcher(err)) if err == nil { @@ -112,14 +110,9 @@ func TestGenericContainerShouldReturnRefOnError(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - WaitingFor: wait.ForLog("this string should not be present in the logs"), - }, - Started: true, - }) + c, err := Run(ctx, nginxAlpineImage, + WithWaitStrategy(wait.ForLog("this string should not be present in the logs")), + ) require.Error(t, err) require.NotNil(t, c) CleanupContainer(t, c) @@ -187,17 +180,13 @@ func TestHelperContainerStarterProcess(t *testing.T) { ctx := context.Background() - nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxDelayedImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForListeningPort(nginxDefaultPort), // default startupTimeout is 60s - Name: reusableContainerName, - }, - Started: true, - Reuse: true, - }) + options := []ContainerCustomizer{ + WithExposedPorts(nginxDefaultPort), + WithWaitStrategy(wait.ForListeningPort(nginxDefaultPort)), + WithReuseByName(reusableContainerName), + } + + nginxC, err := Run(ctx, nginxDelayedImage, options...) require.NoError(t, err) require.True(t, nginxC.IsRunning()) } diff --git a/image_substitutors_test.go b/image_substitutors_test.go index c9d6aee244..ed4cf2631d 100644 --- a/image_substitutors_test.go +++ b/image_substitutors_test.go @@ -99,22 +99,14 @@ func TestPrependHubRegistrySubstitutor(t *testing.T) { } func TestSubstituteBuiltImage(t *testing.T) { - req := GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - FromDockerfile: FromDockerfile{ - Context: "testdata", - Dockerfile: "echo.Dockerfile", - Tag: "my-image", - Repo: "my-repo", - }, - ImageSubstitutors: []ImageSubstitutor{newPrependHubRegistry("my-registry")}, - }, - Started: false, - } - t.Run("should not use the properties prefix on built images", func(t *testing.T) { config.Reset() - c, err := GenericContainer(context.Background(), req) + c, err := Run(context.Background(), "", WithDockerfile(FromDockerfile{ + Context: "testdata", + Dockerfile: "echo.Dockerfile", + Tag: "my-image", + Repo: "my-repo", + }), WithImageSubstitutors(newPrependHubRegistry("my-registry"))) CleanupContainer(t, c) require.NoError(t, err) diff --git a/internal/core/labels.go b/internal/core/labels.go index 198fdae7c8..3136f52cbb 100644 --- a/internal/core/labels.go +++ b/internal/core/labels.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "strings" + "sync" "github.com/testcontainers/testcontainers-go/internal" "github.com/testcontainers/testcontainers-go/internal/config" @@ -33,6 +34,50 @@ const ( LabelReap = LabelBase + ".reap" ) +// labelMerger provides thread-safe operations for merging labels +type labelMerger struct { + mu sync.RWMutex + labels map[string]string +} + +// newLabelMerger creates a new thread-safe label merger +func newLabelMerger(initial map[string]string) *labelMerger { + if initial == nil { + initial = make(map[string]string) + } + return &labelMerger{ + labels: initial, + } +} + +// MergeCustomLabelsSafeMerge merges labels from src to dst. +// If a key in src has [LabelBase] prefix returns an error. +func (m *labelMerger) Merge(src map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + for key, value := range src { + if strings.HasPrefix(key, LabelBase) { + return fmt.Errorf("key %q has %q prefix", key, LabelBase) + } + m.labels[key] = value + } + return nil +} + +// Labels returns a copy of the current labels +func (m *labelMerger) Labels() map[string]string { + m.mu.RLock() + defer m.mu.RUnlock() + + labels := make(map[string]string, len(m.labels)) + for k, v := range m.labels { + labels[k] = v + } + + return labels +} + // DefaultLabels returns the standard set of labels which // includes LabelSessionID if the reaper is enabled. func DefaultLabels(sessionID string) map[string]string { @@ -62,11 +107,7 @@ func MergeCustomLabels(dst, src map[string]string) error { if dst == nil { return errors.New("destination map is nil") } - for key, value := range src { - if strings.HasPrefix(key, LabelBase) { - return fmt.Errorf("key %q has %q prefix", key, LabelBase) - } - dst[key] = value - } - return nil + + merger := newLabelMerger(dst) + return merger.Merge(src) } diff --git a/internal/core/labels_test.go b/internal/core/labels_test.go index e382a0ad48..7276537f30 100644 --- a/internal/core/labels_test.go +++ b/internal/core/labels_test.go @@ -32,3 +32,21 @@ func TestMergeCustomLabels(t *testing.T) { require.Error(t, err) }) } + +func TestLabelMerger(t *testing.T) { + dst := map[string]string{"A": "1", "B": "2"} + src := map[string]string{"B": "X", "C": "3"} + + merger := newLabelMerger(dst) + err := merger.Merge(src) + require.NoError(t, err) + + t.Run("merge", func(t *testing.T) { + require.Equal(t, map[string]string{"A": "1", "B": "X", "C": "3"}, dst) + }) + + t.Run("labels", func(t *testing.T) { + labels := merger.Labels() + require.Equal(t, map[string]string{"A": "1", "B": "X", "C": "3"}, labels) + }) +} diff --git a/lifecycle_test.go b/lifecycle_test.go index c568eb09e8..c97d02305e 100644 --- a/lifecycle_test.go +++ b/lifecycle_test.go @@ -598,7 +598,8 @@ func TestLifecycleHooks(t *testing.T) { req.Name = "reuse-container" } - c, err := GenericContainer(ctx, GenericContainerRequest{ + // TODO: use Run once the WithReuse option is implemented + c, err := genericContainer(ctx, GenericContainerRequest{ ContainerRequest: req, Reuse: tt.reuse, Started: true, @@ -639,18 +640,10 @@ func TestLifecycleHooks_WithDefaultLogger(t *testing.T) { // reqWithDefaultLoggingHook { dl := inMemoryLogger{} - req := ContainerRequest{ - Image: nginxAlpineImage, - LifecycleHooks: []ContainerLifecycleHooks{ - DefaultLoggingHook(&dl), - }, - } + c, err := Run(ctx, nginxAlpineImage, + WithLifecycleHooks(DefaultLoggingHook(&dl)), + ) // } - - c, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) CleanupContainer(t, c) require.NoError(t, err) require.NotNil(t, c) @@ -807,18 +800,9 @@ func TestLifecycleHooks_WithMultipleHooks(t *testing.T) { dl := inMemoryLogger{} - req := ContainerRequest{ - Image: nginxAlpineImage, - LifecycleHooks: []ContainerLifecycleHooks{ - DefaultLoggingHook(&dl), - DefaultLoggingHook(&dl), - }, - } - - c, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + c, err := Run(ctx, nginxAlpineImage, + WithLifecycleHooks(DefaultLoggingHook(&dl), DefaultLoggingHook(&dl)), + ) CleanupContainer(t, c) require.NoError(t, err) require.NotNil(t, c) @@ -848,22 +832,15 @@ func (l *linesTestLogger) Printf(format string, args ...any) { func TestPrintContainerLogsOnError(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: "alpine", - Cmd: []string{"echo", "-n", "I am expecting this"}, - WaitingFor: wait.ForLog("I was expecting that").WithStartupTimeout(5 * time.Second), - } - arrayOfLinesLogger := linesTestLogger{ data: []string{}, } - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: req, - Logger: &arrayOfLinesLogger, - Started: true, - }) + ctr, err := Run(ctx, "alpine", + WithCmd("echo", "-n", "I am expecting this"), + WithWaitStrategy(wait.ForLog("I was expecting that").WithStartupTimeout(5*time.Second)), + WithLogger(&arrayOfLinesLogger), + ) CleanupContainer(t, ctr) // it should fail because the waiting for condition is not met require.Error(t, err) diff --git a/logconsumer_test.go b/logconsumer_test.go index b7852d40c7..158b5ab0a2 100644 --- a/logconsumer_test.go +++ b/logconsumer_test.go @@ -75,24 +75,15 @@ func Test_LogConsumerGetsCalled(t *testing.T) { Accepted: devNullAcceptorChan(), } - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ + c, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ Context: "./testdata/", Dockerfile: "echoserver.Dockerfile", - }, - ExposedPorts: []string{"8080/tcp"}, - WaitingFor: wait.ForLog("ready"), - LogConsumerCfg: &LogConsumerConfig{ - Consumers: []LogConsumer{&g}, - }, - } - - gReq := GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - c, err := GenericContainer(ctx, gReq) + }), + WithExposedPorts("8080/tcp"), + WithWaitStrategy(wait.ForLog("ready")), + WithLogConsumers(&g), + ) CleanupContainer(t, c) require.NoError(t, err) @@ -139,24 +130,15 @@ func Test_ShouldRecognizeLogTypes(t *testing.T) { Ack: make(chan bool), } - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ + c, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ Context: "./testdata/", Dockerfile: "echoserver.Dockerfile", - }, - ExposedPorts: []string{"8080/tcp"}, - WaitingFor: wait.ForLog("ready"), - LogConsumerCfg: &LogConsumerConfig{ - Consumers: []LogConsumer{&g}, - }, - } - - gReq := GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - c, err := GenericContainer(ctx, gReq) + }), + WithExposedPorts("8080/tcp"), + WithWaitStrategy(wait.ForLog("ready")), + WithLogConsumers(&g), + ) CleanupContainer(t, c) require.NoError(t, err) @@ -194,24 +176,15 @@ func Test_MultipleLogConsumers(t *testing.T) { Accepted: devNullAcceptorChan(), } - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ + c, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ Context: "./testdata/", Dockerfile: "echoserver.Dockerfile", - }, - ExposedPorts: []string{"8080/tcp"}, - WaitingFor: wait.ForLog("ready"), - LogConsumerCfg: &LogConsumerConfig{ - Consumers: []LogConsumer{&first, &second}, - }, - } - - gReq := GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - c, err := GenericContainer(ctx, gReq) + }), + WithExposedPorts("8080/tcp"), + WithWaitStrategy(wait.ForLog("ready")), + WithLogConsumers(&first, &second), + ) CleanupContainer(t, c) require.NoError(t, err) @@ -249,18 +222,14 @@ func TestContainerLogWithErrClosed(t *testing.T) { // closed to test behaviour in connection-closed situations. ctx := context.Background() - dind, err := GenericContainer(ctx, GenericContainerRequest{ - Started: true, - ContainerRequest: ContainerRequest{ - Image: "docker:dind", - ExposedPorts: []string{"2375/tcp"}, - Env: map[string]string{"DOCKER_TLS_CERTDIR": ""}, - WaitingFor: wait.ForListeningPort("2375/tcp"), - HostConfigModifier: func(hc *container.HostConfig) { - hc.Privileged = true - }, - }, - }) + dind, err := Run(ctx, "docker:dind", + WithExposedPorts("2375/tcp"), + WithEnv(map[string]string{"DOCKER_TLS_CERTDIR": ""}), + WithWaitStrategy(wait.ForListeningPort("2375/tcp")), + WithHostConfigModifier(func(hc *container.HostConfig) { + hc.Privileged = true + }), + ) CleanupContainer(t, dind) require.NoError(t, err) @@ -361,15 +330,10 @@ func TestContainerLogWithErrClosed(t *testing.T) { func TestContainerLogsShouldBeWithoutStreamHeader(t *testing.T) { ctx := context.Background() - req := ContainerRequest{ - Image: "alpine:latest", - Cmd: []string{"sh", "-c", "id -u"}, - WaitingFor: wait.ForExit(), - } - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + ctr, err := Run(ctx, "alpine:latest", + WithCmd("sh", "-c", "id -u"), + WithWaitStrategy(wait.ForExit()), + ) CleanupContainer(t, ctr) require.NoError(t, err) @@ -390,26 +354,19 @@ func TestContainerLogsEnableAtStart(t *testing.T) { } // logConsumersAtRequest { - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ + c, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ Context: "./testdata/", Dockerfile: "echoserver.Dockerfile", - }, - ExposedPorts: []string{"8080/tcp"}, - WaitingFor: wait.ForLog("ready"), - LogConsumerCfg: &LogConsumerConfig{ + }), + WithExposedPorts("8080/tcp"), + WithWaitStrategy(wait.ForLog("ready")), + WithLogConsumerConfig(&LogConsumerConfig{ Opts: []LogProductionOption{WithLogProductionTimeout(10 * time.Second)}, Consumers: []LogConsumer{&g}, - }, - } + }), + ) // } - - gReq := GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - c, err := GenericContainer(ctx, gReq) CleanupContainer(t, c) require.NoError(t, err) @@ -442,25 +399,18 @@ func Test_StartLogProductionStillStartsWithTooLowTimeout(t *testing.T) { Accepted: devNullAcceptorChan(), } - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ + c, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ Context: "./testdata/", Dockerfile: "echoserver.Dockerfile", - }, - ExposedPorts: []string{"8080/tcp"}, - WaitingFor: wait.ForLog("ready"), - LogConsumerCfg: &LogConsumerConfig{ + }), + WithExposedPorts("8080/tcp"), + WithWaitStrategy(wait.ForLog("ready")), + WithLogConsumerConfig(&LogConsumerConfig{ Opts: []LogProductionOption{WithLogProductionTimeout(4 * time.Second)}, Consumers: []LogConsumer{&g}, - }, - } - - gReq := GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - c, err := GenericContainer(ctx, gReq) + }), + ) CleanupContainer(t, c) require.NoError(t, err) } @@ -474,31 +424,23 @@ func Test_StartLogProductionStillStartsWithTooHighTimeout(t *testing.T) { Accepted: devNullAcceptorChan(), } - req := ContainerRequest{ - FromDockerfile: FromDockerfile{ + c, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ Context: "./testdata/", Dockerfile: "echoserver.Dockerfile", - }, - ExposedPorts: []string{"8080/tcp"}, - WaitingFor: wait.ForLog("ready"), - LogConsumerCfg: &LogConsumerConfig{ + }), + WithExposedPorts("8080/tcp"), + WithWaitStrategy(wait.ForLog("ready")), + WithLogConsumerConfig(&LogConsumerConfig{ Opts: []LogProductionOption{WithLogProductionTimeout(61 * time.Second)}, Consumers: []LogConsumer{&g}, - }, - } - - gReq := GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - c, err := GenericContainer(ctx, gReq) + }), + ) CleanupContainer(t, c) require.NoError(t, err) require.NotNil(t, c) - dc := c.(*DockerContainer) - require.NoError(t, dc.stopLogProduction()) + require.NoError(t, c.stopLogProduction()) } // bufLogger is a Logging implementation that writes to a bytes.Buffer. @@ -550,24 +492,17 @@ func Test_MultiContainerLogConsumer_CancelledContext(t *testing.T) { Accepted: devNullAcceptorChan(), } - containerReq1 := ContainerRequest{ - FromDockerfile: FromDockerfile{ + c, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ Context: "./testdata/", Dockerfile: "echoserver.Dockerfile", - }, - ExposedPorts: []string{"8080/tcp"}, - WaitingFor: wait.ForLog("ready"), - LogConsumerCfg: &LogConsumerConfig{ + }), + WithExposedPorts("8080/tcp"), + WithWaitStrategy(wait.ForLog("ready")), + WithLogConsumerConfig(&LogConsumerConfig{ Consumers: []LogConsumer{&first}, - }, - } - - genericReq1 := GenericContainerRequest{ - ContainerRequest: containerReq1, - Started: true, - } - - c, err := GenericContainer(ctx, genericReq1) + }), + ) CleanupContainer(t, c) require.NoError(t, err) @@ -586,24 +521,17 @@ func Test_MultiContainerLogConsumer_CancelledContext(t *testing.T) { Accepted: devNullAcceptorChan(), } - containerReq2 := ContainerRequest{ - FromDockerfile: FromDockerfile{ + c2, err := Run(ctx, "", + WithDockerfile(FromDockerfile{ Context: "./testdata/", Dockerfile: "echoserver.Dockerfile", - }, - ExposedPorts: []string{"8080/tcp"}, - WaitingFor: wait.ForLog("ready"), - LogConsumerCfg: &LogConsumerConfig{ + }), + WithExposedPorts("8080/tcp"), + WithWaitStrategy(wait.ForLog("ready")), + WithLogConsumerConfig(&LogConsumerConfig{ Consumers: []LogConsumer{&second}, - }, - } - - genericReq2 := GenericContainerRequest{ - ContainerRequest: containerReq2, - Started: true, - } - - c2, err := GenericContainer(ctx, genericReq2) + }), + ) CleanupContainer(t, c2) require.NoError(t, err) @@ -677,16 +605,13 @@ func TestRestartContainerWithLogConsumer(t *testing.T) { logConsumer := NewFooLogConsumer(t) ctx := context.Background() - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - Image: "hello-world", - AlwaysPullImage: true, - LogConsumerCfg: &LogConsumerConfig{ - Consumers: []LogConsumer{logConsumer}, - }, - }, - Started: false, - }) + + ctr, err := Run(ctx, "hello-world", + WithAlwaysPull(), + WithLogConsumerConfig(&LogConsumerConfig{ + Consumers: []LogConsumer{logConsumer}, + }), + ) CleanupContainer(t, ctr) require.NoError(t, err) diff --git a/modulegen/_template/module.go.tmpl b/modulegen/_template/module.go.tmpl index 585e853fba..a31c2ea6ae 100644 --- a/modulegen/_template/module.go.tmpl +++ b/modulegen/_template/module.go.tmpl @@ -14,25 +14,10 @@ type Container struct { // {{ $entrypoint }} creates an instance of the {{ $title }} container type func {{ $entrypoint }}(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - } - - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } - } - - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, opts...) var c *Container if container != nil { - c = &Container{Container: container} + c = &Container{Container: ctr} } if err != nil { diff --git a/modulegen/main_test.go b/modulegen/main_test.go index 49b75497ba..88e9c016a7 100644 --- a/modulegen/main_test.go +++ b/modulegen/main_test.go @@ -426,12 +426,11 @@ func assertModuleContent(t *testing.T, module context.TestcontainersModule, exam require.Equal(t, "type "+containerName+" struct {", data[10]) require.Equal(t, "// "+entrypoint+" creates an instance of the "+exampleName+" container type", data[14]) require.Equal(t, "func "+entrypoint+"(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*"+containerName+", error) {", data[15]) - require.Equal(t, "\t\tImage: img,", data[17]) - require.Equal(t, "\t\tif err := opt.Customize(&genericContainerReq); err != nil {", data[26]) - require.Equal(t, "\t\t\treturn nil, fmt.Errorf(\"customize: %w\", err)", data[27]) - require.Equal(t, "\tvar c *"+containerName, data[32]) - require.Equal(t, "\t\tc = &"+containerName+"{Container: container}", data[34]) - require.Equal(t, "\treturn c, nil", data[41]) + require.Equal(t, "\tctr, err := testcontainers.Run(ctx, img, opts...)", data[16]) + require.Equal(t, "\tvar c *"+containerName, data[17]) + require.Equal(t, "\t\tc = &"+containerName+"{Container: ctr}", data[19]) + require.Equal(t, "\t\treturn c, fmt.Errorf(\"generic container: %w\", err)", data[23]) + require.Equal(t, "\treturn c, nil", data[26]) } // assert content go.mod diff --git a/modules/aerospike/aerospike.go b/modules/aerospike/aerospike.go index ad15b4b601..36af34d700 100644 --- a/modules/aerospike/aerospike.go +++ b/modules/aerospike/aerospike.go @@ -29,39 +29,29 @@ type Container struct { // Run creates an instance of the Aerospike container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{port, fabricPort, heartbeatPort, infoPort}, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(port, fabricPort, heartbeatPort, infoPort), + testcontainers.WithEnv(map[string]string{ "AEROSPIKE_CONFIG_FILE": "/etc/aerospike/aerospike.conf", - }, - WaitingFor: wait.ForAll( + }), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForLog("migrations: complete"), wait.ForListeningPort(port).WithStartupTimeout(10*time.Second), wait.ForListeningPort(fabricPort).WithStartupTimeout(10*time.Second), wait.ForListeningPort(heartbeatPort).WithStartupTimeout(10*time.Second), - ), + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container if container != nil { c = &Container{Container: container} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/arangodb/arangodb.go b/modules/arangodb/arangodb.go index 831991b7bb..02b5d1d8f9 100644 --- a/modules/arangodb/arangodb.go +++ b/modules/arangodb/arangodb.go @@ -43,47 +43,46 @@ func (c *Container) HTTPEndpoint(ctx context.Context) (string, error) { // Run creates an instance of the ArangoDB container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{defaultPort}, - Env: map[string]string{ - "ARANGO_ROOT_PASSWORD": defaultPassword, - }, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(defaultPort), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) + defaultOptions := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) + if o, ok := opt.(Option); ok { + if err := o(&defaultOptions); err != nil { + return nil, fmt.Errorf("customize: %w", err) + } } } // Wait for the container to be ready once we know the credentials - genericContainerReq.WaitingFor = wait.ForAll( + moduleOpts = append(moduleOpts, testcontainers.WithWaitStrategy(wait.ForAll( wait.ForListeningPort(defaultPort), wait.ForHTTP("/_admin/status"). WithPort(defaultPort). - WithBasicAuth(DefaultUser, req.Env["ARANGO_ROOT_PASSWORD"]). + WithBasicAuth(DefaultUser, defaultOptions.env["ARANGO_ROOT_PASSWORD"]). WithHeaders(map[string]string{ "Accept": "application/json", }). WithStatusCodeMatcher(func(status int) bool { return status == http.StatusOK }), - ) + ))) + + // module options take precedence over default options + moduleOpts = append(moduleOpts, testcontainers.WithEnv(defaultOptions.env)) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container if container != nil { - c = &Container{Container: container, password: req.Env["ARANGO_ROOT_PASSWORD"]} + c = &Container{Container: container, password: defaultOptions.env["ARANGO_ROOT_PASSWORD"]} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/arangodb/options.go b/modules/arangodb/options.go index 9834aae9ca..d287f5b97b 100644 --- a/modules/arangodb/options.go +++ b/modules/arangodb/options.go @@ -2,10 +2,34 @@ package arangodb import "github.com/testcontainers/testcontainers-go" +type options struct { + env map[string]string +} + +func defaultOptions() options { + return options{ + env: map[string]string{ + "ARANGO_ROOT_PASSWORD": defaultPassword, + }, + } +} + +// Satisfy the testcontainers.CustomizeRequestOption interface +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the ArangoDB container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + // WithRootPassword sets the password for the ArangoDB root user -func WithRootPassword(password string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["ARANGO_ROOT_PASSWORD"] = password +func WithRootPassword(password string) Option { + return func(o *options) error { + o.env["ARANGO_ROOT_PASSWORD"] = password return nil } } diff --git a/modules/artemis/artemis.go b/modules/artemis/artemis.go index e639be7dda..545d8b8141 100644 --- a/modules/artemis/artemis.go +++ b/modules/artemis/artemis.go @@ -47,37 +47,6 @@ func (c *Container) ConsoleURL(ctx context.Context) (string, error) { return fmt.Sprintf("http://%s:%s@%s/console", c.user, c.password, host), nil } -// WithCredentials sets the administrator credentials. The default is artemis:artemis. -func WithCredentials(user, password string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["ARTEMIS_USER"] = user - req.Env["ARTEMIS_PASSWORD"] = password - - return nil - } -} - -// WithAnonymousLogin enables anonymous logins. -func WithAnonymousLogin() testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["ANONYMOUS_LOGIN"] = "true" - - return nil - } -} - -// Additional arguments sent to the `artemis create` command. -// The default is `--http-host 0.0.0.0 --relax-jolokia`. -// Setting this value will override the default. -// See the documentation on `artemis create` for available options. -func WithExtraArgs(args string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["EXTRA_ARGS"] = args - - return nil - } -} - // Deprecated: use Run instead. // RunContainer creates an instance of the Artemis container type. func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*Container, error) { @@ -86,39 +55,36 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Artemis container type with a given image func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - Env: map[string]string{ - "ARTEMIS_USER": "artemis", - "ARTEMIS_PASSWORD": "artemis", - }, - ExposedPorts: []string{defaultBrokerPort, defaultHTTPPort}, - WaitingFor: wait.ForAll( - wait.ForLog("Server is now live"), - wait.ForLog("REST API available"), - ), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(defaultBrokerPort, defaultHTTPPort), + testcontainers.WithWaitStrategy(wait.ForAll( + wait.ForLog("Server is now live"), + wait.ForLog("REST API available"), + )), } + moduleOpts = append(moduleOpts, opts...) + + defaultOptions := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&req); err != nil { - return nil, err + if o, ok := opt.(Option); ok { + if err := o(&defaultOptions); err != nil { + return nil, fmt.Errorf("artemis option: %w", err) + } } } - container, err := testcontainers.GenericContainer(ctx, req) + // module options take precedence over default options + moduleOpts = append(moduleOpts, testcontainers.WithEnv(defaultOptions.env)) + + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container - if container != nil { - c = &Container{Container: container} + if ctr != nil { + c = &Container{Container: ctr, user: defaultOptions.env["ARTEMIS_USER"], password: defaultOptions.env["ARTEMIS_PASSWORD"]} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } - c.user = req.Env["ARTEMIS_USER"] - c.password = req.Env["ARTEMIS_PASSWORD"] - return c, nil } diff --git a/modules/artemis/artemis_test.go b/modules/artemis/artemis_test.go index 70dcab9440..372fd3715d 100644 --- a/modules/artemis/artemis_test.go +++ b/modules/artemis/artemis_test.go @@ -8,7 +8,6 @@ import ( "time" "github.com/go-stomp/stomp/v3" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" @@ -80,11 +79,11 @@ func TestArtemis(t *testing.T) { require.Equal(t, http.StatusOK, res.StatusCode, "failed to access console") if test.user != "" { - assert.Equal(t, test.user, ctr.User(), "unexpected user") + require.Equal(t, test.user, ctr.User(), "unexpected user") } if test.pass != "" { - assert.Equal(t, test.pass, ctr.Password(), "unexpected password") + require.Equal(t, test.pass, ctr.Password(), "unexpected password") } // brokerEndpoint { diff --git a/modules/artemis/options.go b/modules/artemis/options.go new file mode 100644 index 0000000000..b9d7785538 --- /dev/null +++ b/modules/artemis/options.go @@ -0,0 +1,59 @@ +package artemis + +import "github.com/testcontainers/testcontainers-go" + +type options struct { + env map[string]string +} + +func defaultOptions() options { + return options{ + env: map[string]string{ + "ARTEMIS_USER": "artemis", + "ARTEMIS_PASSWORD": "artemis", + }, + } +} + +// Satisfy the testcontainers.CustomizeRequestOption interface +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the Artemis container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + +// WithCredentials sets the administrator credentials. The default is artemis:artemis. +func WithCredentials(user, password string) Option { + return func(o *options) error { + o.env["ARTEMIS_USER"] = user + o.env["ARTEMIS_PASSWORD"] = password + + return nil + } +} + +// WithAnonymousLogin enables anonymous logins. +func WithAnonymousLogin() Option { + return func(o *options) error { + o.env["ANONYMOUS_LOGIN"] = "true" + + return nil + } +} + +// Additional arguments sent to the `artemis create` command. +// The default is `--http-host 0.0.0.0 --relax-jolokia`. +// Setting this value will override the default. +// See the documentation on `artemis create` for available options. +func WithExtraArgs(args string) Option { + return func(o *options) error { + o.env["EXTRA_ARGS"] = args + + return nil + } +} diff --git a/modules/azure/azurite/azurite.go b/modules/azure/azurite/azurite.go index e58fb53b72..425a53c14d 100644 --- a/modules/azure/azurite/azurite.go +++ b/modules/azure/azurite/azurite.go @@ -72,26 +72,15 @@ func (c *Container) serviceURL(ctx context.Context, srv service) (string, error) // Run creates an instance of the Azurite container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{BlobPort, QueuePort, TablePort}, - Env: map[string]string{}, - Entrypoint: []string{"azurite"}, - Cmd: []string{}, - } + moduleCmd := []string{} - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithEntrypoint("azurite"), + testcontainers.WithExposedPorts(BlobPort, QueuePort, TablePort), } // 1. Gather all config options (defaults and then apply provided options) settings := defaultOptions() - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } - } // 2. evaluate the enabled services to apply the right wait strategy and Cmd options if len(settings.EnabledServices) > 0 { @@ -99,32 +88,31 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom for _, srv := range settings.EnabledServices { switch srv { case BlobService: - genericContainerReq.Cmd = append(genericContainerReq.Cmd, "--blobHost", "0.0.0.0") + moduleCmd = append(moduleCmd, "--blobHost", "0.0.0.0") waitingFor = append(waitingFor, wait.ForListeningPort(BlobPort)) case QueueService: - genericContainerReq.Cmd = append(genericContainerReq.Cmd, "--queueHost", "0.0.0.0") + moduleCmd = append(moduleCmd, "--queueHost", "0.0.0.0") waitingFor = append(waitingFor, wait.ForListeningPort(QueuePort)) case TableService: - genericContainerReq.Cmd = append(genericContainerReq.Cmd, "--tableHost", "0.0.0.0") + moduleCmd = append(moduleCmd, "--tableHost", "0.0.0.0") waitingFor = append(waitingFor, wait.ForListeningPort(TablePort)) } } - if genericContainerReq.WaitingFor != nil { - genericContainerReq.WaitingFor = wait.ForAll(genericContainerReq.WaitingFor, wait.ForAll(waitingFor...)) - } else { - genericContainerReq.WaitingFor = wait.ForAll(waitingFor...) - } + moduleOpts = append(moduleOpts, testcontainers.WithWaitStrategy(wait.ForAll(waitingFor...))) + moduleOpts = append(moduleOpts, testcontainers.WithCmd(moduleCmd...)) } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + moduleOpts = append(moduleOpts, opts...) + + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container - if container != nil { - c = &Container{Container: container, opts: settings} + if ctr != nil { + c = &Container{Container: ctr, opts: settings} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/azure/azurite/options.go b/modules/azure/azurite/options.go index 70f464fcff..7e8d2dc780 100644 --- a/modules/azure/azurite/options.go +++ b/modules/azure/azurite/options.go @@ -20,15 +20,11 @@ func defaultOptions() options { // WithInMemoryPersistence is a custom option to enable in-memory persistence for Azurite. // This option is only available for Azurite v3.28.0 and later. func WithInMemoryPersistence(megabytes float64) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - cmd := []string{"--inMemoryPersistence"} + cmd := []string{"--inMemoryPersistence"} - if megabytes > 0 { - cmd = append(cmd, "--extentMemoryLimit", fmt.Sprintf("%f", megabytes)) - } - - req.Cmd = append(req.Cmd, cmd...) - - return nil + if megabytes > 0 { + cmd = append(cmd, "--extentMemoryLimit", fmt.Sprintf("%f", megabytes)) } + + return testcontainers.WithCmdArgs(cmd...) } diff --git a/modules/azure/eventhubs/eventhubs.go b/modules/azure/eventhubs/eventhubs.go index d4b4fd4b20..f0ce6b48d6 100644 --- a/modules/azure/eventhubs/eventhubs.go +++ b/modules/azure/eventhubs/eventhubs.go @@ -69,37 +69,30 @@ func (c *Container) Terminate(ctx context.Context, opts ...testcontainers.Termin // Run creates an instance of the Azure Event Hubs container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{defaultAMPQPort, defaultHTTPPort}, - Env: make(map[string]string), - WaitingFor: wait.ForAll( + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(defaultAMPQPort, defaultHTTPPort), + testcontainers.WithEnv(make(map[string]string)), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForListeningPort(defaultAMPQPort), wait.ForListeningPort(defaultHTTPPort), wait.ForHTTP("/health").WithPort(defaultHTTPPort).WithStatusCodeMatcher(func(status int) bool { return status == http.StatusOK }), - ), + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) defaultOptions := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } if o, ok := opt.(Option); ok { if err := o(&defaultOptions); err != nil { - return nil, fmt.Errorf("eventhubsoption: %w", err) + return nil, fmt.Errorf("eventhubs option: %w", err) } } } - if strings.ToUpper(genericContainerReq.Env["ACCEPT_EULA"]) != "Y" { + if strings.ToUpper(defaultOptions.env["ACCEPT_EULA"]) != "Y" { return nil, errors.New("EULA not accepted. Please use the WithAcceptEULA option to accept the EULA") } @@ -124,20 +117,20 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom } defaultOptions.azuriteContainer = azuriteContainer - genericContainerReq.Env["BLOB_SERVER"] = aliasAzurite - genericContainerReq.Env["METADATA_SERVER"] = aliasAzurite + defaultOptions.env["BLOB_SERVER"] = aliasAzurite + defaultOptions.env["METADATA_SERVER"] = aliasAzurite // apply the network to the eventhubs container - err = network.WithNetwork([]string{aliasEventhubs}, azuriteNetwork)(&genericContainerReq) - if err != nil { - return c, fmt.Errorf("with network: %w", err) - } + moduleOpts = append(moduleOpts, network.WithNetwork([]string{aliasEventhubs}, azuriteNetwork)) } + // module options take precedence over default options + moduleOpts = append(moduleOpts, testcontainers.WithEnv(defaultOptions.env)) + var err error - c.Container, err = testcontainers.GenericContainer(ctx, genericContainerReq) + c.Container, err = testcontainers.Run(ctx, img, moduleOpts...) if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/azure/eventhubs/options.go b/modules/azure/eventhubs/options.go index f439d3df2d..be6351acb8 100644 --- a/modules/azure/eventhubs/options.go +++ b/modules/azure/eventhubs/options.go @@ -8,6 +8,7 @@ import ( ) type options struct { + env map[string]string azuriteImage string azuriteOptions []testcontainers.ContainerCustomizer azuriteContainer *azurite.Container @@ -16,6 +17,7 @@ type options struct { func defaultOptions() options { return options{ + env: make(map[string]string), azuriteImage: "mcr.microsoft.com/azure-storage/azurite:3.33.0", azuriteContainer: nil, } @@ -24,7 +26,7 @@ func defaultOptions() options { // Satisfy the testcontainers.CustomizeRequestOption interface var _ testcontainers.ContainerCustomizer = (Option)(nil) -// Option is an option for the Redpanda container. +// Option is an option for the EventHubs container. type Option func(*options) error // Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. @@ -44,9 +46,9 @@ func WithAzurite(img string, opts ...testcontainers.ContainerCustomizer) Option } // WithAcceptEULA sets the ACCEPT_EULA environment variable to "Y" for the eventhubs container. -func WithAcceptEULA() testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["ACCEPT_EULA"] = "Y" +func WithAcceptEULA() Option { + return func(o *options) error { + o.env["ACCEPT_EULA"] = "Y" return nil } diff --git a/modules/azure/servicebus/options.go b/modules/azure/servicebus/options.go index b1af0b6a32..09b0531787 100644 --- a/modules/azure/servicebus/options.go +++ b/modules/azure/servicebus/options.go @@ -8,6 +8,7 @@ import ( ) type options struct { + env map[string]string mssqlImage string mssqlOptions []testcontainers.ContainerCustomizer mssqlContainer *mssql.MSSQLServerContainer @@ -16,6 +17,9 @@ type options struct { func defaultOptions() options { return options{ + env: map[string]string{ + "SQL_WAIT_INTERVAL": "0", // default is zero because the MSSQL container is started first + }, mssqlImage: defaultMSSQLImage, mssqlContainer: nil, } @@ -24,7 +28,7 @@ func defaultOptions() options { // Satisfy the testcontainers.CustomizeRequestOption interface var _ testcontainers.ContainerCustomizer = (Option)(nil) -// Option is an option for the Redpanda container. +// Option is an option for the ServiceBus container. type Option func(*options) error // Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. @@ -34,7 +38,7 @@ func (o Option) Customize(*testcontainers.GenericContainerRequest) error { } // WithMSSQL sets the image and options for the MSSQL container. -// By default, the image is "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04". +// By default, the image is "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04". func WithMSSQL(img string, opts ...testcontainers.ContainerCustomizer) Option { return func(o *options) error { o.mssqlImage = img @@ -44,9 +48,9 @@ func WithMSSQL(img string, opts ...testcontainers.ContainerCustomizer) Option { } // WithAcceptEULA sets the ACCEPT_EULA environment variable to "Y" for the eventhubs container. -func WithAcceptEULA() testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["ACCEPT_EULA"] = "Y" +func WithAcceptEULA() Option { + return func(o *options) error { + o.env["ACCEPT_EULA"] = "Y" return nil } diff --git a/modules/azure/servicebus/servicebus.go b/modules/azure/servicebus/servicebus.go index 2a3fe0f8e9..f4adf43caa 100644 --- a/modules/azure/servicebus/servicebus.go +++ b/modules/azure/servicebus/servicebus.go @@ -27,7 +27,7 @@ const ( aliasMSSQL = "mssql" // defaultMSSQLImage is the default image for the mssql container - defaultMSSQLImage = "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04" + defaultMSSQLImage = "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04" // containerConfigFile is the path to the config file for the servicebus container containerConfigFile = "/ServiceBus_Emulator/ConfigFiles/Config.json" @@ -74,31 +74,21 @@ func (c *Container) Terminate(ctx context.Context, opts ...testcontainers.Termin // Run creates an instance of the Azure Event Hubs container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - Env: map[string]string{ - "SQL_WAIT_INTERVAL": "0", // default is zero because the MSSQL container is started first - }, - ExposedPorts: []string{defaultPort, defaultHTTPPort}, - WaitingFor: wait.ForAll( + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(defaultPort, defaultHTTPPort), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForListeningPort(defaultPort), wait.ForListeningPort(defaultHTTPPort), wait.ForHTTP("/health").WithPort(defaultHTTPPort).WithStatusCodeMatcher(func(status int) bool { return status == http.StatusOK }), - ), + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) defaultOptions := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } if o, ok := opt.(Option); ok { if err := o(&defaultOptions); err != nil { return nil, fmt.Errorf("servicebus option: %w", err) @@ -106,7 +96,7 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom } } - if strings.ToUpper(genericContainerReq.Env["ACCEPT_EULA"]) != "Y" { + if strings.ToUpper(defaultOptions.env["ACCEPT_EULA"]) != "Y" { return nil, errors.New("EULA not accepted. Please use the WithAcceptEULA option to accept the EULA") } @@ -133,20 +123,20 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom } defaultOptions.mssqlContainer = mssqlContainer - genericContainerReq.Env["SQL_SERVER"] = aliasMSSQL - genericContainerReq.Env["MSSQL_SA_PASSWORD"] = mssqlContainer.Password() + defaultOptions.env["SQL_SERVER"] = aliasMSSQL + defaultOptions.env["MSSQL_SA_PASSWORD"] = mssqlContainer.Password() // apply the network to the eventhubs container - err = network.WithNetwork([]string{aliasServiceBus}, mssqlNetwork)(&genericContainerReq) - if err != nil { - return c, fmt.Errorf("with network: %w", err) - } + moduleOpts = append(moduleOpts, network.WithNetwork([]string{aliasServiceBus}, mssqlNetwork)) } + // module options take precedence over default options + moduleOpts = append(moduleOpts, testcontainers.WithEnv(defaultOptions.env)) + var err error - c.Container, err = testcontainers.GenericContainer(ctx, genericContainerReq) + c.Container, err = testcontainers.Run(ctx, img, moduleOpts...) if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/azure/servicebus/servicebus_test.go b/modules/azure/servicebus/servicebus_test.go index e8db5e47ee..63aace8600 100644 --- a/modules/azure/servicebus/servicebus_test.go +++ b/modules/azure/servicebus/servicebus_test.go @@ -19,7 +19,7 @@ var servicebusConfig string func TestServiceBus_topology(t *testing.T) { ctx := context.Background() - const mssqlImage = "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04" + const mssqlImage = "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04" ctr, err := servicebus.Run( ctx, @@ -52,7 +52,7 @@ func TestServiceBus_topology(t *testing.T) { func TestServiceBus_withConfig(t *testing.T) { ctx := context.Background() - const mssqlImage = "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04" + const mssqlImage = "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04" ctr, err := servicebus.Run( ctx, diff --git a/modules/cassandra/cassandra.go b/modules/cassandra/cassandra.go index 5e4fc4e58a..2b7cb3266b 100644 --- a/modules/cassandra/cassandra.go +++ b/modules/cassandra/cassandra.go @@ -73,45 +73,35 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Cassandra container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*CassandraContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{string(port)}, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(port.Port()), + testcontainers.WithEnv(map[string]string{ "CASSANDRA_SNITCH": "GossipingPropertyFileSnitch", "JVM_OPTS": "-Dcassandra.skip_wait_for_gossip_to_settle=0 -Dcassandra.initial_token=0", "HEAP_NEWSIZE": "128M", "MAX_HEAP_SIZE": "1024M", "CASSANDRA_ENDPOINT_SNITCH": "GossipingPropertyFileSnitch", "CASSANDRA_DC": "datacenter1", - }, - WaitingFor: wait.ForAll( + }), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForListeningPort(port), wait.ForExec([]string{"cqlsh", "-e", "SELECT bootstrapped FROM system.local"}).WithResponseMatcher(func(body io.Reader) bool { data, _ := io.ReadAll(body) return strings.Contains(string(data), "COMPLETED") }), - ), + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *CassandraContainer - if container != nil { - c = &CassandraContainer{Container: container} + if ctr != nil { + c = &CassandraContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/chroma/chroma.go b/modules/chroma/chroma.go index 3a1921ef1b..ddfae62ff3 100644 --- a/modules/chroma/chroma.go +++ b/modules/chroma/chroma.go @@ -21,37 +21,27 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Chroma container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*ChromaContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"8000/tcp"}, - WaitingFor: wait.ForAll( + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8000/tcp"), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForListeningPort("8000/tcp"), wait.ForLog("Application startup complete"), wait.ForHTTP("/api/v1/heartbeat").WithStatusCodeMatcher(func(status int) bool { return status == 200 }), - ), // 5 seconds it's not enough for the container to start + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *ChromaContainer - if container != nil { - c = &ChromaContainer{Container: container} + if ctr != nil { + c = &ChromaContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/clickhouse/clickhouse.go b/modules/clickhouse/clickhouse.go index 6f3cef4d33..79c34ac885 100644 --- a/modules/clickhouse/clickhouse.go +++ b/modules/clickhouse/clickhouse.go @@ -5,8 +5,6 @@ import ( "context" _ "embed" "fmt" - "os" - "path/filepath" "strings" "text/template" @@ -87,123 +85,6 @@ func renderZookeeperConfig(settings ZookeeperOptions) ([]byte, error) { return bootstrapConfig.Bytes(), nil } -// WithZookeeper pass a config to connect clickhouse with zookeeper and make clickhouse as cluster -func WithZookeeper(host, port string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - f, err := os.CreateTemp("", "clickhouse-tc-config-") - if err != nil { - return fmt.Errorf("temporary file: %w", err) - } - - defer f.Close() - - // write data to the temporary file - data, err := renderZookeeperConfig(ZookeeperOptions{Host: host, Port: port}) - if err != nil { - return fmt.Errorf("zookeeper config: %w", err) - } - if _, err := f.Write(data); err != nil { - return fmt.Errorf("write zookeeper config: %w", err) - } - cf := testcontainers.ContainerFile{ - HostFilePath: f.Name(), - ContainerFilePath: "/etc/clickhouse-server/config.d/zookeeper_config.xml", - FileMode: 0o755, - } - req.Files = append(req.Files, cf) - - return nil - } -} - -// WithInitScripts sets the init scripts to be run when the container starts -func WithInitScripts(scripts ...string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - initScripts := []testcontainers.ContainerFile{} - for _, script := range scripts { - cf := testcontainers.ContainerFile{ - HostFilePath: script, - ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base(script), - FileMode: 0o755, - } - initScripts = append(initScripts, cf) - } - req.Files = append(req.Files, initScripts...) - - return nil - } -} - -// WithConfigFile sets the XML config file to be used for the clickhouse container -// It will also set the "configFile" parameter to the path of the config file -// as a command line argument to the container. -func WithConfigFile(configFile string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - cf := testcontainers.ContainerFile{ - HostFilePath: configFile, - ContainerFilePath: "/etc/clickhouse-server/config.d/config.xml", - FileMode: 0o755, - } - req.Files = append(req.Files, cf) - - return nil - } -} - -// WithConfigFile sets the YAML config file to be used for the clickhouse container -// It will also set the "configFile" parameter to the path of the config file -// as a command line argument to the container. -func WithYamlConfigFile(configFile string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - cf := testcontainers.ContainerFile{ - HostFilePath: configFile, - ContainerFilePath: "/etc/clickhouse-server/config.d/config.yaml", - FileMode: 0o755, - } - req.Files = append(req.Files, cf) - - return nil - } -} - -// WithDatabase sets the initial database to be created when the container starts -// It can be used to define a different name for the default database that is created when the image is first started. -// If it is not specified, then the default value("clickhouse") will be used. -func WithDatabase(dbName string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["CLICKHOUSE_DB"] = dbName - - return nil - } -} - -// WithPassword sets the initial password of the user to be created when the container starts -// It is required for you to use the ClickHouse image. It must not be empty or undefined. -// This environment variable sets the password for ClickHouse. -func WithPassword(password string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["CLICKHOUSE_PASSWORD"] = password - - return nil - } -} - -// WithUsername sets the initial username to be created when the container starts -// It is used in conjunction with WithPassword to set a user and its password. -// It will create the specified user with superuser power. -// If it is not specified, then the default user of clickhouse will be used. -func WithUsername(user string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - if user == "" { - user = defaultUser - } - - req.Env["CLICKHOUSE_USER"] = user - - return nil - } -} - // Deprecated: use Run instead // RunContainer creates an instance of the ClickHouse container type func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*ClickHouseContainer, error) { @@ -212,44 +93,40 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the ClickHouse container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*ClickHouseContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - Env: map[string]string{ - "CLICKHOUSE_USER": defaultUser, - "CLICKHOUSE_PASSWORD": defaultUser, - "CLICKHOUSE_DB": defaultDatabaseName, - }, - ExposedPorts: []string{httpPort.Port(), nativePort.Port()}, - WaitingFor: wait.ForAll( - wait.NewHTTPStrategy("/").WithPort(httpPort).WithStatusCodeMatcher(func(status int) bool { - return status == 200 - }), - ), + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(httpPort.Port(), nativePort.Port()), + testcontainers.WithWaitStrategy(wait.NewHTTPStrategy("/").WithPort(httpPort).WithStatusCodeMatcher(func(status int) bool { + return status == 200 + })), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) + defaultSettings := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err + if o, ok := opt.(Option); ok { + if err := o(&defaultSettings); err != nil { + return nil, fmt.Errorf("clickhouse option: %w", err) + } } } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + // module options take precedence over default options + moduleOpts = append(moduleOpts, testcontainers.WithEnv(defaultSettings.env), testcontainers.WithFiles(defaultSettings.files...)) + + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *ClickHouseContainer if container != nil { - c = &ClickHouseContainer{Container: container} + c = &ClickHouseContainer{ + Container: container, + User: defaultSettings.env["CLICKHOUSE_USER"], + Password: defaultSettings.env["CLICKHOUSE_PASSWORD"], + DbName: defaultSettings.env["CLICKHOUSE_DB"], + } } if err != nil { return c, fmt.Errorf("generic container: %w", err) } - c.User = req.Env["CLICKHOUSE_USER"] - c.Password = req.Env["CLICKHOUSE_PASSWORD"] - c.DbName = req.Env["CLICKHOUSE_DB"] - return c, nil } diff --git a/modules/clickhouse/clickhouse_test.go b/modules/clickhouse/clickhouse_test.go index 2b522598df..67393b082e 100644 --- a/modules/clickhouse/clickhouse_test.go +++ b/modules/clickhouse/clickhouse_test.go @@ -161,7 +161,7 @@ func TestClickHouseWithConfigFile(t *testing.T) { testCases := []struct { desc string - configOption testcontainers.CustomizeRequestOption + configOption clickhouse.Option }{ {"XML_Config", clickhouse.WithConfigFile(filepath.Join("testdata", "config.xml"))}, // 1 {"YAML_Config", clickhouse.WithYamlConfigFile(filepath.Join("testdata", "config.yaml"))}, // allow_no_password: true @@ -207,14 +207,13 @@ func TestClickHouseWithZookeeper(t *testing.T) { // withZookeeper { zkPort := nat.Port("2181/tcp") - zkcontainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - ExposedPorts: []string{zkPort.Port()}, - Image: "zookeeper:3.7", - WaitingFor: wait.ForListeningPort(zkPort), - }, - Started: true, - }) + zkOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(zkPort.Port()), + testcontainers.WithWaitStrategy(wait.ForListeningPort(zkPort)), + } + + zkcontainer, err := testcontainers.Run(ctx, "zookeeper:3.7", zkOpts...) + require.NoError(t, err) testcontainers.CleanupContainer(t, zkcontainer) require.NoError(t, err) diff --git a/modules/clickhouse/options.go b/modules/clickhouse/options.go new file mode 100644 index 0000000000..4e2a8d44c8 --- /dev/null +++ b/modules/clickhouse/options.go @@ -0,0 +1,153 @@ +package clickhouse + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/testcontainers/testcontainers-go" +) + +type options struct { + env map[string]string + files []testcontainers.ContainerFile +} + +func defaultOptions() options { + return options{ + env: map[string]string{ + "CLICKHOUSE_USER": defaultUser, + "CLICKHOUSE_PASSWORD": defaultUser, + "CLICKHOUSE_DB": defaultDatabaseName, + }, + } +} + +// Satisfy the testcontainers.CustomizeRequestOption interface +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the ClickHouse container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + +// WithZookeeper pass a config to connect clickhouse with zookeeper and make clickhouse as cluster +func WithZookeeper(host, port string) Option { + return func(o *options) error { + f, err := os.CreateTemp("", "clickhouse-tc-config-") + if err != nil { + return fmt.Errorf("temporary file: %w", err) + } + + defer f.Close() + + // write data to the temporary file + data, err := renderZookeeperConfig(ZookeeperOptions{Host: host, Port: port}) + if err != nil { + return fmt.Errorf("zookeeper config: %w", err) + } + if _, err := f.Write(data); err != nil { + return fmt.Errorf("write zookeeper config: %w", err) + } + cf := testcontainers.ContainerFile{ + HostFilePath: f.Name(), + ContainerFilePath: "/etc/clickhouse-server/config.d/zookeeper_config.xml", + FileMode: 0o755, + } + o.files = append(o.files, cf) + + return nil + } +} + +// WithInitScripts sets the init scripts to be run when the container starts +func WithInitScripts(scripts ...string) Option { + return func(o *options) error { + initScripts := []testcontainers.ContainerFile{} + for _, script := range scripts { + cf := testcontainers.ContainerFile{ + HostFilePath: script, + ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base(script), + FileMode: 0o755, + } + initScripts = append(initScripts, cf) + } + o.files = append(o.files, initScripts...) + + return nil + } +} + +// WithConfigFile sets the XML config file to be used for the clickhouse container +// It will also set the "configFile" parameter to the path of the config file +// as a command line argument to the container. +func WithConfigFile(configFile string) Option { + return func(o *options) error { + cf := testcontainers.ContainerFile{ + HostFilePath: configFile, + ContainerFilePath: "/etc/clickhouse-server/config.d/config.xml", + FileMode: 0o755, + } + o.files = append(o.files, cf) + + return nil + } +} + +// WithConfigFile sets the YAML config file to be used for the clickhouse container +// It will also set the "configFile" parameter to the path of the config file +// as a command line argument to the container. +func WithYamlConfigFile(configFile string) Option { + return func(o *options) error { + cf := testcontainers.ContainerFile{ + HostFilePath: configFile, + ContainerFilePath: "/etc/clickhouse-server/config.d/config.yaml", + FileMode: 0o755, + } + o.files = append(o.files, cf) + + return nil + } +} + +// WithDatabase sets the initial database to be created when the container starts +// It can be used to define a different name for the default database that is created when the image is first started. +// If it is not specified, then the default value("clickhouse") will be used. +func WithDatabase(dbName string) Option { + return func(o *options) error { + o.env["CLICKHOUSE_DB"] = dbName + + return nil + } +} + +// WithPassword sets the initial password of the user to be created when the container starts +// It is required for you to use the ClickHouse image. It must not be empty or undefined. +// This environment variable sets the password for ClickHouse. +func WithPassword(password string) Option { + return func(o *options) error { + o.env["CLICKHOUSE_PASSWORD"] = password + + return nil + } +} + +// WithUsername sets the initial username to be created when the container starts +// It is used in conjunction with WithPassword to set a user and its password. +// It will create the specified user with superuser power. +// If it is not specified, then the default user of clickhouse will be used. +func WithUsername(user string) Option { + return func(o *options) error { + if user == "" { + user = defaultUser + } + + o.env["CLICKHOUSE_USER"] = user + + return nil + } +} diff --git a/modules/cockroachdb/cockroachdb.go b/modules/cockroachdb/cockroachdb.go index 2cb5f649e3..368342d76e 100644 --- a/modules/cockroachdb/cockroachdb.go +++ b/modules/cockroachdb/cockroachdb.go @@ -75,14 +75,6 @@ type CockroachDBContainer struct { options } -// options represents the options for the CockroachDBContainer type. -type options struct { - database string - user string - password string - tlsStrategy *wait.TLSStrategy -} - // MustConnectionString returns a connection string to open a new connection to CockroachDB // as described by [CockroachDBContainer.ConnectionString]. // It panics if an error occurs. @@ -167,70 +159,65 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // [local cluster in docker]: https://www.cockroachlabs.com/docs/stable/start-a-local-cluster-in-docker-linux // [local testing clusters]: https://www.cockroachlabs.com/docs/stable/local-testing func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*CockroachDBContainer, error) { - ctr := &CockroachDBContainer{ - options: options{ - database: defaultDatabase, - user: defaultUser, - password: defaultPassword, - }, + c := &CockroachDBContainer{ + options: defaultOptions(), } - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{ - defaultSQLPort, - defaultAdminPort, - }, - Env: map[string]string{ - "COCKROACH_DATABASE": defaultDatabase, - "COCKROACH_USER": defaultUser, - "COCKROACH_PASSWORD": defaultPassword, - }, - Files: []testcontainers.ContainerFile{{ - Reader: newDefaultsReader(clusterDefaults), - ContainerFilePath: clusterDefaultsContainerFile, - FileMode: 0o644, - }}, - Cmd: []string{ - "start-single-node", - memStorageFlag + defaultStoreSize, - }, - WaitingFor: wait.ForAll( - wait.ForFile(cockroachDir+"/init_success"), - wait.ForHTTP("/health").WithPort(defaultAdminPort), - wait.ForTLSCert( - certsDir+"/client."+defaultUser+".crt", - certsDir+"/client."+defaultUser+".key", - ).WithRootCAs(fileCACert).WithServerName("127.0.0.1"), - wait.ForSQL(defaultSQLPort, "pgx/v5", func(host string, port nat.Port) string { - connStr, err := ctr.connString(host, port) - if err != nil { - panic(err) - } - return connStr - }), - ), - }, - Started: true, + + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithCmd("start-single-node", memStorageFlag+defaultStoreSize), + testcontainers.WithExposedPorts(defaultSQLPort, defaultAdminPort), + testcontainers.WithEnv(map[string]string{ + "COCKROACH_DATABASE": defaultDatabase, + "COCKROACH_USER": defaultUser, + "COCKROACH_PASSWORD": defaultPassword, + }), + testcontainers.WithFiles(testcontainers.ContainerFile{ + Reader: newDefaultsReader(clusterDefaults), + ContainerFilePath: clusterDefaultsContainerFile, + FileMode: 0o644, + }), + testcontainers.WithWaitStrategy(wait.ForAll( + wait.ForFile(cockroachDir+"/init_success"), + wait.ForHTTP("/health").WithPort(defaultAdminPort), + wait.ForTLSCert( + certsDir+"/client."+defaultUser+".crt", + certsDir+"/client."+defaultUser+".key", + ).WithRootCAs(fileCACert).WithServerName("127.0.0.1"), + wait.ForSQL(defaultSQLPort, "pgx/v5", func(host string, port nat.Port) string { + connStr, err := c.connString(host, port) + if err != nil { + panic(err) + } + return connStr + }), + )), } + moduleOpts = append(moduleOpts, opts...) + for _, opt := range opts { - if err := opt.Customize(&req); err != nil { - return nil, fmt.Errorf("customize request: %w", err) + if o, ok := opt.(Option); ok { + if err := o(&c.options); err != nil { + return nil, fmt.Errorf("cockroachdb option: %w", err) + } } } - if err := ctr.configure(&req); err != nil { - return nil, fmt.Errorf("set options: %w", err) - } + moduleOpts = append(moduleOpts, testcontainers.WithEnv(c.env)) + moduleOpts = append(moduleOpts, configure(&c.options)) + + // pass it last to make sure all options have been set. + moduleOpts = append(moduleOpts, validatePassword()) var err error - ctr.Container, err = testcontainers.GenericContainer(ctx, req) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) if err != nil { - return ctr, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } - return ctr, nil + c.Container = ctr + + return c, nil } // connString returns a connection string for the given host, port and options. @@ -246,10 +233,10 @@ func (c *CockroachDBContainer) connString(host string, port nat.Port) (string, e // connConfig returns a [pgx.ConnConfig] for the given host, port and options. func (c *CockroachDBContainer) connConfig(host string, port nat.Port) (*pgx.ConnConfig, error) { var user *url.Userinfo - if c.password != "" { - user = url.UserPassword(c.user, c.password) + if c.env[envPassword] != "" { + user = url.UserPassword(c.env[envUser], c.env[envPassword]) } else { - user = url.User(c.user) + user = url.User(c.env[envUser]) } sslMode := "disable" @@ -265,7 +252,7 @@ func (c *CockroachDBContainer) connConfig(host string, port nat.Port) (*pgx.Conn Scheme: "postgres", User: user, Host: net.JoinHostPort(host, port.Port()), - Path: c.database, + Path: c.env[envDatabase], RawQuery: params.Encode(), } @@ -278,44 +265,3 @@ func (c *CockroachDBContainer) connConfig(host string, port nat.Port) (*pgx.Conn return cfg, nil } - -// configure sets the CockroachDBContainer options from the given request and updates the request -// wait strategies to match the options. -func (c *CockroachDBContainer) configure(req *testcontainers.GenericContainerRequest) error { - c.database = req.Env[envDatabase] - c.user = req.Env[envUser] - c.password = req.Env[envPassword] - - var insecure bool - for _, arg := range req.Cmd { - if arg == insecureFlag { - insecure = true - break - } - } - - // Walk the wait strategies to find the TLS strategy and either remove it or - // update the client certificate files to match the user and configure the - // container to use the TLS strategy. - if err := wait.Walk(&req.WaitingFor, func(strategy wait.Strategy) error { - if cert, ok := strategy.(*wait.TLSStrategy); ok { - if insecure { - // If insecure mode is enabled, the certificate strategy is removed. - return errors.Join(wait.ErrVisitRemove, wait.ErrVisitStop) - } - - // Update the client certificate files to match the user which may have changed. - cert.WithCert(certsDir+"/client."+c.user+".crt", certsDir+"/client."+c.user+".key") - - c.tlsStrategy = cert - - // Stop the walk as the certificate strategy has been found. - return wait.ErrVisitStop - } - return nil - }); err != nil { - return fmt.Errorf("walk strategies: %w", err) - } - - return nil -} diff --git a/modules/cockroachdb/options.go b/modules/cockroachdb/options.go index 9efac532c6..a4df029a63 100644 --- a/modules/cockroachdb/options.go +++ b/modules/cockroachdb/options.go @@ -2,21 +2,55 @@ package cockroachdb import ( "errors" + "fmt" "path/filepath" "strings" "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" ) +// options represents the options for the CockroachDBContainer type. +type options struct { + tlsStrategy *wait.TLSStrategy + + // used to transfer the state of the options to the container + env map[string]string +} + +func defaultOptions() options { + return options{ + env: map[string]string{ + envDatabase: defaultDatabase, + envUser: defaultUser, + envPassword: defaultPassword, + }, + } +} + +// Satisfy the testcontainers.CustomizeRequestOption interface +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the DynamoDB container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + // errInsecureWithPassword is returned when trying to use insecure mode with a password. var errInsecureWithPassword = errors.New("insecure mode cannot be used with a password") // WithDatabase sets the name of the database to create and use. // This will be converted to lowercase as CockroachDB forces the database to be lowercase. // The database creation will be skipped if data exists in the `/cockroach/cockroach-data` directory within the container. -func WithDatabase(database string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env[envDatabase] = strings.ToLower(database) +func WithDatabase(database string) Option { + lowerDB := strings.ToLower(database) + + return func(o *options) error { + o.env[envDatabase] = lowerDB return nil } } @@ -24,9 +58,11 @@ func WithDatabase(database string) testcontainers.CustomizeRequestOption { // WithUser sets the name of the user to create and connect as. // This will be converted to lowercase as CockroachDB forces the user to be lowercase. // The user creation will be skipped if data exists in the `/cockroach/cockroach-data` directory within the container. -func WithUser(user string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env[envUser] = strings.ToLower(user) +func WithUser(user string) Option { + lowerUser := strings.ToLower(user) + + return func(o *options) error { + o.env[envUser] = lowerUser return nil } } @@ -34,16 +70,27 @@ func WithUser(user string) testcontainers.CustomizeRequestOption { // WithPassword sets the password of the user to create and connect as. // The user creation will be skipped if data exists in the `/cockroach/cockroach-data` directory within the container. // This will error if insecure mode is enabled. -func WithPassword(password string) testcontainers.CustomizeRequestOption { +func WithPassword(password string) Option { + return func(o *options) error { + o.env[envPassword] = password + + return nil + } +} + +// validatePassword validates that the password is not set when insecure mode is enabled. +func validatePassword() testcontainers.CustomizeRequestOption { return func(req *testcontainers.GenericContainerRequest) error { - for _, arg := range req.Cmd { - if arg == insecureFlag { + if req.Env[envPassword] == "" { + return nil + } + + for _, cmd := range req.Cmd { + if cmd == insecureFlag { return errInsecureWithPassword } } - req.Env[envPassword] = password - return nil } } @@ -90,19 +137,16 @@ func WithNoClusterDefaults() testcontainers.CustomizeRequestOption { // WithInitScripts adds the given scripts to those automatically run when the container starts. // These will be ignored if data exists in the `/cockroach/cockroach-data` directory within the container. func WithInitScripts(scripts ...string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - files := make([]testcontainers.ContainerFile, len(scripts)) - for i, script := range scripts { - files[i] = testcontainers.ContainerFile{ - HostFilePath: script, - ContainerFilePath: initDBPath + "/" + filepath.Base(script), - FileMode: 0o644, - } + files := make([]testcontainers.ContainerFile, len(scripts)) + for i, script := range scripts { + files[i] = testcontainers.ContainerFile{ + HostFilePath: script, + ContainerFilePath: initDBPath + "/" + filepath.Base(script), + FileMode: 0o644, } - req.Files = append(req.Files, files...) - - return nil } + + return testcontainers.WithFiles(files...) } // WithInsecure enables insecure mode which disables TLS. @@ -112,7 +156,48 @@ func WithInsecure() testcontainers.CustomizeRequestOption { return errInsecureWithPassword } - req.Cmd = append(req.Cmd, insecureFlag) + if err := testcontainers.WithCmdArgs(insecureFlag)(req); err != nil { + return fmt.Errorf("with cmd args: %w", err) + } + + return nil + } +} + +// configure sets the CockroachDBContainer options from the given request and updates the request +// wait strategies to match the options. +func configure(o *options) testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + var insecure bool + for _, arg := range req.Cmd { + if arg == insecureFlag { + insecure = true + break + } + } + + // Walk the wait strategies to find the TLS strategy and either remove it or + // update the client certificate files to match the user and configure the + // container to use the TLS strategy. + if err := wait.Walk(&req.WaitingFor, func(strategy wait.Strategy) error { + if cert, ok := strategy.(*wait.TLSStrategy); ok { + if insecure { + // If insecure mode is enabled, the certificate strategy is removed. + return errors.Join(wait.ErrVisitRemove, wait.ErrVisitStop) + } + + // Update the client certificate files to match the user which may have changed. + cert.WithCert(certsDir+"/client."+o.env[envUser]+".crt", certsDir+"/client."+o.env[envUser]+".key") + + o.tlsStrategy = cert + + // Stop the walk as the certificate strategy has been found. + return wait.ErrVisitStop + } + return nil + }); err != nil { + return fmt.Errorf("walk strategies: %w", err) + } return nil } diff --git a/modules/consul/consul.go b/modules/consul/consul.go index 29243bfce4..acc4ad5610 100644 --- a/modules/consul/consul.go +++ b/modules/consul/consul.go @@ -32,25 +32,18 @@ func (c *ConsulContainer) ApiEndpoint(ctx context.Context) (string, error) { // WithConfigString takes in a JSON string of keys and values to define a configuration to be used by the instance. func WithConfigString(config string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["CONSUL_LOCAL_CONFIG"] = config - - return nil - } + return testcontainers.WithEnv(map[string]string{ + "CONSUL_LOCAL_CONFIG": config, + }) } // WithConfigFile takes in a path to a JSON file to define a configuration to be used by the instance. func WithConfigFile(configPath string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - cf := testcontainers.ContainerFile{ - HostFilePath: configPath, - ContainerFilePath: "/consul/config/node.json", - FileMode: 0o755, - } - req.Files = append(req.Files, cf) - - return nil - } + return testcontainers.WithFiles(testcontainers.ContainerFile{ + HostFilePath: configPath, + ContainerFilePath: "/consul/config/node.json", + FileMode: 0o755, + }) } // Deprecated: use Run instead @@ -61,37 +54,24 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Consul container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*ConsulContainer, error) { - containerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{ - defaultHTTPAPIPort + "/tcp", - defaultBrokerPort + "/tcp", - defaultBrokerPort + "/udp", - }, - Env: map[string]string{}, - WaitingFor: wait.ForAll( - wait.ForLog("Consul agent running!"), - wait.ForListeningPort(defaultHTTPAPIPort+"/tcp"), - ), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(defaultHTTPAPIPort+"/tcp", defaultBrokerPort+"/tcp", defaultBrokerPort+"/udp"), + testcontainers.WithWaitStrategy(wait.ForAll( + wait.ForLog("Consul agent running!"), + wait.ForListeningPort(defaultHTTPAPIPort+"/tcp"), + )), } - for _, opt := range opts { - if err := opt.Customize(&containerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, containerReq) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *ConsulContainer if container != nil { c = &ConsulContainer{Container: container} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/couchbase/couchbase.go b/modules/couchbase/couchbase.go index eddbd88de7..5fc2e6177d 100644 --- a/modules/couchbase/couchbase.go +++ b/modules/couchbase/couchbase.go @@ -71,26 +71,17 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom password: "password", indexStorageMode: MemoryOptimized, } - - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{MGMT_PORT + "/tcp", MGMT_SSL_PORT + "/tcp"}, - } - - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(MGMT_PORT+"/tcp", MGMT_SSL_PORT+"/tcp"), } for _, srv := range initialServices { - opts = append(opts, withService(srv)) + moduleOpts = append(moduleOpts, withService(srv)) } - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } + moduleOpts = append(moduleOpts, opts...) + for _, opt := range moduleOpts { // transfer options to the config if bucketCustomizer, ok := opt.(bucketCustomizer); ok { @@ -113,7 +104,7 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom } } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var couchbaseContainer *CouchbaseContainer if container != nil { couchbaseContainer = &CouchbaseContainer{container, config} diff --git a/modules/databend/databend.go b/modules/databend/databend.go index e702a727dc..5c694dab8f 100644 --- a/modules/databend/databend.go +++ b/modules/databend/databend.go @@ -38,34 +38,32 @@ func (o DatabendOption) Customize(*testcontainers.GenericContainerRequest) error // Run creates an instance of the Databend container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*DatabendContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"8000/tcp"}, - Env: map[string]string{ - "QUERY_DEFAULT_USER": defaultUser, - "QUERY_DEFAULT_PASSWORD": defaultPassword, - }, - WaitingFor: wait.ForListeningPort("8000/tcp"), + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8000/tcp"), + testcontainers.WithWaitStrategy(wait.ForListeningPort("8000/tcp")), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) + defaultOptions := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err + if o, ok := opt.(Option); ok { + if err := o(&defaultOptions); err != nil { + return nil, fmt.Errorf("databend option: %w", err) + } } } - username := req.Env["QUERY_DEFAULT_USER"] - password := req.Env["QUERY_DEFAULT_PASSWORD"] + username := defaultOptions.env["QUERY_DEFAULT_USER"] + password := defaultOptions.env["QUERY_DEFAULT_PASSWORD"] if password == "" && username == "" { return nil, errors.New("empty password and user") } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + // module options take precedence over default options + moduleOpts = append(moduleOpts, testcontainers.WithEnv(defaultOptions.env)) + + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *DatabendContainer if container != nil { c = &DatabendContainer{ @@ -77,7 +75,7 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil @@ -110,21 +108,3 @@ func (c *DatabendContainer) ConnectionString(ctx context.Context, args ...string connectionString := fmt.Sprintf("databend://%s:%s@%s/%s%s", c.username, c.password, endpoint, c.database, extraArgs) return connectionString, nil } - -// WithUsername sets the username for the Databend container. -// WithUsername is [Run] option that configures the default query user by setting -// the `QUERY_DEFAULT_USER` container environment variable. -func WithUsername(username string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["QUERY_DEFAULT_USER"] = username - return nil - } -} - -// WithPassword sets the password for the Databend container. -func WithPassword(password string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["QUERY_DEFAULT_PASSWORD"] = password - return nil - } -} diff --git a/modules/databend/options.go b/modules/databend/options.go new file mode 100644 index 0000000000..9ecf40e869 --- /dev/null +++ b/modules/databend/options.go @@ -0,0 +1,46 @@ +package databend + +import "github.com/testcontainers/testcontainers-go" + +type options struct { + env map[string]string +} + +func defaultOptions() options { + return options{ + env: map[string]string{ + "QUERY_DEFAULT_USER": defaultUser, + "QUERY_DEFAULT_PASSWORD": defaultPassword, + }, + } +} + +// Satisfy the testcontainers.CustomizeRequestOption interface +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the Databend container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + +// WithUsername sets the username for the Databend container. +// WithUsername is [Run] option that configures the default query user by setting +// the `QUERY_DEFAULT_USER` container environment variable. +func WithUsername(username string) Option { + return func(o *options) error { + o.env["QUERY_DEFAULT_USER"] = username + return nil + } +} + +// WithPassword sets the password for the Databend container. +func WithPassword(password string) Option { + return func(o *options) error { + o.env["QUERY_DEFAULT_PASSWORD"] = password + return nil + } +} diff --git a/modules/dind/dind.go b/modules/dind/dind.go index a4425c9754..3874026d5b 100644 --- a/modules/dind/dind.go +++ b/modules/dind/dind.go @@ -23,12 +23,13 @@ type Container struct { // Run creates an instance of the Docker in Docker container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{ - defaultDockerDaemonPort, - }, - HostConfigModifier: func(hc *container.HostConfig) { + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithCmd("dockerd", "-H", "tcp://0.0.0.0:2375", "--tls=false"), + testcontainers.WithExposedPorts(defaultDockerDaemonPort), + testcontainers.WithEnv(map[string]string{ + "DOCKER_HOST": "tcp://localhost:2375", + }), + testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) { hc.Privileged = true hc.CgroupnsMode = "host" hc.Tmpfs = map[string]string{ @@ -36,28 +37,13 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom "/var/run": "", } hc.Mounts = []mount.Mount{} - }, - Cmd: []string{ - "dockerd", "-H", "tcp://0.0.0.0:2375", "--tls=false", - }, - Env: map[string]string{ - "DOCKER_HOST": "tcp://localhost:2375", - }, - WaitingFor: wait.ForListeningPort("2375/tcp"), + }), + testcontainers.WithWaitStrategy(wait.ForListeningPort("2375/tcp")), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container if container != nil { c = &Container{Container: container} diff --git a/modules/dockermodelrunner/options.go b/modules/dockermodelrunner/options.go index 79f058df75..d5964ab487 100644 --- a/modules/dockermodelrunner/options.go +++ b/modules/dockermodelrunner/options.go @@ -13,7 +13,7 @@ func defaultOptions() options { // Compiler check to ensure that Option implements the testcontainers.ContainerCustomizer interface. var _ testcontainers.ContainerCustomizer = (Option)(nil) -// Option is an option for the Redpanda container. +// Option is an option for the DockerModelRunner container. type Option func(*options) error // Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. diff --git a/modules/dolt/dolt.go b/modules/dolt/dolt.go index 9e8b4d9812..0f839cad4e 100644 --- a/modules/dolt/dolt.go +++ b/modules/dolt/dolt.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "path/filepath" "strings" "github.com/testcontainers/testcontainers-go" @@ -27,18 +26,6 @@ type DoltContainer struct { database string } -func WithDefaultCredentials() testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - username := req.Env["DOLT_USER"] - if strings.EqualFold(rootUser, username) { - delete(req.Env, "DOLT_USER") - delete(req.Env, "DOLT_PASSWORD") - } - - return nil - } -} - // Deprecated: use Run instead // RunContainer creates an instance of the Couchbase container type func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*DoltContainer, error) { @@ -47,39 +34,36 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Dolt container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*DoltContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"3306/tcp", "33060/tcp"}, - Env: map[string]string{ - "DOLT_USER": defaultUser, - "DOLT_PASSWORD": defaultPassword, - "DOLT_DATABASE": defaultDatabaseName, - }, - WaitingFor: wait.ForLog("Server ready. Accepting connections."), - } - - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("3306/tcp", "33060/tcp"), + testcontainers.WithWaitStrategy(wait.ForLog("Server ready. Accepting connections.")), } opts = append(opts, WithDefaultCredentials()) + moduleOpts = append(moduleOpts, opts...) + + defaultOptions := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err + if o, ok := opt.(Option); ok { + if err := o(&defaultOptions); err != nil { + return nil, fmt.Errorf("dolt option: %w", err) + } } } + // module options take precedence over default options + moduleOpts = append(moduleOpts, testcontainers.WithEnv(defaultOptions.env), testcontainers.WithFiles(defaultOptions.files...)) + createUser := true - username, ok := req.Env["DOLT_USER"] + username, ok := defaultOptions.env["DOLT_USER"] if !ok { username = rootUser createUser = false } - password := req.Env["DOLT_PASSWORD"] + password := defaultOptions.env["DOLT_PASSWORD"] - database := req.Env["DOLT_DATABASE"] + database := defaultOptions.env["DOLT_DATABASE"] if database == "" { database = defaultDatabaseName } @@ -88,13 +72,13 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom return nil, errors.New("empty password can be used only with the root user") } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var dc *DoltContainer if container != nil { dc = &DoltContainer{Container: container, username: username, password: password, database: database} } if err != nil { - return dc, err + return dc, fmt.Errorf("run: %w", err) } // dolthub/dolt-sql-server does not create user or database, so we do so here @@ -186,79 +170,3 @@ func (c *DoltContainer) ConnectionString(ctx context.Context, args ...string) (s connectionString := fmt.Sprintf("%s:%s@tcp(%s)/%s%s", c.username, c.password, endpoint, c.database, extraArgs) return connectionString, nil } - -func WithUsername(username string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["DOLT_USER"] = username - return nil - } -} - -func WithPassword(password string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["DOLT_PASSWORD"] = password - return nil - } -} - -func WithDoltCredsPublicKey(key string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["DOLT_CREDS_PUB_KEY"] = key - return nil - } -} - -//nolint:revive,staticcheck //FIXME -func WithDoltCloneRemoteUrl(url string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["DOLT_REMOTE_CLONE_URL"] = url - return nil - } -} - -func WithDatabase(database string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["DOLT_DATABASE"] = database - return nil - } -} - -func WithConfigFile(configFile string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - cf := testcontainers.ContainerFile{ - HostFilePath: configFile, - ContainerFilePath: "/etc/dolt/servercfg.d/server.cnf", - FileMode: 0o755, - } - req.Files = append(req.Files, cf) - return nil - } -} - -func WithCredsFile(credsFile string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - cf := testcontainers.ContainerFile{ - HostFilePath: credsFile, - ContainerFilePath: "/root/.dolt/creds/" + filepath.Base(credsFile), - FileMode: 0o755, - } - req.Files = append(req.Files, cf) - return nil - } -} - -func WithScripts(scripts ...string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - var initScripts []testcontainers.ContainerFile - for _, script := range scripts { - cf := testcontainers.ContainerFile{ - HostFilePath: script, - ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base(script), - FileMode: 0o755, - } - initScripts = append(initScripts, cf) - } - req.Files = append(req.Files, initScripts...) - return nil - } -} diff --git a/modules/dolt/options.go b/modules/dolt/options.go new file mode 100644 index 0000000000..816807f901 --- /dev/null +++ b/modules/dolt/options.go @@ -0,0 +1,123 @@ +package dolt + +import ( + "path/filepath" + "strings" + + "github.com/testcontainers/testcontainers-go" +) + +type options struct { + env map[string]string + files []testcontainers.ContainerFile +} + +func defaultOptions() options { + return options{ + env: map[string]string{ + "DOLT_USER": defaultUser, + "DOLT_PASSWORD": defaultPassword, + "DOLT_DATABASE": defaultDatabaseName, + }, + } +} + +// Satisfy the testcontainers.CustomizeRequestOption interface +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the Dolt container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + +func WithDefaultCredentials() Option { + return func(o *options) error { + username := o.env["DOLT_USER"] + if strings.EqualFold(rootUser, username) { + delete(o.env, "DOLT_USER") + delete(o.env, "DOLT_PASSWORD") + } + + return nil + } +} + +func WithUsername(username string) Option { + return func(o *options) error { + o.env["DOLT_USER"] = username + return nil + } +} + +func WithPassword(password string) Option { + return func(o *options) error { + o.env["DOLT_PASSWORD"] = password + return nil + } +} + +func WithDoltCredsPublicKey(key string) Option { + return func(o *options) error { + o.env["DOLT_CREDS_PUB_KEY"] = key + return nil + } +} + +//nolint:revive,staticcheck //FIXME +func WithDoltCloneRemoteUrl(url string) Option { + return func(o *options) error { + o.env["DOLT_REMOTE_CLONE_URL"] = url + return nil + } +} + +func WithDatabase(database string) Option { + return func(o *options) error { + o.env["DOLT_DATABASE"] = database + return nil + } +} + +func WithConfigFile(configFile string) Option { + return func(o *options) error { + cf := testcontainers.ContainerFile{ + HostFilePath: configFile, + ContainerFilePath: "/etc/dolt/servercfg.d/server.cnf", + FileMode: 0o755, + } + o.files = append(o.files, cf) + return nil + } +} + +func WithCredsFile(credsFile string) Option { + return func(o *options) error { + cf := testcontainers.ContainerFile{ + HostFilePath: credsFile, + ContainerFilePath: "/root/.dolt/creds/" + filepath.Base(credsFile), + FileMode: 0o755, + } + o.files = append(o.files, cf) + return nil + } +} + +func WithScripts(scripts ...string) Option { + return func(o *options) error { + var initScripts []testcontainers.ContainerFile + for _, script := range scripts { + cf := testcontainers.ContainerFile{ + HostFilePath: script, + ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base(script), + FileMode: 0o755, + } + initScripts = append(initScripts, cf) + } + o.files = append(o.files, initScripts...) + return nil + } +} diff --git a/modules/dynamodb/dynamodb.go b/modules/dynamodb/dynamodb.go index 411fc53ccf..156b067351 100644 --- a/modules/dynamodb/dynamodb.go +++ b/modules/dynamodb/dynamodb.go @@ -3,6 +3,7 @@ package dynamodb import ( "context" "fmt" + "slices" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" @@ -20,33 +21,39 @@ type DynamoDBContainer struct { // Run creates an instance of the DynamoDB container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*DynamoDBContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{string(port)}, - Entrypoint: []string{"java", "-Djava.library.path=./DynamoDBLocal_lib"}, - Cmd: []string{"-jar", "DynamoDBLocal.jar"}, - WaitingFor: wait.ForListeningPort(port), + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithEntrypoint("java", "-Djava.library.path=./DynamoDBLocal_lib"), + testcontainers.WithCmd("-jar", "DynamoDBLocal.jar"), + testcontainers.WithExposedPorts(port), + testcontainers.WithWaitStrategy(wait.ForListeningPort(port)), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) + defaultOptions := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) + if o, ok := opt.(Option); ok { + if err := o(&defaultOptions); err != nil { + return nil, fmt.Errorf("dynamodb option: %w", err) + } } } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + if slices.Contains(defaultOptions.cmd, "-sharedDb") { + moduleOpts = append(moduleOpts, testcontainers.WithReuseByName(containerName)) + } + + // module options take precedence over default options + moduleOpts = append(moduleOpts, testcontainers.WithCmdArgs(defaultOptions.cmd...)) + + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *DynamoDBContainer if container != nil { c = &DynamoDBContainer{Container: container} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil @@ -56,25 +63,3 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom func (c *DynamoDBContainer) ConnectionString(ctx context.Context) (string, error) { return c.PortEndpoint(ctx, port, "") } - -// WithSharedDB allows container reuse between successive runs. Data will be persisted -func WithSharedDB() testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Cmd = append(req.Cmd, "-sharedDb") - - req.Reuse = true - req.Name = containerName - - return nil - } -} - -// WithDisableTelemetry - DynamoDB local will not send any telemetry -func WithDisableTelemetry() testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - // if other flags (e.g. -sharedDb) exist, append to them - req.Cmd = append(req.Cmd, "-disableTelemetry") - - return nil - } -} diff --git a/modules/dynamodb/options.go b/modules/dynamodb/options.go new file mode 100644 index 0000000000..f3cd2aa6ba --- /dev/null +++ b/modules/dynamodb/options.go @@ -0,0 +1,42 @@ +package dynamodb + +import "github.com/testcontainers/testcontainers-go" + +type options struct { + cmd []string +} + +func defaultOptions() options { + return options{} +} + +// Satisfy the testcontainers.CustomizeRequestOption interface +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the DynamoDB container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + +// WithSharedDB allows container reuse between successive runs. Data will be persisted +func WithSharedDB() Option { + return func(o *options) error { + o.cmd = append(o.cmd, "-sharedDb") + + return nil + } +} + +// WithDisableTelemetry - DynamoDB local will not send any telemetry +func WithDisableTelemetry() Option { + return func(o *options) error { + // if other flags (e.g. -sharedDb) exist, append to them + o.cmd = append(o.cmd, "-disableTelemetry") + + return nil + } +} diff --git a/modules/elasticsearch/elasticsearch.go b/modules/elasticsearch/elasticsearch.go index 5b2979d6a9..374e2c611b 100644 --- a/modules/elasticsearch/elasticsearch.go +++ b/modules/elasticsearch/elasticsearch.go @@ -20,6 +20,7 @@ const ( defaultUsername = "elastic" defaultCaCertPath = "/usr/share/elasticsearch/config/certs/http_ca.crt" minimalImageVersion = "7.9.2" + envPassword = "ELASTIC_PASSWORD" ) const ( @@ -43,55 +44,39 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Elasticsearch container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*ElasticsearchContainer, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - Env: map[string]string{ - "discovery.type": "single-node", - "cluster.routing.allocation.disk.threshold_enabled": "false", - }, - ExposedPorts: []string{ - defaultHTTPPort + "/tcp", - defaultTCPPort + "/tcp", - }, - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(defaultHTTPPort, defaultTCPPort), + testcontainers.WithEnv(map[string]string{ + "discovery.type": "single-node", + "cluster.routing.allocation.disk.threshold_enabled": "false", + }), } + moduleOpts = append(moduleOpts, opts...) + // Gather all config options (defaults and then apply provided options) options := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { apply(options) } - if err := opt.Customize(&req); err != nil { - return nil, err - } } // Transfer the password settings to the container request - if err := configurePassword(options, &req); err != nil { - return nil, err - } + moduleOpts = append(moduleOpts, configurePassword(options)) - if isAtLeastVersion(req.Image, 7) { - req.LifecycleHooks = append(req.LifecycleHooks, - testcontainers.ContainerLifecycleHooks{ - PostCreates: []testcontainers.ContainerHook{configureJvmOpts}, - }, - ) - } + moduleOpts = append(moduleOpts, configureJvmOpts()) // Set the default waiting strategy if not already set. - setWaitFor(options, &req.ContainerRequest) + moduleOpts = append(moduleOpts, configureWaitFor(options)) - container, err := testcontainers.GenericContainer(ctx, req) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var esContainer *ElasticsearchContainer - if container != nil { - esContainer = &ElasticsearchContainer{Container: container, Settings: *options} + if ctr != nil { + esContainer = &ElasticsearchContainer{Container: ctr, Settings: *options} } if err != nil { - return esContainer, fmt.Errorf("generic container: %w", err) + return esContainer, fmt.Errorf("run: %w", err) } if err := esContainer.configureAddress(ctx); err != nil { @@ -120,40 +105,33 @@ func (w *certWriter) Read(r io.Reader) error { return nil } -// setWaitFor sets the req.WaitingFor strategy based on settings. -func setWaitFor(options *Options, req *testcontainers.ContainerRequest) { - var strategies []wait.Strategy - if req.WaitingFor != nil { - // Custom waiting strategy, ensure we honour it. - strategies = append(strategies, req.WaitingFor) - } - - waitHTTP := wait.ForHTTP("/").WithPort(defaultHTTPPort) - if sslRequired(req) { - waitHTTP = waitHTTP.WithTLS(true).WithAllowInsecure(true) - cw := &certWriter{ - options: options, - certPool: x509.NewCertPool(), - } +// configureWaitFor sets the req.WaitingFor strategy based on settings. +func configureWaitFor(options *Options) testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + var strategies []wait.Strategy + + waitHTTP := wait.ForHTTP("/").WithPort(defaultHTTPPort) + if sslRequired(req) { + waitHTTP = waitHTTP.WithTLS(true).WithAllowInsecure(true) + cw := &certWriter{ + options: options, + certPool: x509.NewCertPool(), + } - waitHTTP = waitHTTP. - WithTLS(true, &tls.Config{RootCAs: cw.certPool}) + waitHTTP = waitHTTP. + WithTLS(true, &tls.Config{RootCAs: cw.certPool}) - strategies = append(strategies, wait.ForFile(defaultCaCertPath).WithMatcher(cw.Read)) - } + strategies = append(strategies, wait.ForFile(defaultCaCertPath).WithMatcher(cw.Read)) + } - if options.Password != "" || options.Username != "" { - waitHTTP = waitHTTP.WithBasicAuth(options.Username, options.Password) - } + if options.Password != "" || options.Username != "" { + waitHTTP = waitHTTP.WithBasicAuth(options.Username, options.Password) + } - strategies = append(strategies, waitHTTP) + strategies = append(strategies, waitHTTP) - if len(strategies) > 1 { - req.WaitingFor = wait.ForAll(strategies...) - return + return testcontainers.WithAdditionalWaitStrategy(strategies...)(req) } - - req.WaitingFor = strategies[0] } // configureAddress sets the address of the Elasticsearch container. @@ -175,7 +153,7 @@ func (c *ElasticsearchContainer) configureAddress(ctx context.Context) error { } // sslRequired returns true if the SSL is required, otherwise false. -func sslRequired(req *testcontainers.ContainerRequest) bool { +func sslRequired(req *testcontainers.GenericContainerRequest) bool { if !isAtLeastVersion(req.Image, 8) { return false } @@ -200,58 +178,74 @@ func sslRequired(req *testcontainers.ContainerRequest) bool { // configurePassword transfers the password settings to the container request. // If the password is not set, it will be set to "changeme" for Elasticsearch 8 -func configurePassword(settings *Options, req *testcontainers.GenericContainerRequest) error { - // set "changeme" as default password for Elasticsearch 8 - if isAtLeastVersion(req.Image, 8) && settings.Password == "" { - WithPassword(defaultPassword)(settings) - } - - if settings.Password != "" { - if isOSS(req.Image) { - return errors.New("it's not possible to activate security on Elastic OSS Image. Please switch to the default distribution") +func configurePassword(settings *Options) testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + // set "changeme" as default password for Elasticsearch 8 + if isAtLeastVersion(req.Image, 8) && settings.Password == "" { + WithPassword(defaultPassword)(settings) } - if _, ok := req.Env["ELASTIC_PASSWORD"]; !ok { - req.Env["ELASTIC_PASSWORD"] = settings.Password - } + if settings.Password != "" { + if isOSS(req.Image) { + return errors.New("it's not possible to activate security on Elastic OSS Image. Please switch to the default distribution") + } + + if _, ok := req.Env[envPassword]; !ok { + req.Env[envPassword] = settings.Password + } - // major version 8 is secure by default and does not need this to enable authentication - if !isAtLeastVersion(req.Image, 8) { - req.Env["xpack.security.enabled"] = "true" + // major version 8 is secure by default and does not need this to enable authentication + if !isAtLeastVersion(req.Image, 8) { + req.Env["xpack.security.enabled"] = "true" + } } - } - return nil + return nil + } } // configureJvmOpts sets the default memory of the Elasticsearch instance to 2GB. // This functions, which is only available since version 7, is called as a post create hook // for the container request. -func configureJvmOpts(ctx context.Context, container testcontainers.Container) error { - // Sets default memory of elasticsearch instance to 2GB - defaultJVMOpts := `-Xms2G +func configureJvmOpts() testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + if isAtLeastVersion(req.Image, 7) { + return testcontainers.WithAdditionalLifecycleHooks( + testcontainers.ContainerLifecycleHooks{ + PostCreates: []testcontainers.ContainerHook{ + func(ctx context.Context, container testcontainers.Container) error { + // Sets default memory of elasticsearch instance to 2GB + defaultJVMOpts := `-Xms2G -Xmx2G -Dingest.geoip.downloader.enabled.default=false ` - tmpDir := os.TempDir() - - tmpFile, err := os.CreateTemp(tmpDir, "elasticsearch-default-memory-vm.options") - if err != nil { - return err - } - defer os.Remove(tmpFile.Name()) // clean up - - if _, err := tmpFile.WriteString(defaultJVMOpts); err != nil { - return err - } + tmpDir := os.TempDir() + + tmpFile, err := os.CreateTemp(tmpDir, "elasticsearch-default-memory-vm.options") + if err != nil { + return err + } + defer os.Remove(tmpFile.Name()) // clean up + + if _, err := tmpFile.WriteString(defaultJVMOpts); err != nil { + return err + } + + // Spaces are deliberate to allow user to define additional jvm options as elasticsearch resolves option files lexicographically + if err := container.CopyFileToContainer( + ctx, tmpFile.Name(), + "/usr/share/elasticsearch/config/jvm.options.d/ elasticsearch-default-memory-vm.options", 0o644); err != nil { + return err + } + + return nil + }, + }, + }, + )(req) + } - // Spaces are deliberate to allow user to define additional jvm options as elasticsearch resolves option files lexicographically - if err := container.CopyFileToContainer( - ctx, tmpFile.Name(), - "/usr/share/elasticsearch/config/jvm.options.d/ elasticsearch-default-memory-vm.options", 0o644); err != nil { - return err + return nil } - - return nil } diff --git a/modules/etcd/etcd.go b/modules/etcd/etcd.go index a1b87b0c79..29e8d71af1 100644 --- a/modules/etcd/etcd.go +++ b/modules/etcd/etcd.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" tcnetwork "github.com/testcontainers/testcontainers-go/network" ) @@ -59,24 +60,18 @@ func (c *EtcdContainer) Terminate(ctx context.Context, opts ...testcontainers.Te // Run creates an instance of the etcd container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*EtcdContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{clientPort, peerPort}, - Cmd: []string{}, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(clientPort, peerPort), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) - settings := defaultOptions(&req) + settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { - apply(&settings) - } - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err + if err := apply(&settings); err != nil { + return nil, fmt.Errorf("etcd option: %w", err) + } } } @@ -86,7 +81,7 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom } // configure CMD with the nodes - genericContainerReq.Cmd = configureCMD(settings) + moduleOpts = append(moduleOpts, testcontainers.WithCmd(configureCMD(settings)...)) // Initialise the etcd container with the current settings. // The cluster network, if needed, is already part of the settings, @@ -96,21 +91,33 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom if settings.clusterNetwork != nil { // apply the network to the current node - err := tcnetwork.WithNetwork([]string{settings.nodeNames[settings.currentNode]}, settings.clusterNetwork)(&genericContainerReq) - if err != nil { - return c, fmt.Errorf("with network: %w", err) - } + moduleOpts = append(moduleOpts, tcnetwork.WithNetwork([]string{settings.nodeNames[settings.currentNode]}, settings.clusterNetwork)) + } + + if settings.mountDataDir { + moduleOpts = append(moduleOpts, testcontainers.WithAdditionalLifecycleHooks(testcontainers.ContainerLifecycleHooks{ + PostStarts: []testcontainers.ContainerHook{ + func(ctx context.Context, c testcontainers.Container) error { + _, _, err := c.Exec(ctx, []string{"chmod", "o+rwx", "-R", dataDir}, tcexec.Multiplexed()) + if err != nil { + return fmt.Errorf("chmod etcd data dir: %w", err) + } + + return nil + }, + }, + })) } - if c.Container, err = testcontainers.GenericContainer(ctx, genericContainerReq); err != nil { - return c, fmt.Errorf("generic container: %w", err) + if c.Container, err = testcontainers.Run(ctx, img, moduleOpts...); err != nil { + return c, fmt.Errorf("run: %w", err) } // only the first node creates the cluster if settings.currentNode == 0 { for i := 1; i < len(settings.nodeNames); i++ { // move to the next node - childNode, err := Run(ctx, req.Image, append(clusterOpts, withCurrentNode(i))...) + childNode, err := Run(ctx, img, append(clusterOpts, withCurrentNode(i))...) if err != nil { // return the parent cluster node and the error, so the caller can clean up. return c, fmt.Errorf("run cluster node: %w", err) diff --git a/modules/etcd/etcd_test.go b/modules/etcd/etcd_test.go index 5c0bc702bc..4160626754 100644 --- a/modules/etcd/etcd_test.go +++ b/modules/etcd/etcd_test.go @@ -30,6 +30,22 @@ func TestRun(t *testing.T) { require.Contains(t, string(output), "default") } +func TestRunWithDataDir(t *testing.T) { + ctx := context.Background() + + ctr, err := etcd.Run(ctx, "gcr.io/etcd-development/etcd:v3.5.14", etcd.WithDataDir()) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + c, r, err := ctr.Exec(ctx, []string{"etcdctl", "member", "list"}, tcexec.Multiplexed()) + require.NoError(t, err) + require.Zero(t, c) + + output, err := io.ReadAll(r) + require.NoError(t, err) + require.Contains(t, string(output), "default") +} + func TestPutGet(t *testing.T) { t.Run("single_node", func(t *testing.T) { ctr, err := etcd.Run(context.Background(), "gcr.io/etcd-development/etcd:v3.5.14") diff --git a/modules/etcd/options.go b/modules/etcd/options.go index 1359e4a3b4..0f6333e01f 100644 --- a/modules/etcd/options.go +++ b/modules/etcd/options.go @@ -1,27 +1,23 @@ package etcd import ( - "context" "fmt" "github.com/testcontainers/testcontainers-go" - tcexec "github.com/testcontainers/testcontainers-go/exec" ) type options struct { - currentNode int - clusterNetwork *testcontainers.DockerNetwork - nodeNames []string - clusterToken string - additionalArgs []string - mountDataDir bool // flag needed to avoid extra calculations with the lifecycle hooks - containerRequest *testcontainers.ContainerRequest + currentNode int + clusterNetwork *testcontainers.DockerNetwork + nodeNames []string + clusterToken string + additionalArgs []string + mountDataDir bool // flag needed to avoid extra calculations with the lifecycle hooks } -func defaultOptions(req *testcontainers.ContainerRequest) options { +func defaultOptions() options { return options{ - clusterToken: defaultClusterToken, - containerRequest: req, + clusterToken: defaultClusterToken, } } @@ -29,7 +25,7 @@ func defaultOptions(req *testcontainers.ContainerRequest) options { var _ testcontainers.ContainerCustomizer = (Option)(nil) // Option is an option for the Etcd container. -type Option func(*options) +type Option func(*options) error // Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. func (o Option) Customize(*testcontainers.GenericContainerRequest) error { @@ -40,68 +36,64 @@ func (o Option) Customize(*testcontainers.GenericContainerRequest) error { // WithAdditionalArgs is an option to pass additional arguments to the etcd container. // They will be appended last to the command line. func WithAdditionalArgs(args ...string) Option { - return func(o *options) { + return func(o *options) error { o.additionalArgs = args + return nil } } // WithDataDir is an option to mount the data directory, which is located at /data.etcd. // The option will add a lifecycle hook to the container to change the permissions of the data directory. func WithDataDir() Option { - return func(o *options) { + return func(o *options) error { // Avoid extra calculations with the lifecycle hooks o.mountDataDir = true - - o.containerRequest.LifecycleHooks = append(o.containerRequest.LifecycleHooks, testcontainers.ContainerLifecycleHooks{ - PostStarts: []testcontainers.ContainerHook{ - func(ctx context.Context, c testcontainers.Container) error { - _, _, err := c.Exec(ctx, []string{"chmod", "o+rwx", "-R", dataDir}, tcexec.Multiplexed()) - if err != nil { - return fmt.Errorf("chmod etcd data dir: %w", err) - } - - return nil - }, - }, - }) + return nil } } // WithNodes is an option to set the nodes of the etcd cluster. // It should be used to create a cluster with more than one node. func WithNodes(node1 string, node2 string, nodes ...string) Option { - return func(o *options) { + return func(o *options) error { o.nodeNames = append([]string{node1, node2}, nodes...) + return nil } } // withCurrentNode is an option to set the current node index. // It's an internal option and should not be used by the user. func withCurrentNode(i int) Option { - return func(o *options) { + return func(o *options) error { o.currentNode = i + return nil } } // withClusterNetwork is an option to set the cluster network. // It's an internal option and should not be used by the user. func withClusterNetwork(n *testcontainers.DockerNetwork) Option { - return func(o *options) { + return func(o *options) error { o.clusterNetwork = n + return nil } } // WithClusterToken is an option to set the cluster token. func WithClusterToken(token string) Option { - return func(o *options) { + return func(o *options) error { o.clusterToken = token + return nil } } func withClusterOptions(opts []Option) Option { - return func(o *options) { + return func(o *options) error { for _, opt := range opts { - opt(o) + if err := opt(o); err != nil { + return fmt.Errorf("withClusterOptions: %w", err) + } } + return nil } } diff --git a/modules/gcloud/bigquery.go b/modules/gcloud/bigquery.go index 5eabedddab..7e7cae63d1 100644 --- a/modules/gcloud/bigquery.go +++ b/modules/gcloud/bigquery.go @@ -18,34 +18,32 @@ func RunBigQueryContainer(ctx context.Context, opts ...testcontainers.ContainerC // RunBigQuery creates an instance of the GCloud container type for BigQuery. // The URI uses http:// as the protocol. func RunBigQuery(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*GCloudContainer, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"9050/tcp", "9060/tcp"}, - WaitingFor: wait.ForHTTP("/discovery/v1/apis/bigquery/v2/rest").WithPort("9050/tcp").WithStartupTimeout(time.Second * 5), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("9050/tcp", "9060/tcp"), + testcontainers.WithWaitStrategy(wait.ForHTTP("/discovery/v1/apis/bigquery/v2/rest").WithPort("9050/tcp").WithStartupTimeout(time.Second * 5)), } - settings, err := applyOptions(&req, opts) + moduleOpts = append(moduleOpts, opts...) + + settings, err := applyOptions(opts) if err != nil { return nil, err } - req.Cmd = append(req.Cmd, "--project", settings.ProjectID) + moduleOpts = append(moduleOpts, testcontainers.WithCmdArgs("--project", settings.ProjectID)) // Process data yaml file only for the BigQuery container. if settings.bigQueryDataYaml != nil { containerPath := "/testcontainers-data.yaml" - req.Cmd = append(req.Cmd, "--data-from-yaml", containerPath) + moduleOpts = append(moduleOpts, testcontainers.WithCmdArgs("--data-from-yaml", containerPath)) - req.Files = append(req.Files, testcontainers.ContainerFile{ + moduleOpts = append(moduleOpts, testcontainers.WithFiles(testcontainers.ContainerFile{ Reader: settings.bigQueryDataYaml, ContainerFilePath: containerPath, FileMode: 0o644, - }) + })) } - return newGCloudContainer(ctx, req, 9050, settings, "http") + return newGCloudContainer(ctx, img, 9050, settings, "http", moduleOpts...) } diff --git a/modules/gcloud/bigquery/bigquery.go b/modules/gcloud/bigquery/bigquery.go index 899d16e86e..0166c12659 100644 --- a/modules/gcloud/bigquery/bigquery.go +++ b/modules/gcloud/bigquery/bigquery.go @@ -36,41 +36,36 @@ func (c *Container) URI() string { // Run creates an instance of the BigQuery GCloud container type. // The URI uses http:// as the protocol. func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"9050/tcp", "9060/tcp"}, - WaitingFor: wait.ForAll( - wait.ForListeningPort("9050/tcp"), - wait.ForHTTP("/discovery/v1/apis/bigquery/v2/rest").WithPort("9050/tcp").WithStatusCodeMatcher(func(status int) bool { - return status == 200 - }).WithStartupTimeout(time.Second*5), - ), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("9050/tcp", "9060/tcp"), + testcontainers.WithWaitStrategy(wait.ForAll( + wait.ForListeningPort("9050/tcp"), + wait.ForHTTP("/discovery/v1/apis/bigquery/v2/rest").WithPort("9050/tcp").WithStatusCodeMatcher(func(status int) bool { + return status == 200 + }).WithStartupTimeout(time.Second*5), + )), } + moduleOpts = append(moduleOpts, opts...) + settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { if err := apply(&settings); err != nil { - return nil, err + return nil, fmt.Errorf("bigquery option: %w", err) } } - if err := opt.Customize(&req); err != nil { - return nil, err - } } - req.Cmd = append(req.Cmd, "--project", settings.ProjectID) + moduleOpts = append(moduleOpts, testcontainers.WithCmdArgs("--project", settings.ProjectID)) - container, err := testcontainers.GenericContainer(ctx, req) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container if container != nil { c = &Container{Container: container, settings: settings} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } portEndpoint, err := c.PortEndpoint(ctx, "9050/tcp", "http") diff --git a/modules/gcloud/bigquery/bigquery_test.go b/modules/gcloud/bigquery/bigquery_test.go index 3a4dc346a3..e49c061789 100644 --- a/modules/gcloud/bigquery/bigquery_test.go +++ b/modules/gcloud/bigquery/bigquery_test.go @@ -76,7 +76,7 @@ func TestBigQueryWithDataYAML(t *testing.T) { tcbigquery.WithDataYAML(bytes.NewReader(dataYaml)), ) testcontainers.CleanupContainer(t, bigQueryContainer) - require.EqualError(t, err, `data yaml already exists`) + require.ErrorContains(t, err, `data yaml already exists`) }) t.Run("multi-value-not-set", func(t *testing.T) { diff --git a/modules/gcloud/bigquery/options.go b/modules/gcloud/bigquery/options.go index 16e080bcee..56b50f81b0 100644 --- a/modules/gcloud/bigquery/options.go +++ b/modules/gcloud/bigquery/options.go @@ -34,14 +34,14 @@ func WithDataYAML(r io.Reader) testcontainers.CustomizeRequestOption { return errors.New("data yaml already exists") } - req.Cmd = append(req.Cmd, "--data-from-yaml", bigQueryDataYamlPath) + if err := testcontainers.WithCmdArgs("--data-from-yaml", bigQueryDataYamlPath)(req); err != nil { + return err + } - req.Files = append(req.Files, testcontainers.ContainerFile{ + return testcontainers.WithFiles(testcontainers.ContainerFile{ Reader: r, ContainerFilePath: bigQueryDataYamlPath, FileMode: 0o644, - }) - - return nil + })(req) } } diff --git a/modules/gcloud/bigtable.go b/modules/gcloud/bigtable.go index e133b55e46..650ef3521f 100644 --- a/modules/gcloud/bigtable.go +++ b/modules/gcloud/bigtable.go @@ -16,25 +16,23 @@ func RunBigTableContainer(ctx context.Context, opts ...testcontainers.ContainerC // Deprecated: use [bigtable.Run] instead // RunBigTable creates an instance of the GCloud container type for BigTable. func RunBigTable(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*GCloudContainer, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"9000/tcp"}, - WaitingFor: wait.ForLog("running"), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("9000/tcp"), + testcontainers.WithWaitStrategy(wait.ForLog("running")), } - settings, err := applyOptions(&req, opts) + moduleOpts = append(moduleOpts, opts...) + + settings, err := applyOptions(opts) if err != nil { return nil, err } - req.Cmd = []string{ + moduleOpts = append(moduleOpts, testcontainers.WithCmd( "/bin/sh", "-c", - "gcloud beta emulators bigtable start --host-port 0.0.0.0:9000 --project=" + settings.ProjectID, - } + "gcloud beta emulators bigtable start --host-port 0.0.0.0:9000 --project="+settings.ProjectID, + )) - return newGCloudContainer(ctx, req, 9000, settings, "") + return newGCloudContainer(ctx, img, 9000, settings, "", moduleOpts...) } diff --git a/modules/gcloud/bigtable/bigtable.go b/modules/gcloud/bigtable/bigtable.go index 5d6a7c98b5..fe9efbe0e5 100644 --- a/modules/gcloud/bigtable/bigtable.go +++ b/modules/gcloud/bigtable/bigtable.go @@ -32,43 +32,38 @@ func (c *Container) URI() string { // Run creates an instance of the BigTable GCloud container type. // The URI uses the empty string as the protocol. func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"9000/tcp"}, - WaitingFor: wait.ForAll( - wait.ForListeningPort("9000/tcp"), - wait.ForLog("running"), - ), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("9000/tcp"), + testcontainers.WithWaitStrategy(wait.ForAll( + wait.ForListeningPort("9000/tcp"), + wait.ForLog("running"), + )), } + moduleOpts = append(moduleOpts, opts...) + settings := defaultOptions() - for _, opt := range opts { + for _, opt := range moduleOpts { if apply, ok := opt.(Option); ok { if err := apply(&settings); err != nil { - return nil, err + return nil, fmt.Errorf("bigtable option: %w", err) } } - if err := opt.Customize(&req); err != nil { - return nil, err - } } - req.Cmd = []string{ + moduleOpts = append(moduleOpts, testcontainers.WithCmd( "/bin/sh", "-c", - "gcloud beta emulators bigtable start --host-port 0.0.0.0:9000 --project=" + settings.ProjectID, - } + "gcloud beta emulators bigtable start --host-port 0.0.0.0:9000 --project="+settings.ProjectID, + )) - container, err := testcontainers.GenericContainer(ctx, req) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container if container != nil { c = &Container{Container: container, settings: settings} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } portEndpoint, err := c.PortEndpoint(ctx, "9000/tcp", "") diff --git a/modules/gcloud/datastore.go b/modules/gcloud/datastore.go index 02fbd73f6a..a435f60eab 100644 --- a/modules/gcloud/datastore.go +++ b/modules/gcloud/datastore.go @@ -16,25 +16,23 @@ func RunDatastoreContainer(ctx context.Context, opts ...testcontainers.Container // Deprecated: use [datastore.Run] instead // RunDatastore creates an instance of the GCloud container type for Datastore. func RunDatastore(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*GCloudContainer, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"8081/tcp"}, - WaitingFor: wait.ForHTTP("/").WithPort("8081/tcp"), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8081/tcp"), + testcontainers.WithWaitStrategy(wait.ForHTTP("/").WithPort("8081/tcp")), } - settings, err := applyOptions(&req, opts) + moduleOpts = append(moduleOpts, opts...) + + settings, err := applyOptions(opts) if err != nil { return nil, err } - req.Cmd = []string{ + moduleOpts = append(moduleOpts, testcontainers.WithCmd( "/bin/sh", "-c", - "gcloud beta emulators datastore start --host-port 0.0.0.0:8081 --project=" + settings.ProjectID, - } + "gcloud beta emulators datastore start --host-port 0.0.0.0:8081 --project="+settings.ProjectID, + )) - return newGCloudContainer(ctx, req, 8081, settings, "") + return newGCloudContainer(ctx, img, 8081, settings, "", moduleOpts...) } diff --git a/modules/gcloud/datastore/datastore.go b/modules/gcloud/datastore/datastore.go index b421925079..2c0558e7fd 100644 --- a/modules/gcloud/datastore/datastore.go +++ b/modules/gcloud/datastore/datastore.go @@ -32,43 +32,38 @@ func (c *Container) URI() string { // Run creates an instance of the Datastore GCloud container type. // The URI uses the empty string as the protocol. func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"8081/tcp"}, - WaitingFor: wait.ForAll( - wait.ForListeningPort("8081/tcp"), - wait.ForHTTP("/").WithPort("8081/tcp"), - ), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8081/tcp"), + testcontainers.WithWaitStrategy(wait.ForAll( + wait.ForListeningPort("8081/tcp"), + wait.ForHTTP("/").WithPort("8081/tcp"), + )), } + moduleOpts = append(moduleOpts, opts...) + settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { if err := apply(&settings); err != nil { - return nil, err + return nil, fmt.Errorf("datastore option: %w", err) } } - if err := opt.Customize(&req); err != nil { - return nil, err - } } - req.Cmd = []string{ + moduleOpts = append(moduleOpts, testcontainers.WithCmd( "/bin/sh", "-c", - "gcloud beta emulators datastore start --host-port 0.0.0.0:8081 --project=" + settings.ProjectID, - } + "gcloud beta emulators datastore start --host-port 0.0.0.0:8081 --project="+settings.ProjectID, + )) - container, err := testcontainers.GenericContainer(ctx, req) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container if container != nil { c = &Container{Container: container, settings: settings} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } portEndpoint, err := c.PortEndpoint(ctx, "8081/tcp", "") diff --git a/modules/gcloud/firestore.go b/modules/gcloud/firestore.go index b333a64f44..f31289ae40 100644 --- a/modules/gcloud/firestore.go +++ b/modules/gcloud/firestore.go @@ -16,25 +16,23 @@ func RunFirestoreContainer(ctx context.Context, opts ...testcontainers.Container // Deprecated: use [firestore.Run] instead // RunFirestore creates an instance of the GCloud container type for Firestore. func RunFirestore(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*GCloudContainer, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"8080/tcp"}, - WaitingFor: wait.ForLog("running"), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8080/tcp"), + testcontainers.WithWaitStrategy(wait.ForLog("running")), } - settings, err := applyOptions(&req, opts) + moduleOpts = append(moduleOpts, opts...) + + settings, err := applyOptions(opts) if err != nil { return nil, err } - req.Cmd = []string{ + moduleOpts = append(moduleOpts, testcontainers.WithCmd( "/bin/sh", "-c", - "gcloud beta emulators firestore start --host-port 0.0.0.0:8080 --project=" + settings.ProjectID, - } + "gcloud beta emulators firestore start --host-port 0.0.0.0:8080 --project="+settings.ProjectID, + )) - return newGCloudContainer(ctx, req, 8080, settings, "") + return newGCloudContainer(ctx, img, 8080, settings, "", moduleOpts...) } diff --git a/modules/gcloud/firestore/firestore.go b/modules/gcloud/firestore/firestore.go index 1f7f417626..5bf7f221f2 100644 --- a/modules/gcloud/firestore/firestore.go +++ b/modules/gcloud/firestore/firestore.go @@ -32,28 +32,21 @@ func (c *Container) URI() string { // Run creates an instance of the Firestore GCloud container type. // The URI uses the empty string as the protocol. func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"8080/tcp"}, - WaitingFor: wait.ForAll( - wait.ForListeningPort("8080/tcp"), - wait.ForLog("running"), - ), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8080/tcp"), + testcontainers.WithWaitStrategy(wait.ForAll( + wait.ForListeningPort("8080/tcp"), + wait.ForLog("running"), + )), } settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { if err := apply(&settings); err != nil { - return nil, err + return nil, fmt.Errorf("firestore option: %w", err) } } - if err := opt.Customize(&req); err != nil { - return nil, err - } } gcloudParameters := "--project=" + settings.ProjectID @@ -61,19 +54,19 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom gcloudParameters += " --database-mode=datastore-mode" } - req.Cmd = []string{ + moduleOpts = append(moduleOpts, testcontainers.WithCmd( "/bin/sh", "-c", - "gcloud beta emulators firestore start --host-port 0.0.0.0:8080 " + gcloudParameters, - } + "gcloud beta emulators firestore start --host-port 0.0.0.0:8080 "+gcloudParameters, + )) - container, err := testcontainers.GenericContainer(ctx, req) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container if container != nil { c = &Container{Container: container, settings: settings} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } portEndpoint, err := c.PortEndpoint(ctx, "8080/tcp", "") diff --git a/modules/gcloud/gcloud.go b/modules/gcloud/gcloud.go index c814b99e7b..eff7f55e7a 100644 --- a/modules/gcloud/gcloud.go +++ b/modules/gcloud/gcloud.go @@ -27,8 +27,8 @@ type GCloudContainer struct { } // newGCloudContainer creates a new GCloud container, obtaining the URL to access the container from the specified port. -func newGCloudContainer(ctx context.Context, req testcontainers.GenericContainerRequest, port int, settings options, proto string) (*GCloudContainer, error) { - container, err := testcontainers.GenericContainer(ctx, req) +func newGCloudContainer(ctx context.Context, img string, port int, settings options, proto string, opts ...testcontainers.ContainerCustomizer) (*GCloudContainer, error) { + container, err := testcontainers.Run(ctx, img, opts...) var c *GCloudContainer if container != nil { c = &GCloudContainer{Container: container, Settings: settings} @@ -97,7 +97,7 @@ func WithDataYAML(r io.Reader) Option { } // applyOptions applies the options to the container request and returns the settings. -func applyOptions(req *testcontainers.GenericContainerRequest, opts []testcontainers.ContainerCustomizer) (options, error) { +func applyOptions(opts []testcontainers.ContainerCustomizer) (options, error) { settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { @@ -105,9 +105,6 @@ func applyOptions(req *testcontainers.GenericContainerRequest, opts []testcontai return options{}, err } } - if err := opt.Customize(req); err != nil { - return options{}, err - } } return settings, nil diff --git a/modules/gcloud/pubsub.go b/modules/gcloud/pubsub.go index 2d637563dc..533b047d0f 100644 --- a/modules/gcloud/pubsub.go +++ b/modules/gcloud/pubsub.go @@ -16,25 +16,23 @@ func RunPubsubContainer(ctx context.Context, opts ...testcontainers.ContainerCus // Deprecated: use [pubsub.Run] instead // RunPubsub creates an instance of the GCloud container type for Pubsub. func RunPubsub(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*GCloudContainer, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"8085/tcp"}, - WaitingFor: wait.ForLog("started"), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8085/tcp"), + testcontainers.WithWaitStrategy(wait.ForLog("started")), } - settings, err := applyOptions(&req, opts) + moduleOpts = append(moduleOpts, opts...) + + settings, err := applyOptions(opts) if err != nil { return nil, err } - req.Cmd = []string{ + moduleOpts = append(moduleOpts, testcontainers.WithCmd( "/bin/sh", "-c", - "gcloud beta emulators pubsub start --host-port 0.0.0.0:8085 --project=" + settings.ProjectID, - } + "gcloud beta emulators pubsub start --host-port 0.0.0.0:8085 --project="+settings.ProjectID, + )) - return newGCloudContainer(ctx, req, 8085, settings, "") + return newGCloudContainer(ctx, img, 8085, settings, "", moduleOpts...) } diff --git a/modules/gcloud/pubsub/pubsub.go b/modules/gcloud/pubsub/pubsub.go index d41e55d21a..0e4885cb48 100644 --- a/modules/gcloud/pubsub/pubsub.go +++ b/modules/gcloud/pubsub/pubsub.go @@ -32,43 +32,38 @@ func (c *Container) URI() string { // Run creates an instance of the Pubsub GCloud container type. // The URI uses the empty string as the protocol. func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"8085/tcp"}, - WaitingFor: wait.ForAll( - wait.ForListeningPort("8085/tcp"), - wait.ForLog("started"), - ), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8085/tcp"), + testcontainers.WithWaitStrategy(wait.ForAll( + wait.ForListeningPort("8085/tcp"), + wait.ForLog("started"), + )), } + moduleOpts = append(moduleOpts, opts...) + settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { if err := apply(&settings); err != nil { - return nil, err + return nil, fmt.Errorf("pubsub option: %w", err) } } - if err := opt.Customize(&req); err != nil { - return nil, err - } } - req.Cmd = []string{ + moduleOpts = append(moduleOpts, testcontainers.WithCmd( "/bin/sh", "-c", - "gcloud beta emulators pubsub start --host-port 0.0.0.0:8085 --project=" + settings.ProjectID, - } + "gcloud beta emulators pubsub start --host-port 0.0.0.0:8085 --project="+settings.ProjectID, + )) - container, err := testcontainers.GenericContainer(ctx, req) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container if container != nil { c = &Container{Container: container, settings: settings} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } portEndpoint, err := c.PortEndpoint(ctx, "8085/tcp", "") diff --git a/modules/gcloud/spanner.go b/modules/gcloud/spanner.go index b5e9bb57f3..6404c258b6 100644 --- a/modules/gcloud/spanner.go +++ b/modules/gcloud/spanner.go @@ -16,19 +16,17 @@ func RunSpannerContainer(ctx context.Context, opts ...testcontainers.ContainerCu // Deprecated: use [spanner.Run] instead // RunSpanner creates an instance of the GCloud container type for Spanner. func RunSpanner(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*GCloudContainer, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"9010/tcp"}, - WaitingFor: wait.ForLog("Cloud Spanner emulator running"), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("9010/tcp"), + testcontainers.WithWaitStrategy(wait.ForLog("Cloud Spanner emulator running")), } - settings, err := applyOptions(&req, opts) + moduleOpts = append(moduleOpts, opts...) + + settings, err := applyOptions(opts) if err != nil { return nil, err } - return newGCloudContainer(ctx, req, 9010, settings, "") + return newGCloudContainer(ctx, img, 9010, settings, "", moduleOpts...) } diff --git a/modules/gcloud/spanner/spanner.go b/modules/gcloud/spanner/spanner.go index 388c6e5074..78fc8632db 100644 --- a/modules/gcloud/spanner/spanner.go +++ b/modules/gcloud/spanner/spanner.go @@ -32,37 +32,32 @@ func (c *Container) URI() string { // Run creates an instance of the Spanner GCloud container type. // The URI uses the empty string as the protocol. func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"9010/tcp"}, - WaitingFor: wait.ForAll( - wait.ForListeningPort("9010/tcp"), - wait.ForLog("Cloud Spanner emulator running"), - ), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("9010/tcp"), + testcontainers.WithWaitStrategy(wait.ForAll( + wait.ForListeningPort("9010/tcp"), + wait.ForLog("Cloud Spanner emulator running"), + )), } + moduleOpts = append(moduleOpts, opts...) + settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { if err := apply(&settings); err != nil { - return nil, err + return nil, fmt.Errorf("spanner option: %w", err) } } - if err := opt.Customize(&req); err != nil { - return nil, err - } } - container, err := testcontainers.GenericContainer(ctx, req) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container if container != nil { c = &Container{Container: container, settings: settings} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } portEndpoint, err := c.PortEndpoint(ctx, "9010/tcp", "") diff --git a/modules/grafana-lgtm/grafana.go b/modules/grafana-lgtm/grafana.go index 46a0f8d543..cb4c798d4f 100644 --- a/modules/grafana-lgtm/grafana.go +++ b/modules/grafana-lgtm/grafana.go @@ -25,29 +25,19 @@ type GrafanaLGTMContainer struct { // Run creates an instance of the Grafana LGTM container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*GrafanaLGTMContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{GrafanaPort, LokiPort, TempoPort, OtlpGrpcPort, OtlpHttpPort, PrometheusPort}, - WaitingFor: wait.ForLog(".*The OpenTelemetry collector and the Grafana LGTM stack are up and running.*\\s").AsRegexp().WithOccurrence(1), + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(GrafanaPort, LokiPort, TempoPort, OtlpGrpcPort, OtlpHttpPort, PrometheusPort), + testcontainers.WithWaitStrategy(wait.ForLog(".*The OpenTelemetry collector and the Grafana LGTM stack are up and running.*\\s").AsRegexp().WithOccurrence(1)), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) if err != nil { - return nil, fmt.Errorf("generic container: %w", err) + return nil, fmt.Errorf("run: %w", err) } - c := &GrafanaLGTMContainer{Container: container} + c := &GrafanaLGTMContainer{Container: ctr} url, err := c.OtlpHttpEndpoint(ctx) if err != nil { diff --git a/modules/inbucket/inbucket.go b/modules/inbucket/inbucket.go index 7f9049b624..33fbdbe830 100644 --- a/modules/inbucket/inbucket.go +++ b/modules/inbucket/inbucket.go @@ -36,35 +36,25 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Inbucket container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*InbucketContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"2500/tcp", "9000/tcp", "1100/tcp"}, - WaitingFor: wait.ForAll( + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("2500/tcp", "9000/tcp", "1100/tcp"), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForListeningPort("2500/tcp"), wait.ForListeningPort("9000/tcp"), wait.ForListeningPort("1100/tcp"), - ), + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *InbucketContainer - if container != nil { - c = &InbucketContainer{Container: container} + if ctr != nil { + c = &InbucketContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/influxdb/influxdb.go b/modules/influxdb/influxdb.go index 9a5856a660..9baca0351e 100644 --- a/modules/influxdb/influxdb.go +++ b/modules/influxdb/influxdb.go @@ -28,38 +28,29 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the InfluxDB container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*InfluxDbContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"8086/tcp", "8088/tcp"}, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8086/tcp", "8088/tcp"), + testcontainers.WithEnv(map[string]string{ "INFLUXDB_BIND_ADDRESS": ":8088", "INFLUXDB_HTTP_BIND_ADDRESS": ":8086", "INFLUXDB_REPORTING_DISABLED": "true", "INFLUXDB_MONITOR_STORE_ENABLED": "false", "INFLUXDB_HTTP_HTTPS_ENABLED": "false", "INFLUXDB_HTTP_AUTH_ENABLED": "false", - }, - WaitingFor: waitForHTTPHealth(), - } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, + }), + testcontainers.WithWaitStrategy(waitForHTTPHealth()), } - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *InfluxDbContainer if container != nil { c = &InfluxDbContainer{Container: container} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/k3s/k3s.go b/modules/k3s/k3s.go index c00ad14bd7..ea3db6241f 100644 --- a/modules/k3s/k3s.go +++ b/modules/k3s/k3s.go @@ -61,13 +61,14 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom return nil, err } - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{ - defaultKubeSecurePort, - defaultRancherWebhookPort, - }, - HostConfigModifier: func(hc *container.HostConfig) { + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithCmd("server", "--disable=traefik", "--tls-san="+host), // Host which will be used to access the Kubernetes server from tests. + testcontainers.WithExposedPorts(defaultKubeSecurePort, defaultRancherWebhookPort), + testcontainers.WithEnv(map[string]string{ + "K3S_KUBECONFIG_MODE": "644", + }), + testcontainers.WithWaitStrategy(wait.ForLog(".*Node controller sync successful.*").AsRegexp()), + testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) { hc.Privileged = true hc.CgroupnsMode = "host" hc.Tmpfs = map[string]string{ @@ -75,37 +76,19 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom "/var/run": "", } hc.Mounts = []mount.Mount{} - }, - Cmd: []string{ - "server", - "--disable=traefik", - "--tls-san=" + host, // Host which will be used to access the Kubernetes server from tests. - }, - Env: map[string]string{ - "K3S_KUBECONFIG_MODE": "644", - }, - WaitingFor: wait.ForLog(".*Node controller sync successful.*").AsRegexp(), - } - - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, + }), } - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *K3sContainer if container != nil { c = &K3sContainer{Container: container} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/k6/examples_test.go b/modules/k6/examples_test.go index c842814d4c..573cf59a9d 100644 --- a/modules/k6/examples_test.go +++ b/modules/k6/examples_test.go @@ -17,18 +17,12 @@ func ExampleRun() { // create a container with the httpbin application that will be the target // for the test script that runs in the k6 container - gcr := testcontainers.GenericContainerRequest{ - ProviderType: testcontainers.ProviderDocker, - ContainerRequest: testcontainers.ContainerRequest{ - Image: "kennethreitz/httpbin", - ExposedPorts: []string{ - "80", - }, - WaitingFor: wait.ForExposedPort(), - }, - Started: true, + gcrOptions := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("80"), + testcontainers.WithWaitStrategy(wait.ForExposedPort()), } - httpbin, err := testcontainers.GenericContainer(ctx, gcr) + + httpbin, err := testcontainers.Run(ctx, "kennethreitz/httpbin", gcrOptions...) defer func() { if err := testcontainers.TerminateContainer(httpbin); err != nil { log.Printf("failed to terminate container: %s", err) diff --git a/modules/k6/k6.go b/modules/k6/k6.go index 7e9aef38e4..e1dc3cd863 100644 --- a/modules/k6/k6.go +++ b/modules/k6/k6.go @@ -168,31 +168,21 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the K6 container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*K6Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - Cmd: []string{"run"}, - WaitingFor: wait.ForExit(), + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithCmd("run"), + testcontainers.WithWaitStrategy(wait.ForExit()), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *K6Container if container != nil { c = &K6Container{Container: container} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/kafka/kafka.go b/modules/kafka/kafka.go index b1342de98f..eb06108022 100644 --- a/modules/kafka/kafka.go +++ b/modules/kafka/kafka.go @@ -46,10 +46,12 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Kafka container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*KafkaContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{string(publicPort)}, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithEntrypoint("sh"), + // this CMD will wait for the starter script to be copied into the container and then execute it + testcontainers.WithCmd("-c", "while [ ! -f "+starterScript+" ]; do sleep 0.1; done; bash "+starterScript), + testcontainers.WithExposedPorts(string(publicPort)), + testcontainers.WithEnv(map[string]string{ // envVars { "KAFKA_LISTENERS": "PLAINTEXT://0.0.0.0:9093,BROKER://0.0.0.0:9092,CONTROLLER://0.0.0.0:9094", "KAFKA_REST_BOOTSTRAP_SERVERS": "PLAINTEXT://0.0.0.0:9093,BROKER://0.0.0.0:9092,CONTROLLER://0.0.0.0:9094", @@ -66,56 +68,52 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom "KAFKA_PROCESS_ROLES": "broker,controller", "KAFKA_CONTROLLER_LISTENER_NAMES": "CONTROLLER", // } - }, - Entrypoint: []string{"sh"}, - // this CMD will wait for the starter script to be copied into the container and then execute it - Cmd: []string{"-c", "while [ ! -f " + starterScript + " ]; do sleep 0.1; done; bash " + starterScript}, - LifecycleHooks: []testcontainers.ContainerLifecycleHooks{ - { - PostStarts: []testcontainers.ContainerHook{ - // Use a single hook to copy the starter script and wait for - // the Kafka server to be ready. This prevents the wait running - // if the starter script fails to copy. - func(ctx context.Context, c testcontainers.Container) error { - // 1. copy the starter script into the container - if err := copyStarterScript(ctx, c); err != nil { - return fmt.Errorf("copy starter script: %w", err) - } - - // 2. wait for the Kafka server to be ready - return wait.ForLog(".*Transitioning from RECOVERY to RUNNING.*").AsRegexp().WaitUntilReady(ctx, c) - }, + }), + testcontainers.WithAdditionalLifecycleHooks(testcontainers.ContainerLifecycleHooks{ + PostStarts: []testcontainers.ContainerHook{ + // Use a single hook to copy the starter script and wait for + // the Kafka server to be ready. This prevents the wait running + // if the starter script fails to copy. + func(ctx context.Context, c testcontainers.Container) error { + // 1. copy the starter script into the container + if err := copyStarterScript(ctx, c); err != nil { + return fmt.Errorf("copy starter script: %w", err) + } + + // 2. wait for the Kafka server to be ready + return wait.ForLog(".*Transitioning from RECOVERY to RUNNING.*").AsRegexp().WaitUntilReady(ctx, c) }, }, - }, + }), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) + defaultOptions := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err + if o, ok := opt.(Option); ok { + if err := o(&defaultOptions); err != nil { + return nil, fmt.Errorf("kafka option: %w", err) + } } } - err := validateKRaftVersion(genericContainerReq.Image) + err := validateKRaftVersion(img) if err != nil { return nil, err } - configureControllerQuorumVoters(&genericContainerReq) + // configure the controller quorum voters the last option, as it depends on the network aliases + moduleOpts = append(moduleOpts, configureControllerQuorumVoters()) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *KafkaContainer if container != nil { - c = &KafkaContainer{Container: container, ClusterID: genericContainerReq.Env["CLUSTER_ID"]} + c = &KafkaContainer{Container: container, ClusterID: defaultOptions.env["CLUSTER_ID"]} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil @@ -149,14 +147,6 @@ func copyStarterScript(ctx context.Context, c testcontainers.Container) error { return nil } -func WithClusterID(clusterID string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["CLUSTER_ID"] = clusterID - - return nil - } -} - // Brokers retrieves the broker connection strings from Kafka with only one entry, // defined by the exposed public port. func (kc *KafkaContainer) Brokers(ctx context.Context) ([]string, error) { @@ -168,28 +158,6 @@ func (kc *KafkaContainer) Brokers(ctx context.Context) ([]string, error) { return []string{endpoint}, nil } -// configureControllerQuorumVoters sets the quorum voters for the controller. For that, it will -// check if there are any network aliases defined for the container and use the first alias in the -// first network. Else, it will use localhost. -func configureControllerQuorumVoters(req *testcontainers.GenericContainerRequest) { - if req.Env == nil { - req.Env = map[string]string{} - } - - if req.Env["KAFKA_CONTROLLER_QUORUM_VOTERS"] == "" { - host := "localhost" - if len(req.Networks) > 0 { - nw := req.Networks[0] - if len(req.NetworkAliases[nw]) > 0 { - host = req.NetworkAliases[nw][0] - } - } - - req.Env["KAFKA_CONTROLLER_QUORUM_VOTERS"] = fmt.Sprintf("1@%s:9094", host) - } - // } -} - // validateKRaftVersion validates if the image version is compatible with KRaft mode, // which is available since version 7.0.0. func validateKRaftVersion(fqName string) error { diff --git a/modules/kafka/kafka_helpers_test.go b/modules/kafka/kafka_helpers_test.go index 6ef7deb60f..d828f6939d 100644 --- a/modules/kafka/kafka_helpers_test.go +++ b/modules/kafka/kafka_helpers_test.go @@ -55,7 +55,8 @@ func TestConfigureQuorumVoters(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - configureControllerQuorumVoters(test.req) + err := configureControllerQuorumVoters()(test.req) + require.NoError(t, err) require.Equalf(t, test.expectedVoters, test.req.Env["KAFKA_CONTROLLER_QUORUM_VOTERS"], "expected KAFKA_CONTROLLER_QUORUM_VOTERS to be %s, got %s", test.expectedVoters, test.req.Env["KAFKA_CONTROLLER_QUORUM_VOTERS"]) }) diff --git a/modules/kafka/options.go b/modules/kafka/options.go new file mode 100644 index 0000000000..e3d07df495 --- /dev/null +++ b/modules/kafka/options.go @@ -0,0 +1,62 @@ +package kafka + +import ( + "fmt" + + "github.com/testcontainers/testcontainers-go" +) + +type options struct { + env map[string]string +} + +func defaultOptions() options { + return options{ + env: map[string]string{}, + } +} + +// Satisfy the testcontainers.CustomizeRequestOption interface +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the Kafka container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + +func WithClusterID(clusterID string) Option { + return func(o *options) error { + o.env["CLUSTER_ID"] = clusterID + + return nil + } +} + +// configureControllerQuorumVoters sets the quorum voters for the controller. For that, it will +// check if there are any network aliases defined for the container and use the first alias in the +// first network. Else, it will use localhost. +func configureControllerQuorumVoters() testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + if req.Env == nil { + req.Env = map[string]string{} + } + + if req.Env["KAFKA_CONTROLLER_QUORUM_VOTERS"] == "" { + host := "localhost" + if len(req.Networks) > 0 { + nw := req.Networks[0] + if len(req.NetworkAliases[nw]) > 0 { + host = req.NetworkAliases[nw][0] + } + } + + req.Env["KAFKA_CONTROLLER_QUORUM_VOTERS"] = fmt.Sprintf("1@%s:9094", host) + } + + return nil + } +} diff --git a/modules/localstack/localstack.go b/modules/localstack/localstack.go index 30ddf087df..c0387018af 100644 --- a/modules/localstack/localstack.go +++ b/modules/localstack/localstack.go @@ -66,53 +66,38 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*LocalStackContainer, error) { dockerHost := testcontainers.MustExtractDockerSocket(ctx) - req := testcontainers.ContainerRequest{ - Image: img, - WaitingFor: wait.ForHTTP("/_localstack/health").WithPort("4566/tcp").WithStartupTimeout(120 * time.Second), - ExposedPorts: []string{fmt.Sprintf("%d/tcp", defaultPort)}, - Env: map[string]string{}, - HostConfigModifier: func(hostConfig *container.HostConfig) { - hostConfig.Binds = []string{dockerHost + ":/var/run/docker.sock"} - }, - } + logger := log.Default() - localStackReq := LocalStackContainerRequest{ - GenericContainerRequest: testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Logger: log.Default(), - Started: true, - }, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(fmt.Sprintf("%d/tcp", defaultPort)), + testcontainers.WithHostConfigModifier(func(hostConfig *container.HostConfig) { + hostConfig.Binds = []string{dockerHost + ":/var/run/docker.sock"} + }), + testcontainers.WithWaitStrategy(wait.ForHTTP("/_localstack/health").WithPort("4566/tcp").WithStartupTimeout(120 * time.Second)), + testcontainers.WithLogger(logger), } - for _, opt := range opts { - if err := opt.Customize(&localStackReq.GenericContainerRequest); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - if !isMinimumVersion(localStackReq.Image, "v0.11") { - return nil, fmt.Errorf("version=%s. Testcontainers for Go does not support running LocalStack in legacy mode. Please use a version >= 0.11.0", localStackReq.Image) + if !isMinimumVersion(img, "v0.11") { + return nil, fmt.Errorf("version=%s. Testcontainers for Go does not support running LocalStack in legacy mode. Please use a version >= 0.11.0", img) } envVar := hostnameExternalEnvVar - if isMinimumVersion(localStackReq.Image, "v2") { + if isMinimumVersion(img, "v2") { envVar = localstackHostEnvVar } - hostnameExternalReason, err := configureDockerHost(&localStackReq, envVar) - if err != nil { - return nil, err - } - localStackReq.Logger.Printf("Setting %s to %s (%s)\n", envVar, req.Env[envVar], hostnameExternalReason) + moduleOpts = append(moduleOpts, configureDockerHost(logger, envVar)) - container, err := testcontainers.GenericContainer(ctx, localStackReq.GenericContainerRequest) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *LocalStackContainer - if container != nil { - c = &LocalStackContainer{Container: container} + if ctr != nil { + c = &LocalStackContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil @@ -125,33 +110,36 @@ func StartContainer(ctx context.Context, overrideReq OverrideContainerRequestOpt return RunContainer(ctx, overrideReq) } -func configureDockerHost(req *LocalStackContainerRequest, envVar string) (string, error) { - reason := "" +func configureDockerHost(logger log.Logger, envVar string) testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + if _, ok := req.Env[envVar]; ok { + logger.Printf("Setting %s to %s (explicitly as environment variable)\n", envVar, req.Env[envVar]) + return nil + } - if _, ok := req.Env[envVar]; ok { - return "explicitly as environment variable", nil - } + // if the container is not connected to the default network, use the last network alias in the first network + // for that we need to check if the container is connected to a network and if it has network aliases + if len(req.Networks) > 0 && len(req.NetworkAliases) > 0 && len(req.NetworkAliases[req.Networks[0]]) > 0 { + alias := req.NetworkAliases[req.Networks[0]][len(req.NetworkAliases[req.Networks[0]])-1] - // if the container is not connected to the default network, use the last network alias in the first network - // for that we need to check if the container is connected to a network and if it has network aliases - if len(req.Networks) > 0 && len(req.NetworkAliases) > 0 && len(req.NetworkAliases[req.Networks[0]]) > 0 { - alias := req.NetworkAliases[req.Networks[0]][len(req.NetworkAliases[req.Networks[0]])-1] + req.Env[envVar] = alias + logger.Printf("Setting %s to %s (to match last network alias on container with non-default network)\n", envVar, req.Env[envVar]) + return nil + } - req.Env[envVar] = alias - return "to match last network alias on container with non-default network", nil - } + dockerProvider, err := testcontainers.NewDockerProvider() + if err != nil { + return err + } + defer dockerProvider.Close() - dockerProvider, err := testcontainers.NewDockerProvider() - if err != nil { - return reason, err - } - defer dockerProvider.Close() + daemonHost, err := dockerProvider.DaemonHost(context.Background()) + if err != nil { + return err + } - daemonHost, err := dockerProvider.DaemonHost(context.Background()) - if err != nil { - return reason, err + req.Env[envVar] = daemonHost + logger.Printf("Setting %s to %s (to match host-routable address for container)\n", envVar, req.Env[envVar]) + return nil } - - req.Env[envVar] = daemonHost - return "to match host-routable address for container", nil } diff --git a/modules/localstack/localstack_test.go b/modules/localstack/localstack_test.go index d371a9d60f..ce51eeb8bf 100644 --- a/modules/localstack/localstack_test.go +++ b/modules/localstack/localstack_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/log" "github.com/testcontainers/testcontainers-go/network" "github.com/testcontainers/testcontainers-go/wait" ) @@ -34,14 +35,16 @@ func TestConfigureDockerHost(t *testing.T) { } for _, tt := range tests { + logger := log.TestLogger(t) + t.Run("HOSTNAME_EXTERNAL variable is passed as part of the request", func(t *testing.T) { req := generateContainerRequest() req.Env[tt.envVar] = "foo" - reason, err := configureDockerHost(req, tt.envVar) + err := configureDockerHost(logger, tt.envVar)(&req.GenericContainerRequest) require.NoError(t, err) - require.Equal(t, "explicitly as environment variable", reason) + require.Equal(t, "foo", req.Env[tt.envVar]) }) t.Run("HOSTNAME_EXTERNAL matches the last network alias on a container with non-default network", func(t *testing.T) { @@ -54,9 +57,8 @@ func TestConfigureDockerHost(t *testing.T) { "baaz": {"baaz0", "baaz1", "baaz2", "baaz3"}, } - reason, err := configureDockerHost(req, tt.envVar) + err := configureDockerHost(logger, tt.envVar)(&req.GenericContainerRequest) require.NoError(t, err) - require.Equal(t, "to match last network alias on container with non-default network", reason) require.Equal(t, "foo3", req.Env[tt.envVar]) }) @@ -74,9 +76,8 @@ func TestConfigureDockerHost(t *testing.T) { req.Networks = []string{"foo", "bar", "baaz"} req.NetworkAliases = map[string][]string{} - reason, err := configureDockerHost(req, tt.envVar) + err = configureDockerHost(logger, tt.envVar)(&req.GenericContainerRequest) require.NoError(t, err) - require.Equal(t, "to match host-routable address for container", reason) require.Equal(t, expectedDaemonHost, req.Env[tt.envVar]) }) } @@ -225,35 +226,31 @@ func TestStartV2WithNetwork(t *testing.T) { require.NoError(t, err) require.NotNil(t, localstack) - networkName := nw.Name - - cli, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "amazon/aws-cli:2.7.27", - Networks: []string{networkName}, - Entrypoint: []string{"tail"}, - Cmd: []string{"-f", "/dev/null"}, - Env: map[string]string{ - "AWS_ACCESS_KEY_ID": "accesskey", - "AWS_SECRET_ACCESS_KEY": "secretkey", - "AWS_REGION": "eu-west-1", - }, - WaitingFor: wait.ForExec([]string{ - "/usr/local/bin/aws", "sqs", "create-queue", "--queue-name", "baz", "--region", "eu-west-1", - "--endpoint-url", "http://localstack:4566", "--no-verify-ssl", + cliOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithEntrypoint("tail"), + testcontainers.WithCmd("-f", "/dev/null"), + testcontainers.WithEnv(map[string]string{ + "AWS_ACCESS_KEY_ID": "accesskey", + "AWS_SECRET_ACCESS_KEY": "secretkey", + "AWS_REGION": "eu-west-1", + }), + network.WithNetwork([]string{"cli"}, nw), + testcontainers.WithWaitStrategy(wait.ForExec([]string{ + "/usr/local/bin/aws", "sqs", "create-queue", "--queue-name", "baz", "--region", "eu-west-1", + "--endpoint-url", "http://localstack:4566", "--no-verify-ssl", + }). + WithStartupTimeout(time.Second * 10). + WithExitCodeMatcher(func(exitCode int) bool { + return exitCode == 0 }). - WithStartupTimeout(time.Second * 10). - WithExitCodeMatcher(func(exitCode int) bool { - return exitCode == 0 - }). - WithResponseMatcher(func(r io.Reader) bool { - respBytes, _ := io.ReadAll(r) - resp := string(respBytes) - return strings.Contains(resp, "http://localstack:4566") - }), - }, - Started: true, - }) + WithResponseMatcher(func(r io.Reader) bool { + respBytes, _ := io.ReadAll(r) + resp := string(respBytes) + return strings.Contains(resp, "http://localstack:4566") + })), + } + + cli, err := testcontainers.Run(ctx, "amazon/aws-cli:2.7.27", cliOpts...) testcontainers.CleanupContainer(t, cli) require.NoError(t, err) require.NotNil(t, cli) diff --git a/modules/mariadb/mariadb.go b/modules/mariadb/mariadb.go index ab1437e94d..b10feca159 100644 --- a/modules/mariadb/mariadb.go +++ b/modules/mariadb/mariadb.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "path/filepath" "strings" "github.com/testcontainers/testcontainers-go" @@ -26,100 +25,6 @@ type MariaDBContainer struct { database string } -// WithDefaultCredentials applies the default credentials to the container request. -// It will look up for MARIADB environment variables. -func WithDefaultCredentials() testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - username := req.Env["MARIADB_USER"] - password := req.Env["MARIADB_PASSWORD"] - if strings.EqualFold(rootUser, username) { - delete(req.Env, "MARIADB_USER") - } - - if len(password) != 0 && password != "" { - req.Env["MARIADB_ROOT_PASSWORD"] = password - } else if strings.EqualFold(rootUser, username) { - req.Env["MARIADB_ALLOW_EMPTY_ROOT_PASSWORD"] = "yes" - delete(req.Env, "MARIADB_PASSWORD") - } - - return nil - } -} - -// https://github.com/docker-library/docs/tree/master/mariadb#environment-variables -// From tag 10.2.38, 10.3.29, 10.4.19, 10.5.10 onwards, and all 10.6 and later tags, -// the MARIADB_* equivalent variables are provided. MARIADB_* variants will always be -// used in preference to MYSQL_* variants. -func withMySQLEnvVars() testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - // look up for MARIADB environment variables and apply the same to MYSQL - for k, v := range req.Env { - if strings.HasPrefix(k, "MARIADB_") { - // apply the same value to the MYSQL environment variables - mysqlEnvVar := strings.ReplaceAll(k, "MARIADB_", "MYSQL_") - req.Env[mysqlEnvVar] = v - } - } - - return nil - } -} - -func WithUsername(username string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["MARIADB_USER"] = username - - return nil - } -} - -func WithPassword(password string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["MARIADB_PASSWORD"] = password - - return nil - } -} - -func WithDatabase(database string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["MARIADB_DATABASE"] = database - - return nil - } -} - -func WithConfigFile(configFile string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - cf := testcontainers.ContainerFile{ - HostFilePath: configFile, - ContainerFilePath: "/etc/mysql/conf.d/my.cnf", - FileMode: 0o755, - } - req.Files = append(req.Files, cf) - - return nil - } -} - -func WithScripts(scripts ...string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - var initScripts []testcontainers.ContainerFile - for _, script := range scripts { - cf := testcontainers.ContainerFile{ - HostFilePath: script, - ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base(script), - FileMode: 0o755, - } - initScripts = append(initScripts, cf) - } - req.Files = append(req.Files, initScripts...) - - return nil - } -} - // Deprecated: use Run instead // RunContainer creates an instance of the MariaDB container type func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*MariaDBContainer, error) { @@ -128,60 +33,59 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the MariaDB container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*MariaDBContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"3306/tcp", "33060/tcp"}, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("3306/tcp", "33060/tcp"), + testcontainers.WithEnv(map[string]string{ "MARIADB_USER": defaultUser, "MARIADB_PASSWORD": defaultPassword, "MARIADB_DATABASE": defaultDatabaseName, - }, - WaitingFor: wait.ForLog("port: 3306 mariadb.org binary distribution"), - } - - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, + }), + testcontainers.WithWaitStrategy(wait.ForLog("port: 3306 mariadb.org binary distribution")), } opts = append(opts, WithDefaultCredentials()) + moduleOpts = append(moduleOpts, opts...) + + defaultOptions := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err + if o, ok := opt.(Option); ok { + if err := o(&defaultOptions); err != nil { + return nil, fmt.Errorf("mariadb option: %w", err) + } } } // Apply MySQL environment variables after user customization // In future releases of MariaDB, they could remove the MYSQL_* environment variables // at all. Then we can remove this customization. - if err := withMySQLEnvVars().Customize(&genericContainerReq); err != nil { - return nil, err - } + moduleOpts = append(moduleOpts, withMySQLEnvVars()) - username, ok := req.Env["MARIADB_USER"] + username, ok := defaultOptions.env["MARIADB_USER"] if !ok { username = rootUser } - password := req.Env["MARIADB_PASSWORD"] + password := defaultOptions.env["MARIADB_PASSWORD"] if len(password) == 0 && password == "" && !strings.EqualFold(rootUser, username) { return nil, errors.New("empty password can be used only with the root user") } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + moduleOpts = append(moduleOpts, testcontainers.WithEnv(defaultOptions.env)) + + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *MariaDBContainer - if container != nil { + if ctr != nil { c = &MariaDBContainer{ - Container: container, + Container: ctr, username: username, password: password, - database: req.Env["MARIADB_DATABASE"], + database: defaultOptions.env["MARIADB_DATABASE"], } } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/mariadb/options.go b/modules/mariadb/options.go new file mode 100644 index 0000000000..68537b9913 --- /dev/null +++ b/modules/mariadb/options.go @@ -0,0 +1,120 @@ +package mariadb + +import ( + "path/filepath" + "strings" + + "github.com/testcontainers/testcontainers-go" +) + +type options struct { + env map[string]string +} + +func defaultOptions() options { + return options{ + env: map[string]string{ + "MARIADB_USER": defaultUser, + "MARIADB_PASSWORD": defaultPassword, + "MARIADB_DATABASE": defaultDatabaseName, + }, + } +} + +// Satisfy the testcontainers.CustomizeRequestOption interface +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the MariaDB container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + +// WithDefaultCredentials applies the default credentials to the container request. +// It will look up for MARIADB environment variables. +func WithDefaultCredentials() Option { + return func(o *options) error { + username := o.env["MARIADB_USER"] + password := o.env["MARIADB_PASSWORD"] + if strings.EqualFold(rootUser, username) { + delete(o.env, "MARIADB_USER") + } + + if len(password) != 0 && password != "" { + o.env["MARIADB_ROOT_PASSWORD"] = password + } else if strings.EqualFold(rootUser, username) { + o.env["MARIADB_ALLOW_EMPTY_ROOT_PASSWORD"] = "yes" + delete(o.env, "MARIADB_PASSWORD") + } + + return nil + } +} + +// https://github.com/docker-library/docs/tree/master/mariadb#environment-variables +// From tag 10.2.38, 10.3.29, 10.4.19, 10.5.10 onwards, and all 10.6 and later tags, +// the MARIADB_* equivalent variables are provided. MARIADB_* variants will always be +// used in preference to MYSQL_* variants. +func withMySQLEnvVars() Option { + return func(o *options) error { + // look up for MARIADB environment variables and apply the same to MYSQL + for k, v := range o.env { + if strings.HasPrefix(k, "MARIADB_") { + // apply the same value to the MYSQL environment variables + mysqlEnvVar := strings.ReplaceAll(k, "MARIADB_", "MYSQL_") + o.env[mysqlEnvVar] = v + } + } + + return nil + } +} + +func WithUsername(username string) Option { + return func(o *options) error { + o.env["MARIADB_USER"] = username + + return nil + } +} + +func WithPassword(password string) Option { + return func(o *options) error { + o.env["MARIADB_PASSWORD"] = password + + return nil + } +} + +func WithDatabase(database string) Option { + return func(o *options) error { + o.env["MARIADB_DATABASE"] = database + + return nil + } +} + +func WithConfigFile(configFile string) testcontainers.CustomizeRequestOption { + return testcontainers.WithFiles(testcontainers.ContainerFile{ + HostFilePath: configFile, + ContainerFilePath: "/etc/mysql/conf.d/my.cnf", + FileMode: 0o755, + }) +} + +func WithScripts(scripts ...string) testcontainers.CustomizeRequestOption { + var initScripts []testcontainers.ContainerFile + for _, script := range scripts { + cf := testcontainers.ContainerFile{ + HostFilePath: script, + ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base(script), + FileMode: 0o755, + } + initScripts = append(initScripts, cf) + } + + return testcontainers.WithFiles(initScripts...) +} diff --git a/modules/meilisearch/meilisearch.go b/modules/meilisearch/meilisearch.go index 10c1632fed..7f89738032 100644 --- a/modules/meilisearch/meilisearch.go +++ b/modules/meilisearch/meilisearch.go @@ -31,47 +31,41 @@ func (c *MeilisearchContainer) MasterKey() string { // Run creates an instance of the Meilisearch container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*MeilisearchContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{defaultHTTPPort}, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(defaultHTTPPort), + testcontainers.WithEnv(map[string]string{ masterKeyEnvVar: defaultMasterKey, - }, + }), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) // Gather all config options (defaults and then apply provided options) settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { - apply(settings) - } - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) + if err := apply(settings); err != nil { + return nil, fmt.Errorf("meilisearch option: %w", err) + } } } if settings.DumpDataFilePath != "" { - genericContainerReq.Files = []testcontainers.ContainerFile{ - { - HostFilePath: settings.DumpDataFilePath, - ContainerFilePath: "/dumps/" + settings.DumpDataFileName, - FileMode: 0o755, - }, - } - genericContainerReq.Cmd = []string{"meilisearch", "--import-dump", "/dumps/" + settings.DumpDataFileName} + moduleOpts = append(moduleOpts, testcontainers.WithFiles(testcontainers.ContainerFile{ + HostFilePath: settings.DumpDataFilePath, + ContainerFilePath: "/dumps/" + settings.DumpDataFileName, + FileMode: 0o755, + })) + + moduleOpts = append(moduleOpts, testcontainers.WithCmd("meilisearch", "--import-dump", "/dumps/"+settings.DumpDataFileName)) } // the wait strategy does not support TLS at the moment, // so we need to disable it in the strategy for now. - genericContainerReq.WaitingFor = wait.ForHTTP("/health"). + moduleOpts = append(moduleOpts, testcontainers.WithWaitStrategy(wait.ForHTTP("/health"). WithPort(defaultHTTPPort). WithTLS(false). - WithStartupTimeout(120 * time.Second). + WithStartupTimeout(120*time.Second). WithStatusCodeMatcher(func(status int) bool { return status == http.StatusOK }). @@ -85,16 +79,18 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom } return r.Status == "available" - }) + }))) + + moduleOpts = append(moduleOpts, testcontainers.WithEnv(settings.env)) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *MeilisearchContainer - if container != nil { - c = &MeilisearchContainer{Container: container, masterKey: req.Env[masterKeyEnvVar]} + if ctr != nil { + c = &MeilisearchContainer{Container: ctr, masterKey: settings.env[masterKeyEnvVar]} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/meilisearch/options.go b/modules/meilisearch/options.go index 06df8b4435..2914a1964d 100644 --- a/modules/meilisearch/options.go +++ b/modules/meilisearch/options.go @@ -8,19 +8,24 @@ import ( // Options is a struct for specifying options for the Meilisearch container. type Options struct { + env map[string]string DumpDataFilePath string DumpDataFileName string } func defaultOptions() *Options { - return &Options{} + return &Options{ + env: map[string]string{ + masterKeyEnvVar: defaultMasterKey, + }, + } } // Compiler check to ensure that Option implements the testcontainers.ContainerCustomizer interface. var _ testcontainers.ContainerCustomizer = (*Option)(nil) // Option is an option for the Meilisearch container. -type Option func(*Options) +type Option func(*Options) error // Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. func (o Option) Customize(*testcontainers.GenericContainerRequest) error { @@ -31,16 +36,17 @@ func (o Option) Customize(*testcontainers.GenericContainerRequest) error { // WithDumpImport sets the data dump file path for the Meilisearch container. // dumpFilePath either relative to where you call meilisearch run or absolute path func WithDumpImport(dumpFilePath string) Option { - return func(o *Options) { + return func(o *Options) error { o.DumpDataFilePath, o.DumpDataFileName = dumpFilePath, filepath.Base(dumpFilePath) + return nil } } // WithMasterKey sets the master key for the Meilisearch container // it satisfies the testcontainers.ContainerCustomizer interface -func WithMasterKey(masterKey string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["MEILI_MASTER_KEY"] = masterKey +func WithMasterKey(masterKey string) Option { + return func(o *Options) error { + o.env[masterKeyEnvVar] = masterKey return nil } } diff --git a/modules/memcached/memcached.go b/modules/memcached/memcached.go index acc3b480b0..51bef23987 100644 --- a/modules/memcached/memcached.go +++ b/modules/memcached/memcached.go @@ -19,31 +19,21 @@ type Container struct { // Run creates an instance of the Memcached container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{defaultPort}, - WaitingFor: wait.ForListeningPort(defaultPort), + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(defaultPort), + testcontainers.WithWaitStrategy(wait.ForListeningPort(defaultPort)), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container - if container != nil { - c = &Container{Container: container} + if ctr != nil { + c = &Container{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/milvus/milvus.go b/modules/milvus/milvus.go index 79e886ee86..73b128a404 100644 --- a/modules/milvus/milvus.go +++ b/modules/milvus/milvus.go @@ -46,44 +46,35 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom return nil, fmt.Errorf("render config: %w", err) } - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"19530/tcp", "9091/tcp", "2379/tcp"}, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithCmd("milvus", "run", "standalone"), + testcontainers.WithExposedPorts("19530/tcp", "9091/tcp", "2379/tcp"), + testcontainers.WithEnv(map[string]string{ "ETCD_USE_EMBED": "true", "ETCD_DATA_DIR": "/var/lib/milvus/etcd", "ETCD_CONFIG_PATH": embedEtcdContainerPath, "COMMON_STORAGETYPE": "local", - }, - Cmd: []string{"milvus", "run", "standalone"}, - WaitingFor: wait.ForHTTP("/healthz"). + }), + testcontainers.WithFiles(testcontainers.ContainerFile{ + ContainerFilePath: embedEtcdContainerPath, + Reader: config, + }), + testcontainers.WithWaitStrategy(wait.ForHTTP("/healthz"). WithPort("9091"). WithStartupTimeout(time.Minute). - WithPollInterval(time.Second), - Files: []testcontainers.ContainerFile{ - {ContainerFilePath: embedEtcdContainerPath, Reader: config}, - }, + WithPollInterval(time.Second)), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *MilvusContainer - if container != nil { - c = &MilvusContainer{Container: container} + if ctr != nil { + c = &MilvusContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/minio/minio.go b/modules/minio/minio.go index 4805716caf..d06f2d3b06 100644 --- a/modules/minio/minio.go +++ b/modules/minio/minio.go @@ -21,28 +21,6 @@ type MinioContainer struct { Password string } -// WithUsername sets the initial username to be created when the container starts -// It is used in conjunction with WithPassword to set a user and its password. -// It will create the specified user. It must not be empty or undefined. -func WithUsername(username string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["MINIO_ROOT_USER"] = username - - return nil - } -} - -// WithPassword sets the initial password of the user to be created when the container starts -// It is required for you to use the Minio image. It must not be empty or undefined. -// This environment variable sets the root user password for Minio. -func WithPassword(password string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["MINIO_ROOT_PASSWORD"] = password - - return nil - } -} - // ConnectionString returns the connection string for the minio container, using the default 9000 port, and // obtaining the host and exposed port from the container. func (c *MinioContainer) ConnectionString(ctx context.Context) (string, error) { @@ -57,42 +35,43 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Minio container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*MinioContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"9000/tcp"}, - WaitingFor: wait.ForHTTP("/minio/health/live").WithPort("9000"), - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithCmd("server", "/data"), + testcontainers.WithExposedPorts("9000/tcp"), + testcontainers.WithEnv(map[string]string{ "MINIO_ROOT_USER": defaultUser, "MINIO_ROOT_PASSWORD": defaultPassword, - }, - Cmd: []string{"server", "/data"}, + }), + testcontainers.WithWaitStrategy(wait.ForHTTP("/minio/health/live").WithPort("9000")), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) + defaultOptions := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err + if o, ok := opt.(Option); ok { + if err := o(&defaultOptions); err != nil { + return nil, fmt.Errorf("minio option: %w", err) + } } } - username := req.Env["MINIO_ROOT_USER"] - password := req.Env["MINIO_ROOT_PASSWORD"] + username := defaultOptions.env["MINIO_ROOT_USER"] + password := defaultOptions.env["MINIO_ROOT_PASSWORD"] if username == "" || password == "" { return nil, errors.New("username or password has not been set") } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + moduleOpts = append(moduleOpts, testcontainers.WithEnv(defaultOptions.env)) + + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *MinioContainer - if container != nil { - c = &MinioContainer{Container: container, Username: username, Password: password} + if ctr != nil { + c = &MinioContainer{Container: ctr, Username: username, Password: password} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/minio/options.go b/modules/minio/options.go new file mode 100644 index 0000000000..33765766bb --- /dev/null +++ b/modules/minio/options.go @@ -0,0 +1,52 @@ +package minio + +import ( + "github.com/testcontainers/testcontainers-go" +) + +type options struct { + env map[string]string +} + +func defaultOptions() options { + return options{ + env: map[string]string{ + "MINIO_ROOT_USER": defaultUser, + "MINIO_ROOT_PASSWORD": defaultPassword, + }, + } +} + +// Satisfy the testcontainers.CustomizeRequestOption interface +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the Minio container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + +// WithUsername sets the initial username to be created when the container starts +// It is used in conjunction with WithPassword to set a user and its password. +// It will create the specified user. It must not be empty or undefined. +func WithUsername(username string) Option { + return func(o *options) error { + o.env["MINIO_ROOT_USER"] = username + + return nil + } +} + +// WithPassword sets the initial password of the user to be created when the container starts +// It is required for you to use the Minio image. It must not be empty or undefined. +// This environment variable sets the root user password for Minio. +func WithPassword(password string) Option { + return func(o *options) error { + o.env["MINIO_ROOT_PASSWORD"] = password + + return nil + } +} diff --git a/modules/mockserver/mockserver.go b/modules/mockserver/mockserver.go index 022095540c..e8ba983653 100644 --- a/modules/mockserver/mockserver.go +++ b/modules/mockserver/mockserver.go @@ -21,34 +21,23 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the MockServer container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*MockServerContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"1080/tcp"}, - WaitingFor: wait.ForAll( + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("1080/tcp"), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForLog("started on port: 1080"), wait.ForListeningPort("1080/tcp").SkipInternalCheck(), - ), - Env: map[string]string{}, + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *MockServerContainer - if container != nil { - c = &MockServerContainer{Container: container} + if ctr != nil { + c = &MockServerContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/mongodb/mongodb.go b/modules/mongodb/mongodb.go index 76a3c57125..3dd1e480e1 100644 --- a/modules/mongodb/mongodb.go +++ b/modules/mongodb/mongodb.go @@ -38,83 +38,55 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the MongoDB container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*MongoDBContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"27017/tcp"}, - WaitingFor: wait.ForAll( + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("27017/tcp"), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForLog("Waiting for connections"), wait.ForListeningPort("27017/tcp"), - ), - Env: map[string]string{}, + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) + defaultOptions := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err + if o, ok := opt.(Option); ok { + if err := o(&defaultOptions); err != nil { + return nil, fmt.Errorf("mongodb option: %w", err) + } } } - username := req.Env["MONGO_INITDB_ROOT_USERNAME"] - password := req.Env["MONGO_INITDB_ROOT_PASSWORD"] + + username := defaultOptions.env["MONGO_INITDB_ROOT_USERNAME"] + password := defaultOptions.env["MONGO_INITDB_ROOT_PASSWORD"] if username != "" && password == "" || username == "" && password != "" { return nil, errors.New("if you specify username or password, you must provide both of them") } - replicaSet := req.Env[replicaSetOptEnvKey] + replicaSet := defaultOptions.env[replicaSetOptEnvKey] if replicaSet != "" { - if err := configureRequestForReplicaset(username, password, replicaSet, &genericContainerReq); err != nil { - return nil, err + if username == "" || password == "" { + moduleOpts = append(moduleOpts, noAuthReplicaSet(replicaSet)) + } else { + moduleOpts = append(moduleOpts, withAuthReplicaset(replicaSet, username, password)) } } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + moduleOpts = append(moduleOpts, testcontainers.WithEnv(defaultOptions.env)) + + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *MongoDBContainer - if container != nil { - c = &MongoDBContainer{Container: container, username: username, password: password, replicaSet: replicaSet} + if ctr != nil { + c = &MongoDBContainer{Container: ctr, username: username, password: password, replicaSet: replicaSet} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil } -// WithUsername sets the initial username to be created when the container starts -// It is used in conjunction with WithPassword to set a username and its password. -// It will create the specified user with superuser power. -func WithUsername(username string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["MONGO_INITDB_ROOT_USERNAME"] = username - - return nil - } -} - -// WithPassword sets the initial password of the user to be created when the container starts -// It is used in conjunction with WithUsername to set a username and its password. -// It will set the superuser password for MongoDB. -func WithPassword(password string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["MONGO_INITDB_ROOT_PASSWORD"] = password - - return nil - } -} - -// WithReplicaSet sets the replica set name for Single node MongoDB replica set. -func WithReplicaSet(replSetName string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env[replicaSetOptEnvKey] = replSetName - - return nil - } -} - // ConnectionString returns the connection string for the MongoDB container. // If you provide a username and a password, the connection string will also include them. func (c *MongoDBContainer) ConnectionString(ctx context.Context) (string, error) { @@ -141,66 +113,74 @@ func (c *MongoDBContainer) ConnectionString(ctx context.Context) (string, error) return u.String(), nil } -func setupEntrypointForAuth(req *testcontainers.GenericContainerRequest) { - req.Files = append( - req.Files, testcontainers.ContainerFile{ +func setupEntrypointForAuth() testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + if err := testcontainers.WithFiles(testcontainers.ContainerFile{ Reader: bytes.NewReader(entrypointContent), ContainerFilePath: entrypointPath, FileMode: 0o755, - }, - ) - req.Entrypoint = []string{entrypointPath} - req.Env["MONGO_KEYFILE"] = keyFilePath -} + })(req); err != nil { + return err + } -func configureRequestForReplicaset( - username string, - password string, - replicaSet string, - genericContainerReq *testcontainers.GenericContainerRequest, -) error { - if username == "" || password == "" { - return noAuthReplicaSet(replicaSet)(genericContainerReq) - } + if err := testcontainers.WithEntrypoint(entrypointPath)(req); err != nil { + return err + } - return withAuthReplicaset(replicaSet, username, password)(genericContainerReq) + if err := testcontainers.WithEnv(map[string]string{ + "MONGO_KEYFILE": keyFilePath, + })(req); err != nil { + return err + } + + return nil + } } func noAuthReplicaSet(replSetName string) testcontainers.CustomizeRequestOption { return func(req *testcontainers.GenericContainerRequest) error { cli := newMongoCli("", "") - req.Cmd = append(req.Cmd, "--replSet", replSetName) - initiateReplicaSet(req, cli, replSetName) + if err := testcontainers.WithCmdArgs("--replSet", replSetName)(req); err != nil { + return fmt.Errorf("with cmd args: %w", err) + } + + if err := initiateReplicaSet(cli, replSetName)(req); err != nil { + return fmt.Errorf("initiate replica set: %w", err) + } return nil } } -func initiateReplicaSet(req *testcontainers.GenericContainerRequest, cli mongoCli, replSetName string) { - req.WaitingFor = wait.ForAll( - req.WaitingFor, - wait.ForExec(cli.eval("rs.status().ok")), - ).WithDeadline(60 * time.Second) - - req.LifecycleHooks = append( - req.LifecycleHooks, testcontainers.ContainerLifecycleHooks{ - PostStarts: []testcontainers.ContainerHook{ - func(ctx context.Context, c testcontainers.Container) error { - ip, err := c.ContainerIP(ctx) - if err != nil { - return fmt.Errorf("container ip: %w", err) - } - - cmd := cli.eval( - "rs.initiate({ _id: '%s', members: [ { _id: 0, host: '%s:27017' } ] })", - replSetName, - ip, - ) - return wait.ForExec(cmd).WaitUntilReady(ctx, c) +func initiateReplicaSet(cli mongoCli, replSetName string) testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + req.WaitingFor = wait.ForAll( + req.WaitingFor, + wait.ForExec(cli.eval("rs.status().ok")), + ).WithDeadline(60 * time.Second) + + req.LifecycleHooks = append( + req.LifecycleHooks, testcontainers.ContainerLifecycleHooks{ + PostStarts: []testcontainers.ContainerHook{ + func(ctx context.Context, c testcontainers.Container) error { + ip, err := c.ContainerIP(ctx) + if err != nil { + return fmt.Errorf("container ip: %w", err) + } + + cmd := cli.eval( + "rs.initiate({ _id: '%s', members: [ { _id: 0, host: '%s:27017' } ] })", + replSetName, + ip, + ) + return wait.ForExec(cmd).WaitUntilReady(ctx, c) + }, }, }, - }, - ) + ) + + return nil + } } func withAuthReplicaset( @@ -209,10 +189,19 @@ func withAuthReplicaset( password string, ) testcontainers.CustomizeRequestOption { return func(req *testcontainers.GenericContainerRequest) error { - setupEntrypointForAuth(req) + if err := setupEntrypointForAuth()(req); err != nil { + return fmt.Errorf("setup entrypoint for auth: %w", err) + } + cli := newMongoCli(username, password) - req.Cmd = append(req.Cmd, "--replSet", replSetName, "--keyFile", keyFilePath) - initiateReplicaSet(req, cli, replSetName) + + if err := testcontainers.WithCmdArgs("--replSet", replSetName, "--keyFile", keyFilePath)(req); err != nil { + return fmt.Errorf("with cmd args: %w", err) + } + + if err := initiateReplicaSet(cli, replSetName)(req); err != nil { + return fmt.Errorf("initiate replica set: %w", err) + } return nil } diff --git a/modules/mongodb/options.go b/modules/mongodb/options.go new file mode 100644 index 0000000000..9198934848 --- /dev/null +++ b/modules/mongodb/options.go @@ -0,0 +1,58 @@ +package mongodb + +import ( + "github.com/testcontainers/testcontainers-go" +) + +type options struct { + env map[string]string +} + +func defaultOptions() options { + return options{ + env: map[string]string{}, + } +} + +// Satisfy the testcontainers.CustomizeRequestOption interface +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the MongoDB container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + +// WithUsername sets the initial username to be created when the container starts +// It is used in conjunction with WithPassword to set a username and its password. +// It will create the specified user with superuser power. +func WithUsername(username string) Option { + return func(o *options) error { + o.env["MONGO_INITDB_ROOT_USERNAME"] = username + + return nil + } +} + +// WithPassword sets the initial password of the user to be created when the container starts +// It is used in conjunction with WithUsername to set a username and its password. +// It will set the superuser password for MongoDB. +func WithPassword(password string) Option { + return func(o *options) error { + o.env["MONGO_INITDB_ROOT_PASSWORD"] = password + + return nil + } +} + +// WithReplicaSet sets the replica set name for Single node MongoDB replica set. +func WithReplicaSet(replSetName string) Option { + return func(o *options) error { + o.env[replicaSetOptEnvKey] = replSetName + + return nil + } +} diff --git a/modules/mssql/examples_test.go b/modules/mssql/examples_test.go index 8c1f2c0cac..5310b57c31 100644 --- a/modules/mssql/examples_test.go +++ b/modules/mssql/examples_test.go @@ -16,7 +16,7 @@ func ExampleRun() { password := "SuperStrong@Passw0rd" mssqlContainer, err := mssql.Run(ctx, - "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04", + "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04", mssql.WithAcceptEULA(), mssql.WithPassword(password), ) diff --git a/modules/mssql/mssql.go b/modules/mssql/mssql.go index d93de3feb0..6f7b97ae00 100644 --- a/modules/mssql/mssql.go +++ b/modules/mssql/mssql.go @@ -4,12 +4,10 @@ import ( "context" "errors" "fmt" - "io" "strings" "time" "github.com/testcontainers/testcontainers-go" - tcexec "github.com/testcontainers/testcontainers-go/exec" "github.com/testcontainers/testcontainers-go/wait" ) @@ -31,122 +29,47 @@ func (c *MSSQLServerContainer) Password() string { return c.password } -// WithAcceptEULA sets the ACCEPT_EULA environment variable to "Y" -func WithAcceptEULA() testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["ACCEPT_EULA"] = "Y" - - return nil - } -} - -// WithPassword sets the MSSQL_SA_PASSWORD environment variable to the provided password -func WithPassword(password string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - if password == "" { - password = defaultPassword - } - req.Env["MSSQL_SA_PASSWORD"] = password - - return nil - } -} - -// WithInitSQL adds SQL scripts to be executed after the container is ready. -// The scripts are executed in the order they are provided using sqlcmd tool. -func WithInitSQL(files ...io.Reader) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - hooks := make([]testcontainers.ContainerHook, 0, len(files)) - - for i, script := range files { - content, err := io.ReadAll(script) - if err != nil { - return fmt.Errorf("failed to read script: %w", err) - } - - hook := func(ctx context.Context, c testcontainers.Container) error { - password := defaultPassword - if req.Env["MSSQL_SA_PASSWORD"] != "" { - password = req.Env["MSSQL_SA_PASSWORD"] - } - - // targetPath is a dummy path to store the script in the container - targetPath := "/tmp/" + fmt.Sprintf("script_%d.sql", i) - if err := c.CopyToContainer(ctx, content, targetPath, 0o644); err != nil { - return fmt.Errorf("failed to copy script to container: %w", err) - } - - // NOTE: we add both legacy and new mssql-tools paths to ensure compatibility - envOpts := tcexec.WithEnv([]string{ - "PATH=/opt/mssql-tools18/bin:/opt/mssql-tools/bin:$PATH", - }) - cmd := []string{ - "sqlcmd", - "-S", "localhost", - "-U", defaultUsername, - "-P", password, - "-No", - "-i", targetPath, - } - if _, _, err := c.Exec(ctx, cmd, envOpts); err != nil { - return fmt.Errorf("failed to execute SQL script %q using sqlcmd: %w", targetPath, err) - } - return nil - } - hooks = append(hooks, hook) - } - - req.LifecycleHooks = append(req.LifecycleHooks, testcontainers.ContainerLifecycleHooks{ - PostReadies: hooks, - }) - - return nil - } -} - // Deprecated: use Run instead // RunContainer creates an instance of the MSSQLServer container type func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*MSSQLServerContainer, error) { - return Run(ctx, "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04", opts...) + return Run(ctx, "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04", opts...) } // Run creates an instance of the MSSQLServer container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*MSSQLServerContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{defaultPort}, - Env: map[string]string{ - "MSSQL_SA_PASSWORD": defaultPassword, - }, - WaitingFor: wait.ForAll( + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(defaultPort), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForListeningPort(defaultPort).WithStartupTimeout(time.Minute), wait.ForLog("Recovery is complete."), - ), + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) + defaultSettings := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) + if o, ok := opt.(Option); ok { + if err := o(&defaultSettings); err != nil { + return nil, fmt.Errorf("mssql option: %w", err) + } } } - if strings.ToUpper(genericContainerReq.Env["ACCEPT_EULA"]) != "Y" { + if strings.ToUpper(defaultSettings.env["ACCEPT_EULA"]) != "Y" { return nil, errors.New("EULA not accepted. Please use the WithAcceptEULA option to accept the EULA") } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + moduleOpts = append(moduleOpts, testcontainers.WithEnv(defaultSettings.env)) + + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *MSSQLServerContainer - if container != nil { - c = &MSSQLServerContainer{Container: container, password: req.Env["MSSQL_SA_PASSWORD"], username: defaultUsername} + if ctr != nil { + c = &MSSQLServerContainer{Container: ctr, password: defaultSettings.env["MSSQL_SA_PASSWORD"], username: defaultUsername} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/mssql/mssql_test.go b/modules/mssql/mssql_test.go index 10052b8d40..840051e065 100644 --- a/modules/mssql/mssql_test.go +++ b/modules/mssql/mssql_test.go @@ -19,7 +19,7 @@ func TestMSSQLServer(t *testing.T) { ctx := context.Background() ctr, err := mssql.Run(ctx, - "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04", + "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04", mssql.WithAcceptEULA(), ) testcontainers.CleanupContainer(t, ctr) @@ -49,7 +49,7 @@ func TestMSSQLServerWithMissingEulaOption(t *testing.T) { t.Run("empty", func(t *testing.T) { ctr, err := mssql.Run(ctx, - "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04", + "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04", testcontainers.WithAdditionalWaitStrategy( wait.ForLog("The SQL Server End-User License Agreement (EULA) must be accepted")), ) @@ -59,7 +59,7 @@ func TestMSSQLServerWithMissingEulaOption(t *testing.T) { t.Run("not-y", func(t *testing.T) { ctr, err := mssql.Run(ctx, - "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04", + "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04", testcontainers.WithEnv(map[string]string{"ACCEPT_EULA": "yes"}), testcontainers.WithAdditionalWaitStrategy( wait.ForLog("The SQL Server End-User License Agreement (EULA) must be accepted")), @@ -73,7 +73,7 @@ func TestMSSQLServerWithConnectionStringParameters(t *testing.T) { ctx := context.Background() ctr, err := mssql.Run(ctx, - "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04", + "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04", mssql.WithAcceptEULA(), ) testcontainers.CleanupContainer(t, ctr) @@ -103,7 +103,7 @@ func TestMSSQLServerWithCustomStrongPassword(t *testing.T) { ctx := context.Background() ctr, err := mssql.Run(ctx, - "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04", + "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04", mssql.WithAcceptEULA(), mssql.WithPassword("Strong@Passw0rd"), ) @@ -127,7 +127,7 @@ func TestMSSQLServerWithInvalidPassword(t *testing.T) { ctx := context.Background() ctr, err := mssql.Run(ctx, - "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04", + "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04", testcontainers.WithWaitStrategy( wait.ForLog("Password validation failed")), mssql.WithAcceptEULA(), @@ -195,7 +195,7 @@ func TestMSSQLServerWithScriptsDDL(t *testing.T) { t.Run("WithPassword/beforeWithScripts", func(t *testing.T) { assertContainer(t, ctx, - "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04", + "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04", mssql.WithPassword(password), mssql.WithInitSQL(bytes.NewReader(seedSQLContent)), ) @@ -203,15 +203,15 @@ func TestMSSQLServerWithScriptsDDL(t *testing.T) { t.Run("WithPassword/afterWithScripts", func(t *testing.T) { assertContainer(t, ctx, - "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04", + "mcr.microsoft.com/mssql/server:2022-CU18-ubuntu-22.04", mssql.WithInitSQL(bytes.NewReader(seedSQLContent)), mssql.WithPassword(password), ) }) - t.Run("2019-CU30-ubuntu-20.04/oldSQLCmd", func(t *testing.T) { + t.Run("2019-CU32-ubuntu-20.04/oldSQLCmd", func(t *testing.T) { assertContainer(t, ctx, - "mcr.microsoft.com/mssql/server:2019-CU30-ubuntu-20.04", + "mcr.microsoft.com/mssql/server:2019-CU32-ubuntu-20.04", mssql.WithPassword(password), mssql.WithInitSQL(bytes.NewReader(seedSQLContent)), ) diff --git a/modules/mssql/options.go b/modules/mssql/options.go new file mode 100644 index 0000000000..40b6f9789b --- /dev/null +++ b/modules/mssql/options.go @@ -0,0 +1,107 @@ +package mssql + +import ( + "context" + "fmt" + "io" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/exec" +) + +type options struct { + env map[string]string +} + +func defaultOptions() options { + return options{ + env: map[string]string{ + "MSSQL_SA_PASSWORD": defaultPassword, + }, + } +} + +// Satisfy the testcontainers.CustomizeRequestOption interface +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the MSSQL container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + +// WithAcceptEULA sets the ACCEPT_EULA environment variable to "Y" +func WithAcceptEULA() Option { + return func(o *options) error { + o.env["ACCEPT_EULA"] = "Y" + + return nil + } +} + +// WithPassword sets the MSSQL_SA_PASSWORD environment variable to the provided password +func WithPassword(password string) Option { + return func(o *options) error { + if password == "" { + password = defaultPassword + } + o.env["MSSQL_SA_PASSWORD"] = password + + return nil + } +} + +// WithInitSQL adds SQL scripts to be executed after the container is ready. +// The scripts are executed in the order they are provided using sqlcmd tool. +func WithInitSQL(files ...io.Reader) testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + hooks := make([]testcontainers.ContainerHook, 0, len(files)) + + for i, script := range files { + content, err := io.ReadAll(script) + if err != nil { + return fmt.Errorf("failed to read script: %w", err) + } + + hook := func(ctx context.Context, c testcontainers.Container) error { + password := defaultPassword + if req.Env["MSSQL_SA_PASSWORD"] != "" { + password = req.Env["MSSQL_SA_PASSWORD"] + } + + // targetPath is a dummy path to store the script in the container + targetPath := "/tmp/" + fmt.Sprintf("script_%d.sql", i) + if err := c.CopyToContainer(ctx, content, targetPath, 0o644); err != nil { + return fmt.Errorf("failed to copy script to container: %w", err) + } + + // NOTE: we add both legacy and new mssql-tools paths to ensure compatibility + envOpts := exec.WithEnv([]string{ + "PATH=/opt/mssql-tools18/bin:/opt/mssql-tools/bin:$PATH", + }) + cmd := []string{ + "sqlcmd", + "-S", "localhost", + "-U", defaultUsername, + "-P", password, + "-No", + "-i", targetPath, + } + if _, _, err := c.Exec(ctx, cmd, envOpts); err != nil { + return fmt.Errorf("failed to execute SQL script %q using sqlcmd: %w", targetPath, err) + } + return nil + } + hooks = append(hooks, hook) + } + + req.LifecycleHooks = append(req.LifecycleHooks, testcontainers.ContainerLifecycleHooks{ + PostReadies: hooks, + }) + + return nil + } +} diff --git a/modules/mysql/mysql.go b/modules/mysql/mysql.go index c2e10efde7..1de4dac10b 100644 --- a/modules/mysql/mysql.go +++ b/modules/mysql/mysql.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "path/filepath" "strings" "github.com/testcontainers/testcontainers-go" @@ -26,24 +25,6 @@ type MySQLContainer struct { database string } -func WithDefaultCredentials() testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - username := req.Env["MYSQL_USER"] - password := req.Env["MYSQL_PASSWORD"] - if strings.EqualFold(rootUser, username) { - delete(req.Env, "MYSQL_USER") - } - if len(password) != 0 && password != "" { - req.Env["MYSQL_ROOT_PASSWORD"] = password - } else if strings.EqualFold(rootUser, username) { - req.Env["MYSQL_ALLOW_EMPTY_PASSWORD"] = "yes" - delete(req.Env, "MYSQL_PASSWORD") - } - - return nil - } -} - // Deprecated: use Run instead // RunContainer creates an instance of the MySQL container type func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*MySQLContainer, error) { @@ -52,48 +33,49 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the MySQL container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*MySQLContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"3306/tcp", "33060/tcp"}, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("3306/tcp", "33060/tcp"), + testcontainers.WithEnv(map[string]string{ "MYSQL_USER": defaultUser, "MYSQL_PASSWORD": defaultPassword, "MYSQL_DATABASE": defaultDatabaseName, - }, - WaitingFor: wait.ForLog("port: 3306 MySQL Community Server"), - } - - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, + }), + testcontainers.WithWaitStrategy(wait.ForLog("port: 3306 MySQL Community Server")), } opts = append(opts, WithDefaultCredentials()) + moduleOpts = append(moduleOpts, opts...) + + defaultOptions := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err + if o, ok := opt.(Option); ok { + if err := o(&defaultOptions); err != nil { + return nil, fmt.Errorf("mysql option: %w", err) + } } } - username, ok := req.Env["MYSQL_USER"] + username, ok := defaultOptions.env["MYSQL_USER"] if !ok { username = rootUser } - password := req.Env["MYSQL_PASSWORD"] + password := defaultOptions.env["MYSQL_PASSWORD"] if len(password) == 0 && password == "" && !strings.EqualFold(rootUser, username) { return nil, errors.New("empty password can be used only with the root user") } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + moduleOpts = append(moduleOpts, testcontainers.WithEnv(defaultOptions.env)) + + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *MySQLContainer if container != nil { c = &MySQLContainer{ Container: container, password: password, username: username, - database: req.Env["MYSQL_DATABASE"], + database: defaultOptions.env["MYSQL_DATABASE"], } } @@ -130,57 +112,3 @@ func (c *MySQLContainer) ConnectionString(ctx context.Context, args ...string) ( connectionString := fmt.Sprintf("%s:%s@tcp(%s)/%s%s", c.username, c.password, endpoint, c.database, extraArgs) return connectionString, nil } - -func WithUsername(username string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["MYSQL_USER"] = username - - return nil - } -} - -func WithPassword(password string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["MYSQL_PASSWORD"] = password - - return nil - } -} - -func WithDatabase(database string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["MYSQL_DATABASE"] = database - - return nil - } -} - -func WithConfigFile(configFile string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - cf := testcontainers.ContainerFile{ - HostFilePath: configFile, - ContainerFilePath: "/etc/mysql/conf.d/my.cnf", - FileMode: 0o755, - } - req.Files = append(req.Files, cf) - - return nil - } -} - -func WithScripts(scripts ...string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - var initScripts []testcontainers.ContainerFile - for _, script := range scripts { - cf := testcontainers.ContainerFile{ - HostFilePath: script, - ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base(script), - FileMode: 0o755, - } - initScripts = append(initScripts, cf) - } - req.Files = append(req.Files, initScripts...) - - return nil - } -} diff --git a/modules/mysql/options.go b/modules/mysql/options.go new file mode 100644 index 0000000000..38fa7edcd3 --- /dev/null +++ b/modules/mysql/options.go @@ -0,0 +1,98 @@ +package mysql + +import ( + "path/filepath" + "strings" + + "github.com/testcontainers/testcontainers-go" +) + +type options struct { + env map[string]string +} + +func defaultOptions() options { + return options{ + env: map[string]string{ + "MYSQL_USER": defaultUser, + "MYSQL_PASSWORD": defaultPassword, + "MYSQL_DATABASE": defaultDatabaseName, + }, + } +} + +// Satisfy the testcontainers.CustomizeRequestOption interface +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the MSSQL container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + +func WithDefaultCredentials() Option { + return func(o *options) error { + username := o.env["MYSQL_USER"] + password := o.env["MYSQL_PASSWORD"] + if strings.EqualFold(rootUser, username) { + delete(o.env, "MYSQL_USER") + } + if len(password) != 0 && password != "" { + o.env["MYSQL_ROOT_PASSWORD"] = password + } else if strings.EqualFold(rootUser, username) { + o.env["MYSQL_ALLOW_EMPTY_PASSWORD"] = "yes" + delete(o.env, "MYSQL_PASSWORD") + } + + return nil + } +} + +func WithUsername(username string) Option { + return func(o *options) error { + o.env["MYSQL_USER"] = username + + return nil + } +} + +func WithPassword(password string) Option { + return func(o *options) error { + o.env["MYSQL_PASSWORD"] = password + + return nil + } +} + +func WithDatabase(database string) Option { + return func(o *options) error { + o.env["MYSQL_DATABASE"] = database + + return nil + } +} + +func WithConfigFile(configFile string) testcontainers.CustomizeRequestOption { + return testcontainers.WithFiles(testcontainers.ContainerFile{ + HostFilePath: configFile, + ContainerFilePath: "/etc/mysql/conf.d/my.cnf", + FileMode: 0o755, + }) +} + +func WithScripts(scripts ...string) testcontainers.CustomizeRequestOption { + var initScripts []testcontainers.ContainerFile + for _, script := range scripts { + cf := testcontainers.ContainerFile{ + HostFilePath: script, + ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base(script), + FileMode: 0o755, + } + initScripts = append(initScripts, cf) + } + + return testcontainers.WithFiles(initScripts...) +} diff --git a/modules/nats/nats.go b/modules/nats/nats.go index 482d46dea0..086f9e0d67 100644 --- a/modules/nats/nats.go +++ b/modules/nats/nats.go @@ -29,36 +29,35 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the NATS container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*NATSContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{defaultClientPort, defaultRoutingPort, defaultMonitoringPort}, - Cmd: []string{"-DV", "-js"}, - WaitingFor: wait.ForLog("Listening for client connections on 0.0.0.0:4222"), + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(defaultClientPort, defaultRoutingPort, defaultMonitoringPort), + testcontainers.WithCmd("-DV", "-js"), + testcontainers.WithWaitStrategy(wait.ForLog("Listening for client connections on 0.0.0.0:4222")), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) // Gather all config options (defaults and then apply provided options) settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(CmdOption); ok { - apply(&settings) - } - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err + if err := apply(&settings); err != nil { + return nil, fmt.Errorf("nats option: %w", err) + } } } // Include the command line arguments + cmdArgs := []string{} for k, v := range settings.CmdArgs { // always prepend the dash because it was removed in the options - genericContainerReq.Cmd = append(genericContainerReq.Cmd, []string{"--" + k, v}...) + cmdArgs = append(cmdArgs, []string{"--" + k, v}...) + } + if len(cmdArgs) > 0 { + moduleOpts = append(moduleOpts, testcontainers.WithCmdArgs(cmdArgs...)) } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *NATSContainer if container != nil { c = &NATSContainer{ @@ -69,7 +68,7 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/nats/options.go b/modules/nats/options.go index 2db31f2746..84e23510a1 100644 --- a/modules/nats/options.go +++ b/modules/nats/options.go @@ -21,7 +21,7 @@ func defaultOptions() options { var _ testcontainers.ContainerCustomizer = (*CmdOption)(nil) // CmdOption is an option for the NATS container. -type CmdOption func(opts *options) +type CmdOption func(opts *options) error // Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. func (o CmdOption) Customize(_ *testcontainers.GenericContainerRequest) error { @@ -30,14 +30,16 @@ func (o CmdOption) Customize(_ *testcontainers.GenericContainerRequest) error { } func WithUsername(username string) CmdOption { - return func(o *options) { + return func(o *options) error { o.CmdArgs["user"] = username + return nil } } func WithPassword(password string) CmdOption { - return func(o *options) { + return func(o *options) error { o.CmdArgs["pass"] = password + return nil } } @@ -46,8 +48,9 @@ func WithPassword(password string) CmdOption { func WithArgument(flag string, value string) CmdOption { flag = strings.ReplaceAll(flag, "--", "") // remove all dashes to make it easier to use - return func(o *options) { + return func(o *options) error { o.CmdArgs[flag] = value + return nil } } diff --git a/modules/neo4j/neo4j.go b/modules/neo4j/neo4j.go index c29ee497d9..3df6b05485 100644 --- a/modules/neo4j/neo4j.go +++ b/modules/neo4j/neo4j.go @@ -37,50 +37,31 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Neo4j container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Neo4jContainer, error) { - request := testcontainers.ContainerRequest{ - Image: img, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithEnv(map[string]string{ "NEO4J_AUTH": "none", - }, - ExposedPorts: []string{ - defaultBoltPort, - defaultHTTPPort, - defaultHTTPSPort, - }, - WaitingFor: &wait.MultiStrategy{ - Strategies: []wait.Strategy{ - wait.NewLogStrategy("Bolt enabled on"), - &wait.HTTPStrategy{ - Port: defaultHTTPPort, - StatusCodeMatcher: isHTTPOk(), - }, - }, - }, - } - - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: request, - Started: true, + }), + testcontainers.WithExposedPorts(defaultBoltPort, defaultHTTPPort, defaultHTTPSPort), + testcontainers.WithWaitStrategy(wait.ForAll( + wait.ForLog("Bolt enabled on"), + wait.ForHTTP("/").WithPort(defaultHTTPPort).WithStatusCodeMatcher(isHTTPOk()), + )), } if len(opts) == 0 { opts = append(opts, WithoutAuthentication()) } - for _, option := range opts { - if err := option.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + container, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Neo4jContainer if container != nil { c = &Neo4jContainer{Container: container} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/ollama/local.go b/modules/ollama/local.go index b4c1f89fa4..ecc0840f27 100644 --- a/modules/ollama/local.go +++ b/modules/ollama/local.go @@ -91,7 +91,20 @@ type localProcess struct { } // runLocal returns an OllamaContainer that uses the local Ollama binary instead of using a Docker container. -func (c *localProcess) run(ctx context.Context, req testcontainers.GenericContainerRequest) (*OllamaContainer, error) { +func (c *localProcess) run(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*OllamaContainer, error) { + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Env: make(map[string]string), + }, + Started: true, + } + + for _, opt := range opts { + if err := opt.Customize(&req); err != nil { + return nil, fmt.Errorf("customize local ollama: %w", err) + } + } + if err := c.validateRequest(req); err != nil { return nil, fmt.Errorf("validate request: %w", err) } @@ -164,6 +177,10 @@ func (c *localProcess) validateRequest(req testcontainers.GenericContainerReques req.Image = "" req.Started = false req.Logger = nil // We don't need the logger. + req.HostConfigModifier = nil + req.ConfigModifier = nil + req.EndpointSettingsModifier = nil + req.BuildOptionsModifier = nil parts := make([]string, 0, 3) value := reflect.ValueOf(req) @@ -713,7 +730,14 @@ func (c *localProcess) Customize(req *testcontainers.GenericContainerRequest) er logStrategy := wait.ForLog(localLogRegex).Submatch(c.extractLogDetails) if req.WaitingFor == nil { req.WaitingFor = logStrategy + } else if multiStrategy, ok := req.WaitingFor.(*wait.MultiStrategy); ok { + if len(multiStrategy.Strategies) == 0 { + req.WaitingFor = logStrategy + } else { + req.WaitingFor = wait.ForAll(req.WaitingFor, logStrategy) + } } else { + // If it's a different type of strategy, combine it with the log strategy req.WaitingFor = wait.ForAll(req.WaitingFor, logStrategy) } diff --git a/modules/ollama/ollama.go b/modules/ollama/ollama.go index 8e8ffe8b61..375bafe9d4 100644 --- a/modules/ollama/ollama.go +++ b/modules/ollama/ollama.go @@ -74,23 +74,18 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Ollama container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*OllamaContainer, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"11434/tcp"}, - WaitingFor: wait.ForListeningPort("11434/tcp").WithStartupTimeout(60 * time.Second), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("11434/tcp"), + testcontainers.WithWaitStrategy(wait.ForListeningPort("11434/tcp").WithStartupTimeout(60 * time.Second)), } + moduleOpts = append(moduleOpts, opts...) + // Always request a GPU if the host supports it. - opts = append(opts, withGpu()) + moduleOpts = append(moduleOpts, withGpu()) var local *localProcess for _, opt := range opts { - if err := opt.Customize(&req); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } if l, ok := opt.(*localProcess); ok { local = l } @@ -98,17 +93,19 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom // Now we have processed all the options, we can check if we need to use the local process. if local != nil { - return local.run(ctx, req) + // pass the image to the local process + moduleOpts = append(moduleOpts, testcontainers.WithImage(img)) + return local.run(ctx, moduleOpts...) } - container, err := testcontainers.GenericContainer(ctx, req) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *OllamaContainer - if container != nil { - c = &OllamaContainer{Container: container} + if ctr != nil { + c = &OllamaContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/openfga/openfga.go b/modules/openfga/openfga.go index ce1c726e77..6c432fa1b0 100644 --- a/modules/openfga/openfga.go +++ b/modules/openfga/openfga.go @@ -49,11 +49,10 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the OpenFGA container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*OpenFGAContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - Cmd: []string{"run"}, - ExposedPorts: []string{"3000/tcp", "8080/tcp", "8081/tcp"}, - WaitingFor: wait.ForAll( + modulesOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithCmd("run"), + testcontainers.WithExposedPorts("3000/tcp", "8080/tcp", "8081/tcp"), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForHTTP("/healthz").WithPort("8080/tcp").WithResponseMatcher(func(r io.Reader) bool { bs, err := io.ReadAll(r) if err != nil { @@ -65,28 +64,19 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom wait.ForHTTP("/playground").WithPort("3000/tcp").WithStatusCodeMatcher(func(status int) bool { return status == http.StatusOK }), - ), + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } - } + modulesOpts = append(modulesOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, modulesOpts...) var c *OpenFGAContainer - if container != nil { - c = &OpenFGAContainer{Container: container} + if ctr != nil { + c = &OpenFGAContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/openldap/openldap.go b/modules/openldap/openldap.go index fc083a0779..f57414ebfe 100644 --- a/modules/openldap/openldap.go +++ b/modules/openldap/openldap.go @@ -47,69 +47,6 @@ func (c *OpenLDAPContainer) LoadLdif(ctx context.Context, ldif []byte) error { return nil } -// WithAdminUsername sets the initial admin username to be created when the container starts -// It is used in conjunction with WithAdminPassword to set a username and its password. -// It will create the specified user with admin power. -func WithAdminUsername(username string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["LDAP_ADMIN_USERNAME"] = username - - return nil - } -} - -// WithAdminPassword sets the initial admin password of the user to be created when the container starts -// It is used in conjunction with WithAdminUsername to set a username and its password. -// It will set the admin password for OpenLDAP. -func WithAdminPassword(password string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["LDAP_ADMIN_PASSWORD"] = password - - return nil - } -} - -// WithRoot sets the root of the OpenLDAP instance -func WithRoot(root string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["LDAP_ROOT"] = root - - return nil - } -} - -// WithInitialLdif sets the initial ldif file to be loaded into the OpenLDAP container -func WithInitialLdif(ldif string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Files = append(req.Files, testcontainers.ContainerFile{ - HostFilePath: ldif, - ContainerFilePath: "/initial_ldif.ldif", - FileMode: 0o644, - }) - - req.LifecycleHooks = append(req.LifecycleHooks, testcontainers.ContainerLifecycleHooks{ - PostReadies: []testcontainers.ContainerHook{ - func(ctx context.Context, container testcontainers.Container) error { - username := req.Env["LDAP_ADMIN_USERNAME"] - rootDn := req.Env["LDAP_ROOT"] - password := req.Env["LDAP_ADMIN_PASSWORD"] - code, output, err := container.Exec(ctx, []string{"ldapadd", "-H", "ldap://localhost:1389", "-x", "-D", fmt.Sprintf("cn=%s,%s", username, rootDn), "-w", password, "-f", "/initial_ldif.ldif"}) - if err != nil { - return err - } - if code != 0 { - data, _ := io.ReadAll(output) - return errors.New(string(data)) - } - return nil - }, - }, - }) - - return nil - } -} - // Deprecated: use Run instead // RunContainer creates an instance of the OpenLDAP container type func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*OpenLDAPContainer, error) { @@ -118,49 +55,46 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the OpenLDAP container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*OpenLDAPContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - Env: map[string]string{ + modulesOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithEnv(map[string]string{ "LDAP_ADMIN_USERNAME": defaultUser, "LDAP_ADMIN_PASSWORD": defaultPassword, "LDAP_ROOT": defaultRoot, - }, - ExposedPorts: []string{"1389/tcp"}, - WaitingFor: wait.ForAll( + }), + testcontainers.WithExposedPorts("1389/tcp"), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForLog("** Starting slapd **"), wait.ForListeningPort("1389/tcp"), - ), - LifecycleHooks: []testcontainers.ContainerLifecycleHooks{ - { - PostReadies: []testcontainers.ContainerHook{}, - }, - }, + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + modulesOpts = append(modulesOpts, opts...) + // Gather all config options (defaults and then apply provided options) + settings := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err + if apply, ok := opt.(Option); ok { + if err := apply(&settings); err != nil { + return nil, fmt.Errorf("openldap option: %w", err) + } } } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + modulesOpts = append(modulesOpts, testcontainers.WithEnv(settings.env)) + + ctr, err := testcontainers.Run(ctx, img, modulesOpts...) var c *OpenLDAPContainer - if container != nil { + if ctr != nil { c = &OpenLDAPContainer{ - Container: container, - adminUsername: req.Env["LDAP_ADMIN_USERNAME"], - adminPassword: req.Env["LDAP_ADMIN_PASSWORD"], - rootDn: req.Env["LDAP_ROOT"], + Container: ctr, + adminUsername: settings.env["LDAP_ADMIN_USERNAME"], + adminPassword: settings.env["LDAP_ADMIN_PASSWORD"], + rootDn: settings.env["LDAP_ROOT"], } } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/openldap/options.go b/modules/openldap/options.go new file mode 100644 index 0000000000..ab9100693c --- /dev/null +++ b/modules/openldap/options.go @@ -0,0 +1,99 @@ +package openldap + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/testcontainers/testcontainers-go" +) + +type options struct { + env map[string]string +} + +func defaultOptions() options { + return options{ + env: map[string]string{ + "LDAP_ADMIN_USERNAME": defaultUser, + "LDAP_ADMIN_PASSWORD": defaultPassword, + "LDAP_ROOT": defaultRoot, + }, + } +} + +// Compiler check to ensure that Option implements the testcontainers.ContainerCustomizer interface. +var _ testcontainers.ContainerCustomizer = (*Option)(nil) + +// Option is an option for the OpenLDAP container. +type Option func(opts *options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(_ *testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + +// WithAdminUsername sets the initial admin username to be created when the container starts +// It is used in conjunction with WithAdminPassword to set a username and its password. +// It will create the specified user with admin power. +func WithAdminUsername(username string) Option { + return func(opts *options) error { + opts.env["LDAP_ADMIN_USERNAME"] = username + + return nil + } +} + +// WithAdminPassword sets the initial admin password of the user to be created when the container starts +// It is used in conjunction with WithAdminUsername to set a username and its password. +// It will set the admin password for OpenLDAP. +func WithAdminPassword(password string) Option { + return func(opts *options) error { + opts.env["LDAP_ADMIN_PASSWORD"] = password + + return nil + } +} + +// WithRoot sets the root of the OpenLDAP instance +func WithRoot(root string) Option { + return func(opts *options) error { + opts.env["LDAP_ROOT"] = root + + return nil + } +} + +// WithInitialLdif sets the initial ldif file to be loaded into the OpenLDAP container +func WithInitialLdif(ldif string) testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + req.Files = append(req.Files, testcontainers.ContainerFile{ + HostFilePath: ldif, + ContainerFilePath: "/initial_ldif.ldif", + FileMode: 0o644, + }) + + req.LifecycleHooks = append(req.LifecycleHooks, testcontainers.ContainerLifecycleHooks{ + PostReadies: []testcontainers.ContainerHook{ + func(ctx context.Context, container testcontainers.Container) error { + username := req.Env["LDAP_ADMIN_USERNAME"] + rootDn := req.Env["LDAP_ROOT"] + password := req.Env["LDAP_ADMIN_PASSWORD"] + code, output, err := container.Exec(ctx, []string{"ldapadd", "-H", "ldap://localhost:1389", "-x", "-D", fmt.Sprintf("cn=%s,%s", username, rootDn), "-w", password, "-f", "/initial_ldif.ldif"}) + if err != nil { + return err + } + if code != 0 { + data, _ := io.ReadAll(output) + return errors.New(string(data)) + } + return nil + }, + }, + }) + + return nil + } +} diff --git a/modules/opensearch/opensearch.go b/modules/opensearch/opensearch.go index 09b54759ba..dc73b7b586 100644 --- a/modules/opensearch/opensearch.go +++ b/modules/opensearch/opensearch.go @@ -35,17 +35,16 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the OpenSearch container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*OpenSearchContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{defaultHTTPPort, "9600/tcp"}, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(defaultHTTPPort, "9600/tcp"), + testcontainers.WithEnv(map[string]string{ "discovery.type": "single-node", "DISABLE_INSTALL_DEMO_CONFIG": "true", "DISABLE_SECURITY_PLUGIN": "true", "OPENSEARCH_USERNAME": defaultUsername, "OPENSEARCH_PASSWORD": defaultPassword, - }, - HostConfigModifier: func(hc *container.HostConfig) { + }), + testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) { hc.Ulimits = []*units.Ulimit{ { Name: "memlock", @@ -58,13 +57,10 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom Hard: 65536, }, } - }, + }), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) // Gather all config options (defaults and then apply provided options) settings := defaultOptions() @@ -72,59 +68,54 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom if apply, ok := opt.(Option); ok { apply(settings) } - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } } // set credentials if they are provided, otherwise use the defaults - if settings.Username != "" { - genericContainerReq.Env["OPENSEARCH_USERNAME"] = settings.Username - } - if settings.Password != "" { - genericContainerReq.Env["OPENSEARCH_PASSWORD"] = settings.Password - } - - username := genericContainerReq.Env["OPENSEARCH_USERNAME"] - password := genericContainerReq.Env["OPENSEARCH_PASSWORD"] + moduleOpts = append(moduleOpts, testcontainers.WithEnv(map[string]string{ + "OPENSEARCH_USERNAME": settings.Username, + "OPENSEARCH_PASSWORD": settings.Password, + })) // the wat strategy does not support TLS at the moment, // so we need to disable it in the strategy for now. - genericContainerReq.WaitingFor = wait.ForHTTP("/"). - WithPort("9200"). - WithTLS(false). - WithStartupTimeout(120*time.Second). - WithStatusCodeMatcher(func(status int) bool { - return status == 200 - }). - WithBasicAuth(username, password). - WithResponseMatcher(func(body io.Reader) bool { - bs, err := io.ReadAll(body) - if err != nil { - return false - } - - type response struct { - Tagline string `json:"tagline"` - } - - var r response - err = json.Unmarshal(bs, &r) - if err != nil { - return false - } - - return r.Tagline == "The OpenSearch Project: https://opensearch.org/" - }) - - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + moduleOpts = append(moduleOpts, testcontainers.WithAdditionalWaitStrategy( + wait.ForHTTP("/"). + WithPort("9200"). + WithTLS(false). + WithStartupTimeout(120*time.Second). + WithStatusCodeMatcher(func(status int) bool { + return status == 200 + }). + WithBasicAuth(settings.Username, settings.Password). + WithResponseMatcher(func(body io.Reader) bool { + bs, err := io.ReadAll(body) + if err != nil { + return false + } + + type response struct { + Tagline string `json:"tagline"` + } + + var r response + err = json.Unmarshal(bs, &r) + if err != nil { + return false + } + + return r.Tagline == "The OpenSearch Project: https://opensearch.org/" + }), + ), + ) + + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *OpenSearchContainer - if container != nil { - c = &OpenSearchContainer{Container: container, User: username, Password: password} + if ctr != nil { + c = &OpenSearchContainer{Container: ctr, User: settings.Username, Password: settings.Password} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/pinecone/pinecone.go b/modules/pinecone/pinecone.go index d4a26b5245..6bb9ae4d4e 100644 --- a/modules/pinecone/pinecone.go +++ b/modules/pinecone/pinecone.go @@ -14,30 +14,20 @@ type Container struct { // Run creates an instance of the Pinecone container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"5080/tcp"}, + modulesOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("5080/tcp"), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } - } + modulesOpts = append(modulesOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, modulesOpts...) var c *Container - if container != nil { - c = &Container{Container: container} + if ctr != nil { + c = &Container{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/postgres/options.go b/modules/postgres/options.go index 5779f85c04..781c47c7a0 100644 --- a/modules/postgres/options.go +++ b/modules/postgres/options.go @@ -1,10 +1,14 @@ package postgres import ( + "fmt" + "path/filepath" + "github.com/testcontainers/testcontainers-go" ) type options struct { + env map[string]string // SQLDriverName is the name of the SQL driver to use. SQLDriverName string Snapshot string @@ -12,6 +16,11 @@ type options struct { func defaultOptions() options { return options{ + env: map[string]string{ + "POSTGRES_USER": defaultUser, + "POSTGRES_PASSWORD": defaultPassword, + "POSTGRES_DB": defaultUser, // defaults to the user name + }, SQLDriverName: "postgres", Snapshot: defaultSnapshotName, } @@ -21,7 +30,7 @@ func defaultOptions() options { var _ testcontainers.ContainerCustomizer = (Option)(nil) // Option is an option for the Redpanda container. -type Option func(*options) +type Option func(*options) error // Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. func (o Option) Customize(*testcontainers.GenericContainerRequest) error { @@ -33,7 +42,94 @@ func (o Option) Customize(*testcontainers.GenericContainerRequest) error { // It is passed to sql.Open() to connect to the database when making or restoring snapshots. // This can be set if your app imports a different postgres driver, f.ex. "pgx" func WithSQLDriver(driver string) Option { - return func(o *options) { + return func(o *options) error { o.SQLDriverName = driver + return nil + } +} + +// WithConfigFile sets the config file to be used for the postgres container +// It will also set the "config_file" parameter to the path of the config file +// as a command line argument to the container +func WithConfigFile(cfg string) testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + cfgFile := testcontainers.ContainerFile{ + HostFilePath: cfg, + ContainerFilePath: "/etc/postgresql.conf", + FileMode: 0o755, + } + + req.Files = append(req.Files, cfgFile) + req.Cmd = append(req.Cmd, "-c", "config_file=/etc/postgresql.conf") + + return nil + } +} + +// WithDatabase sets the initial database to be created when the container starts +// It can be used to define a different name for the default database that is created when the image is first started. +// If it is not specified, then the value of WithUser will be used. +func WithDatabase(dbName string) Option { + return func(o *options) error { + o.env["POSTGRES_DB"] = dbName + return nil + } +} + +// WithInitScripts sets the init scripts to be run when the container starts. +// These init scripts will be executed in sorted name order as defined by the container's current locale, which defaults to en_US.utf8. +// If you need to run your scripts in a specific order, consider using `WithOrderedInitScripts` instead. +func WithInitScripts(scripts ...string) testcontainers.CustomizeRequestOption { + containerFiles := []testcontainers.ContainerFile{} + for _, script := range scripts { + initScript := testcontainers.ContainerFile{ + HostFilePath: script, + ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base(script), + FileMode: 0o755, + } + containerFiles = append(containerFiles, initScript) + } + + return testcontainers.WithFiles(containerFiles...) +} + +// WithOrderedInitScripts sets the init scripts to be run when the container starts. +// The scripts will be run in the order that they are provided in this function. +func WithOrderedInitScripts(scripts ...string) testcontainers.CustomizeRequestOption { + containerFiles := []testcontainers.ContainerFile{} + for idx, script := range scripts { + initScript := testcontainers.ContainerFile{ + HostFilePath: script, + ContainerFilePath: "/docker-entrypoint-initdb.d/" + fmt.Sprintf("%03d-%s", idx, filepath.Base(script)), + FileMode: 0o755, + } + containerFiles = append(containerFiles, initScript) + } + + return testcontainers.WithFiles(containerFiles...) +} + +// WithPassword sets the initial password of the user to be created when the container starts +// It is required for you to use the PostgreSQL image. It must not be empty or undefined. +// This environment variable sets the superuser password for PostgreSQL. +func WithPassword(password string) Option { + return func(o *options) error { + o.env["POSTGRES_PASSWORD"] = password + return nil + } +} + +// WithUsername sets the initial username to be created when the container starts +// It is used in conjunction with WithPassword to set a user and its password. +// It will create the specified user with superuser power and a database with the same name. +// If it is not specified, then the default user of postgres will be used. +func WithUsername(user string) Option { + return func(o *options) error { + if user == "" { + user = defaultUser + } + + o.env["POSTGRES_USER"] = user + return nil } } diff --git a/modules/postgres/postgres.go b/modules/postgres/postgres.go index f03adc7e16..1688976739 100644 --- a/modules/postgres/postgres.go +++ b/modules/postgres/postgres.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "path/filepath" "strings" "github.com/testcontainers/testcontainers-go" @@ -59,95 +58,6 @@ func (c *PostgresContainer) ConnectionString(ctx context.Context, args ...string return connStr, nil } -// WithConfigFile sets the config file to be used for the postgres container -// It will also set the "config_file" parameter to the path of the config file -// as a command line argument to the container -func WithConfigFile(cfg string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - cfgFile := testcontainers.ContainerFile{ - HostFilePath: cfg, - ContainerFilePath: "/etc/postgresql.conf", - FileMode: 0o755, - } - - req.Files = append(req.Files, cfgFile) - req.Cmd = append(req.Cmd, "-c", "config_file=/etc/postgresql.conf") - - return nil - } -} - -// WithDatabase sets the initial database to be created when the container starts -// It can be used to define a different name for the default database that is created when the image is first started. -// If it is not specified, then the value of WithUser will be used. -func WithDatabase(dbName string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["POSTGRES_DB"] = dbName - - return nil - } -} - -// WithInitScripts sets the init scripts to be run when the container starts. -// These init scripts will be executed in sorted name order as defined by the container's current locale, which defaults to en_US.utf8. -// If you need to run your scripts in a specific order, consider using `WithOrderedInitScripts` instead. -func WithInitScripts(scripts ...string) testcontainers.CustomizeRequestOption { - containerFiles := []testcontainers.ContainerFile{} - for _, script := range scripts { - initScript := testcontainers.ContainerFile{ - HostFilePath: script, - ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base(script), - FileMode: 0o755, - } - containerFiles = append(containerFiles, initScript) - } - - return testcontainers.WithFiles(containerFiles...) -} - -// WithOrderedInitScripts sets the init scripts to be run when the container starts. -// The scripts will be run in the order that they are provided in this function. -func WithOrderedInitScripts(scripts ...string) testcontainers.CustomizeRequestOption { - containerFiles := []testcontainers.ContainerFile{} - for idx, script := range scripts { - initScript := testcontainers.ContainerFile{ - HostFilePath: script, - ContainerFilePath: "/docker-entrypoint-initdb.d/" + fmt.Sprintf("%03d-%s", idx, filepath.Base(script)), - FileMode: 0o755, - } - containerFiles = append(containerFiles, initScript) - } - - return testcontainers.WithFiles(containerFiles...) -} - -// WithPassword sets the initial password of the user to be created when the container starts -// It is required for you to use the PostgreSQL image. It must not be empty or undefined. -// This environment variable sets the superuser password for PostgreSQL. -func WithPassword(password string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["POSTGRES_PASSWORD"] = password - - return nil - } -} - -// WithUsername sets the initial username to be created when the container starts -// It is used in conjunction with WithPassword to set a user and its password. -// It will create the specified user with superuser power and a database with the same name. -// If it is not specified, then the default user of postgres will be used. -func WithUsername(user string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - if user == "" { - user = defaultUser - } - - req.Env["POSTGRES_USER"] = user - - return nil - } -} - // Deprecated: use Run instead // RunContainer creates an instance of the Postgres container type func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*PostgresContainer, error) { @@ -156,48 +66,45 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Postgres container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*PostgresContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - Env: map[string]string{ + modulesOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("5432/tcp"), + testcontainers.WithEnv(map[string]string{ "POSTGRES_USER": defaultUser, "POSTGRES_PASSWORD": defaultPassword, "POSTGRES_DB": defaultUser, // defaults to the user name - }, - ExposedPorts: []string{"5432/tcp"}, - Cmd: []string{"postgres", "-c", "fsync=off"}, + }), + testcontainers.WithCmd("postgres", "-c", "fsync=off"), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + modulesOpts = append(modulesOpts, opts...) // Gather all config options (defaults and then apply provided options) settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { - apply(&settings) - } - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err + if err := apply(&settings); err != nil { + return nil, fmt.Errorf("postgres option: %w", err) + } } } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + modulesOpts = append(modulesOpts, testcontainers.WithEnv(settings.env)) + + ctr, err := testcontainers.Run(ctx, img, modulesOpts...) var c *PostgresContainer - if container != nil { + if ctr != nil { c = &PostgresContainer{ - Container: container, - dbName: req.Env["POSTGRES_DB"], - password: req.Env["POSTGRES_PASSWORD"], - user: req.Env["POSTGRES_USER"], + Container: ctr, + dbName: settings.env["POSTGRES_DB"], + password: settings.env["POSTGRES_PASSWORD"], + user: settings.env["POSTGRES_USER"], sqlDriverName: settings.SQLDriverName, snapshotName: settings.Snapshot, } } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/pulsar/pulsar.go b/modules/pulsar/pulsar.go index 5aefe2d9df..a61c4b2d1c 100644 --- a/modules/pulsar/pulsar.go +++ b/modules/pulsar/pulsar.go @@ -102,11 +102,9 @@ func (c *Container) WithLogConsumers(ctx context.Context, _ ...testcontainers.Lo // WithPulsarEnv allows to use the native APIs and set each variable with PULSAR_PREFIX_ as prefix. func WithPulsarEnv(configVar string, configValue string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env["PULSAR_PREFIX_"+configVar] = configValue - - return nil - } + return testcontainers.WithEnv(map[string]string{ + "PULSAR_PREFIX_" + configVar: configValue, + }) } func WithTransactions() testcontainers.CustomizeRequestOption { @@ -146,33 +144,22 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // // - command: "/bin/bash -c /pulsar/bin/apply-config-from-env.py /pulsar/conf/standalone.conf && bin/pulsar standalone --no-functions-worker -nss" func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - Env: map[string]string{}, - ExposedPorts: []string{defaultPulsarPort, defaultPulsarAdminPort}, - WaitingFor: defaultWaitStrategies, - Cmd: []string{"/bin/bash", "-c", strings.Join([]string{defaultPulsarCmd, defaultPulsarCmdWithoutFunctionsWorker}, " ")}, + modulesOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(defaultPulsarPort, defaultPulsarAdminPort), + testcontainers.WithWaitStrategy(defaultWaitStrategies), + testcontainers.WithCmd("/bin/bash", "-c", strings.Join([]string{defaultPulsarCmd, defaultPulsarCmdWithoutFunctionsWorker}, " ")), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + modulesOpts = append(modulesOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, modulesOpts...) var c *Container - if container != nil { - c = &Container{Container: container} + if ctr != nil { + c = &Container{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/qdrant/qdrant.go b/modules/qdrant/qdrant.go index e9b816af42..42b3165ec7 100644 --- a/modules/qdrant/qdrant.go +++ b/modules/qdrant/qdrant.go @@ -22,34 +22,24 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Qdrant container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*QdrantContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"6333/tcp", "6334/tcp"}, - WaitingFor: wait.ForAll( + modulesOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("6333/tcp", "6334/tcp"), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForListeningPort("6333/tcp").WithStartupTimeout(5*time.Second), wait.ForListeningPort("6334/tcp").WithStartupTimeout(5*time.Second), - ), + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + modulesOpts = append(modulesOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, modulesOpts...) var c *QdrantContainer - if container != nil { - c = &QdrantContainer{Container: container} + if ctr != nil { + c = &QdrantContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/rabbitmq/options.go b/modules/rabbitmq/options.go index 885768ab5e..15fa768891 100644 --- a/modules/rabbitmq/options.go +++ b/modules/rabbitmq/options.go @@ -41,7 +41,7 @@ type SSLSettings struct { var _ testcontainers.ContainerCustomizer = (*Option)(nil) // Option is an option for the RabbitMQ container. -type Option func(*options) +type Option func(*options) error // Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. func (o Option) Customize(*testcontainers.GenericContainerRequest) error { @@ -51,21 +51,24 @@ func (o Option) Customize(*testcontainers.GenericContainerRequest) error { // WithAdminPassword sets the password for the default admin user func WithAdminPassword(password string) Option { - return func(o *options) { + return func(o *options) error { o.AdminPassword = password + return nil } } // WithAdminUsername sets the default admin username func WithAdminUsername(username string) Option { - return func(o *options) { + return func(o *options) error { o.AdminUsername = username + return nil } } // WithSSL enables SSL on the RabbitMQ container, configuring the Erlang config file with the provided settings. func WithSSL(settings SSLSettings) Option { - return func(o *options) { + return func(o *options) error { o.SSLSettings = &settings + return nil } } diff --git a/modules/rabbitmq/rabbitmq.go b/modules/rabbitmq/rabbitmq.go index b9e0264d76..abcf6b960b 100644 --- a/modules/rabbitmq/rabbitmq.go +++ b/modules/rabbitmq/rabbitmq.go @@ -5,8 +5,6 @@ import ( "context" _ "embed" "fmt" - "os" - "path/filepath" "text/template" "time" @@ -80,46 +78,29 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the RabbitMQ container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*RabbitMQContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithEnv(map[string]string{ "RABBITMQ_DEFAULT_USER": defaultUser, "RABBITMQ_DEFAULT_PASS": defaultPassword, - }, - ExposedPorts: []string{ - DefaultAMQPPort, - DefaultAMQPSPort, - DefaultHTTPSPort, - DefaultHTTPPort, - }, - WaitingFor: wait.ForLog(".*Server startup complete.*").AsRegexp().WithStartupTimeout(60 * time.Second), - LifecycleHooks: []testcontainers.ContainerLifecycleHooks{ - { - PostStarts: []testcontainers.ContainerHook{}, - }, - }, + }), + testcontainers.WithExposedPorts(DefaultAMQPPort, DefaultAMQPSPort, DefaultHTTPSPort, DefaultHTTPPort), + testcontainers.WithWaitStrategy(wait.ForLog(".*Server startup complete.*").AsRegexp().WithStartupTimeout(60 * time.Second)), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) // Gather all config options (defaults and then apply provided options) settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { - apply(&settings) - } - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err + if err := apply(&settings); err != nil { + return nil, fmt.Errorf("rabbitmq option: %w", err) + } } } if settings.SSLSettings != nil { - if err := applySSLSettings(settings.SSLSettings)(&genericContainerReq); err != nil { - return nil, err - } + moduleOpts = append(moduleOpts, applySSLSettings(settings.SSLSettings)) } nodeConfig, err := renderRabbitMQConfig(settings) @@ -127,21 +108,19 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom return nil, err } - tmpConfigFile := filepath.Join(os.TempDir(), "rabbitmq-testcontainers.conf") - err = os.WriteFile(tmpConfigFile, nodeConfig, 0o600) - if err != nil { - return nil, err - } + // Make sure the admin user and password are also set as environment variables + moduleOpts = append(moduleOpts, testcontainers.WithEnv(map[string]string{ + "RABBITMQ_DEFAULT_USER": settings.AdminUsername, + "RABBITMQ_DEFAULT_PASS": settings.AdminPassword, + })) - if err := withConfig(tmpConfigFile)(&genericContainerReq); err != nil { - return nil, err - } + moduleOpts = append(moduleOpts, withConfig(nodeConfig)) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *RabbitMQContainer - if container != nil { + if ctr != nil { c = &RabbitMQContainer{ - Container: container, + Container: ctr, AdminUsername: settings.AdminUsername, AdminPassword: settings.AdminPassword, } @@ -154,15 +133,21 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom return c, nil } -func withConfig(hostPath string) testcontainers.CustomizeRequestOption { +func withConfig(b []byte) testcontainers.CustomizeRequestOption { return func(req *testcontainers.GenericContainerRequest) error { - req.Env["RABBITMQ_CONFIG_FILE"] = defaultCustomConfPath + if err := testcontainers.WithEnv(map[string]string{ + "RABBITMQ_CONFIG_FILE": defaultCustomConfPath, + })(req); err != nil { + return err + } - req.Files = append(req.Files, testcontainers.ContainerFile{ - HostFilePath: hostPath, + if err := testcontainers.WithFiles(testcontainers.ContainerFile{ + Reader: bytes.NewReader(b), ContainerFilePath: defaultCustomConfPath, FileMode: 0o644, - }) + })(req); err != nil { + return err + } return nil } @@ -177,25 +162,31 @@ func applySSLSettings(sslSettings *SSLSettings) testcontainers.CustomizeRequestO const defaultPermission = 0o644 return func(req *testcontainers.GenericContainerRequest) error { - req.Files = append(req.Files, testcontainers.ContainerFile{ - HostFilePath: sslSettings.CACertFile, - ContainerFilePath: rabbitCaCertPath, - FileMode: defaultPermission, - }) - req.Files = append(req.Files, testcontainers.ContainerFile{ - HostFilePath: sslSettings.CertFile, - ContainerFilePath: rabbitCertPath, - FileMode: defaultPermission, - }) - req.Files = append(req.Files, testcontainers.ContainerFile{ - HostFilePath: sslSettings.KeyFile, - ContainerFilePath: rabbitKeyPath, - FileMode: defaultPermission, - }) + if err := testcontainers.WithFiles( + testcontainers.ContainerFile{ + HostFilePath: sslSettings.CACertFile, + ContainerFilePath: rabbitCaCertPath, + FileMode: defaultPermission, + }, + testcontainers.ContainerFile{ + HostFilePath: sslSettings.CertFile, + ContainerFilePath: rabbitCertPath, + FileMode: defaultPermission, + }, + testcontainers.ContainerFile{ + HostFilePath: sslSettings.KeyFile, + ContainerFilePath: rabbitKeyPath, + FileMode: defaultPermission, + }, + )(req); err != nil { + return err + } // To verify that TLS has been enabled on the node, container logs should contain an entry about a TLS listener being enabled // See https://www.rabbitmq.com/ssl.html#enabling-tls-verify-configuration - req.WaitingFor = wait.ForAll(req.WaitingFor, wait.ForLog("started TLS (SSL) listener on [::]:5671")) + if err := testcontainers.WithAdditionalWaitStrategy(wait.ForLog("started TLS (SSL) listener on [::]:5671"))(req); err != nil { + return err + } return nil } diff --git a/modules/redis/redis.go b/modules/redis/redis.go index defe86605b..697750f8f7 100644 --- a/modules/redis/redis.go +++ b/modules/redis/redis.go @@ -56,27 +56,19 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Redis container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*RedisContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{redisPort}, - } - - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(redisPort), } var settings options for _, opt := range opts { if opt, ok := opt.(Option); ok { if err := opt(&settings); err != nil { - return nil, err + return nil, fmt.Errorf("redis option: %w", err) } } } - tcOpts := []testcontainers.ContainerCustomizer{} - waitStrategies := []wait.Strategy{ wait.ForListeningPort(redisPort).WithStartupTimeout(time.Second * 10), wait.ForLog("* Ready to accept connections"), @@ -104,8 +96,8 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom "--tls-auth-clients", "yes", } - tcOpts = append(tcOpts, testcontainers.WithCmdArgs(cmds...)) // Append the default CMD with the TLS certificates. - tcOpts = append(tcOpts, testcontainers.WithFiles( + moduleOpts = append(moduleOpts, testcontainers.WithCmdArgs(cmds...)) // Append the default CMD with the TLS certificates. + moduleOpts = append(moduleOpts, testcontainers.WithFiles( testcontainers.ContainerFile{ Reader: bytes.NewReader(caCert.Bytes), ContainerFilePath: "/tls/ca.crt", @@ -130,26 +122,19 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom } } - tcOpts = append(tcOpts, testcontainers.WithWaitStrategy(waitStrategies...)) + moduleOpts = append(moduleOpts, testcontainers.WithWaitStrategy(waitStrategies...)) // Append the customizers passed to the Run function. - tcOpts = append(tcOpts, opts...) - - // Apply the testcontainers customizers. - for _, opt := range tcOpts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *RedisContainer - if container != nil { - c = &RedisContainer{Container: container, settings: settings} + if ctr != nil { + c = &RedisContainer{Container: ctr, settings: settings} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/redpanda/options.go b/modules/redpanda/options.go index 3a67132cbc..f2e602accc 100644 --- a/modules/redpanda/options.go +++ b/modules/redpanda/options.go @@ -70,7 +70,7 @@ func defaultOptions() options { var _ testcontainers.ContainerCustomizer = (Option)(nil) // Option is an option for the Redpanda container. -type Option func(*options) +type Option func(*options) error // Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. func (o Option) Customize(*testcontainers.GenericContainerRequest) error { @@ -82,16 +82,18 @@ func (o Option) Customize(*testcontainers.GenericContainerRequest) error { // that shall be created, so that you can use these to authenticate against // Redpanda (either for the Kafka API or Schema Registry HTTP access). func WithNewServiceAccount(username, password string) Option { - return func(o *options) { + return func(o *options) error { o.ServiceAccounts[username] = password + return nil } } // WithSuperusers defines the superusers added to the redpanda config. // By default, there are no superusers. func WithSuperusers(superusers ...string) Option { - return func(o *options) { + return func(o *options) error { o.Superusers = superusers + return nil } } @@ -100,46 +102,52 @@ func WithSuperusers(superusers ...string) Option { // When setting an authentication method, make sure to add users // as well as authorize them using the WithSuperusers() option. func WithEnableSASL() Option { - return func(o *options) { + return func(o *options) error { o.KafkaAuthenticationMethod = "sasl" + return nil } } // WithEnableKafkaAuthorization enables authorization for connections on the Kafka API. func WithEnableKafkaAuthorization() Option { - return func(o *options) { + return func(o *options) error { o.KafkaEnableAuthorization = true + return nil } } // WithEnableWasmTransform enables wasm transform. // Should not be used with RP versions before 23.3 func WithEnableWasmTransform() Option { - return func(o *options) { + return func(o *options) error { o.EnableWasmTransform = true + return nil } } // WithEnableSchemaRegistryHTTPBasicAuth enables HTTP basic authentication for // Schema Registry. func WithEnableSchemaRegistryHTTPBasicAuth() Option { - return func(o *options) { + return func(o *options) error { o.SchemaRegistryAuthenticationMethod = "http_basic" + return nil } } // WithAutoCreateTopics enables topic auto creation. func WithAutoCreateTopics() Option { - return func(o *options) { + return func(o *options) error { o.AutoCreateTopics = true + return nil } } func WithTLS(cert, key []byte) Option { - return func(o *options) { + return func(o *options) error { o.EnableTLS = true o.cert = cert o.key = key + return nil } } @@ -150,20 +158,25 @@ func WithTLS(cert, key []byte) Option { func WithListener(lis string) Option { host, port, err := net.SplitHostPort(lis) if err != nil { - return func(_ *options) {} + return func(_ *options) error { + return err + } } portInt, err := strconv.Atoi(port) if err != nil { - return func(_ *options) {} + return func(_ *options) error { + return err + } } - return func(o *options) { + return func(o *options) error { o.Listeners = append(o.Listeners, listener{ Address: host, Port: portInt, AuthenticationMethod: o.KafkaAuthenticationMethod, }) + return nil } } @@ -172,8 +185,9 @@ func WithListener(lis string) Option { // config file, which is particularly useful for configs requiring a restart // when otherwise applied to a running Redpanda instance. func WithBootstrapConfig(cfg string, val any) Option { - return func(o *options) { + return func(o *options) error { o.ExtraBootstrapConfig[cfg] = val + return nil } } @@ -181,7 +195,8 @@ func WithBootstrapConfig(cfg string, val any) Option { // It sets `admin_api_require_auth` configuration to true and configures a bootstrap user account. // See https://docs.redpanda.com/current/deploy/deployment-option/self-hosted/manual/production/production-deployment/#bootstrap-a-user-account func WithAdminAPIAuthentication() Option { - return func(o *options) { + return func(o *options) error { o.enableAdminAPIAuthentication = true + return nil } } diff --git a/modules/redpanda/redpanda.go b/modules/redpanda/redpanda.go index aa40218baf..95e7cf6688 100644 --- a/modules/redpanda/redpanda.go +++ b/modules/redpanda/redpanda.go @@ -63,63 +63,48 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Redpanda container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - // 1. Create container request. + // 1. Create container definition with default options. // Some (e.g. Image) may be overridden by providing an option argument to this function. - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - ConfigModifier: func(c *container.Config) { - c.User = "root:root" - }, - // Files: Will be added later after we've rendered our YAML templates. - ExposedPorts: []string{ - defaultKafkaAPIPort, - defaultAdminAPIPort, - defaultSchemaRegistryPort, - }, - Entrypoint: []string{entrypointFile}, - Cmd: []string{ - "redpanda", - "start", - "--mode=dev-container", - "--smp=1", - "--memory=1G", - }, - WaitingFor: wait.ForAll( - // Wait for the ports to be mapped without accessing them, - // because container needs Redpanda configuration before Redpanda is started - // and the mapped ports are part of that configuration. - wait.ForMappedPort(defaultKafkaAPIPort), - wait.ForMappedPort(defaultAdminAPIPort), - wait.ForMappedPort(defaultSchemaRegistryPort), - ), - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithEntrypoint(entrypointFile), + testcontainers.WithCmd("redpanda", "start", "--mode=dev-container", "--smp=1", "--memory=1G"), + testcontainers.WithExposedPorts(defaultKafkaAPIPort, defaultAdminAPIPort, defaultSchemaRegistryPort), + testcontainers.WithWaitStrategy(wait.ForAll( + // Wait for the ports to be mapped without accessing them, + // because container needs Redpanda configuration before Redpanda is started + // and the mapped ports are part of that configuration. + wait.ForMappedPort(defaultKafkaAPIPort), + wait.ForMappedPort(defaultAdminAPIPort), + wait.ForMappedPort(defaultSchemaRegistryPort), + )), + testcontainers.WithConfigModifier(func(c *container.Config) { + c.User = "root:root" + }), } + moduleOpts = append(moduleOpts, opts...) + // 2. Gather all config options (defaults and then apply provided options) settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { - apply(&settings) - } - if err := opt.Customize(&req); err != nil { - return nil, err + if err := apply(&settings); err != nil { + return nil, fmt.Errorf("redpanda option: %w", err) + } } } // 2.1. If the image is not at least v23.3, disable wasm transform - if !isAtLeastVersion(req.Image, "23.3") { + if !isAtLeastVersion(img, "23.3") { settings.EnableWasmTransform = false } // 2.2. If enabled, bootstrap user account if settings.enableAdminAPIAuthentication { // set the RP_BOOTSTRAP_USER env var - if req.Env == nil { - req.Env = map[string]string{} - } - req.Env["RP_BOOTSTRAP_USER"] = bootstrapAdminAPIUser + ":" + bootstrapAdminAPIPassword + moduleOpts = append(moduleOpts, testcontainers.WithEnv(map[string]string{ + "RP_BOOTSTRAP_USER": bootstrapAdminAPIUser + ":" + bootstrapAdminAPIPassword, + })) // add our internal bootstrap admin user to superusers settings.Superusers = append(settings.Superusers, bootstrapAdminAPIUser) @@ -134,9 +119,7 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom // 3. Register extra kafka listeners if provided, network aliases will be // set - if err := registerListeners(settings, req); err != nil { - return nil, fmt.Errorf("register listeners: %w", err) - } + moduleOpts = append(moduleOpts, registerListeners(settings)) // Bootstrap config file contains cluster configurations which will only be considered // the very first time you start a cluster. @@ -150,7 +133,7 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom // We have to do this kind of two-step process, because we need to know the mapped // port, so that we can use this in Redpanda's advertised listeners configuration for // the Kafka API. - req.Files = append(req.Files, + moduleOpts = append(moduleOpts, testcontainers.WithFiles( testcontainers.ContainerFile{ Reader: bytes.NewReader(entrypoint), ContainerFilePath: entrypointFile, @@ -161,11 +144,11 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom ContainerFilePath: path.Join(redpandaDir, bootstrapConfigFile), FileMode: 600, }, - ) + )) // 4. Create certificate and key for TLS connections. if settings.EnableTLS { - req.Files = append(req.Files, + moduleOpts = append(moduleOpts, testcontainers.WithFiles( testcontainers.ContainerFile{ Reader: bytes.NewReader(settings.cert), ContainerFilePath: path.Join(redpandaDir, certFile), @@ -176,10 +159,10 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom ContainerFilePath: path.Join(redpandaDir, keyFile), FileMode: 600, }, - ) + )) } - ctr, err := testcontainers.GenericContainer(ctx, req) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container if ctr != nil { c = &Container{Container: ctr} @@ -326,25 +309,27 @@ func renderBootstrapConfig(settings options) ([]byte, error) { // registerListeners validates that the provided listeners are valid and set network aliases for the provided addresses. // The container must be attached to at least one network. -func registerListeners(settings options, req testcontainers.GenericContainerRequest) error { - if len(settings.Listeners) == 0 { - return nil - } - - if len(req.Networks) == 0 { - return errors.New("container must be attached to at least one network") - } +func registerListeners(settings options) testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + if len(settings.Listeners) == 0 { + return nil + } - for _, listener := range settings.Listeners { - if listener.Port < 0 || listener.Port > math.MaxUint16 { - return fmt.Errorf("invalid port on listener %s:%d (must be between 0 and 65535)", listener.Address, listener.Port) + if len(req.Networks) == 0 { + return errors.New("container must be attached to at least one network") } - for _, network := range req.Networks { - req.NetworkAliases[network] = append(req.NetworkAliases[network], listener.Address) + for _, listener := range settings.Listeners { + if listener.Port < 0 || listener.Port > math.MaxUint16 { + return fmt.Errorf("invalid port on listener %s:%d (must be between 0 and 65535)", listener.Address, listener.Port) + } + + for _, network := range req.Networks { + req.NetworkAliases[network] = append(req.NetworkAliases[network], listener.Address) + } } + return nil } - return nil } // renderNodeConfig renders the redpanda.yaml node config and returns it as diff --git a/modules/redpanda/redpanda_test.go b/modules/redpanda/redpanda_test.go index a315c67f95..e39e314435 100644 --- a/modules/redpanda/redpanda_test.go +++ b/modules/redpanda/redpanda_test.go @@ -575,22 +575,12 @@ func TestRedpandaListener_Simple(t *testing.T) { // 3. Start KCat container // withListenerKcat { - kcat, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "confluentinc/cp-kcat:7.4.1", - Networks: []string{ - rpNetwork.Name, - }, - Entrypoint: []string{ - "sh", - }, - Cmd: []string{ - "-c", - "tail -f /dev/null", - }, - }, - Started: true, - }) + kcatOpts := []testcontainers.ContainerCustomizer{ + network.WithNetwork([]string{"kcat"}, rpNetwork), + testcontainers.WithEntrypoint("sh"), + testcontainers.WithCmd("-c", "tail -f /dev/null"), + } + kcat, err := testcontainers.Run(ctx, "confluentinc/cp-kcat:7.4.1", kcatOpts...) // } testcontainers.CleanupContainer(t, kcat) require.NoError(t, err) diff --git a/modules/registry/examples_test.go b/modules/registry/examples_test.go index 8742456eef..ad264c6e76 100644 --- a/modules/registry/examples_test.go +++ b/modules/registry/examples_test.go @@ -73,20 +73,19 @@ func ExampleRun_withAuthentication() { // build a custom redis image from the private registry, // using RegistryName of the container as the registry. - redisC, err := testcontainers.GenericContainer(context.Background(), testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - FromDockerfile: testcontainers.FromDockerfile{ - Context: filepath.Join("testdata", "redis"), - BuildArgs: map[string]*string{ - "REGISTRY_HOST": ®istryHost, - }, + redisOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithDockerfile(testcontainers.FromDockerfile{ + Context: filepath.Join("testdata", "redis"), + BuildArgs: map[string]*string{ + "REGISTRY_HOST": ®istryHost, }, - AlwaysPullImage: true, // make sure the authentication takes place - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - }, - Started: true, - }) + }), + testcontainers.WithAlwaysPull(), // make sure the authentication takes place + testcontainers.WithExposedPorts("6379/tcp"), + testcontainers.WithWaitStrategy(wait.ForLog("Ready to accept connections")), + } + + redisC, err := testcontainers.Run(context.Background(), "", redisOpts...) defer func() { if err := testcontainers.TerminateContainer(redisC); err != nil { log.Printf("failed to terminate container: %s", err) @@ -155,22 +154,21 @@ func ExampleRun_pushImage() { repo := registryContainer.RegistryName + "/customredis" tag := "v1.2.3" - redisC, err := testcontainers.GenericContainer(context.Background(), testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - FromDockerfile: testcontainers.FromDockerfile{ - Context: filepath.Join("testdata", "redis"), - BuildArgs: map[string]*string{ - "REGISTRY_HOST": ®istryHost, - }, - Repo: repo, - Tag: tag, + redisOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithDockerfile(testcontainers.FromDockerfile{ + Context: filepath.Join("testdata", "redis"), + BuildArgs: map[string]*string{ + "REGISTRY_HOST": ®istryHost, }, - AlwaysPullImage: true, // make sure the authentication takes place - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - }, - Started: true, - }) + Repo: repo, + Tag: tag, + }), + testcontainers.WithAlwaysPull(), // make sure the authentication takes place + testcontainers.WithExposedPorts("6379/tcp"), + testcontainers.WithWaitStrategy(wait.ForLog("Ready to accept connections")), + } + + redisC, err := testcontainers.Run(context.Background(), "", redisOpts...) defer func() { if err := testcontainers.TerminateContainer(redisC); err != nil { log.Printf("failed to terminate container: %s", err) @@ -205,14 +203,12 @@ func ExampleRun_pushImage() { } // } - newRedisC, err := testcontainers.GenericContainer(context.Background(), testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: newImage, - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - }, - Started: true, - }) + newRedisOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("6379/tcp"), + testcontainers.WithWaitStrategy(wait.ForLog("Ready to accept connections")), + } + + newRedisC, err := testcontainers.Run(context.Background(), newImage, newRedisOpts...) defer func() { if err := testcontainers.TerminateContainer(newRedisC); err != nil { log.Printf("failed to terminate container: %s", err) diff --git a/modules/registry/registry.go b/modules/registry/registry.go index 9f5ac0208f..45f77a2c76 100644 --- a/modules/registry/registry.go +++ b/modules/registry/registry.go @@ -213,36 +213,26 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Registry container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*RegistryContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{registryPort}, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithEnv(map[string]string{ // convenient for testing "REGISTRY_STORAGE_DELETE_ENABLED": "true", - }, - WaitingFor: wait.ForHTTP("/"). + }), + testcontainers.WithExposedPorts(registryPort), + testcontainers.WithWaitStrategy(wait.ForHTTP("/"). WithPort(registryPort). - WithStartupTimeout(10 * time.Second), + WithStartupTimeout(10 * time.Second)), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *RegistryContainer - if container != nil { - c = &RegistryContainer{Container: container} + if ctr != nil { + c = &RegistryContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } address, err := c.Address(ctx) diff --git a/modules/registry/registry_test.go b/modules/registry/registry_test.go index 46f7d6dcc2..e7fa7ac881 100644 --- a/modules/registry/registry_test.go +++ b/modules/registry/registry_test.go @@ -93,20 +93,19 @@ func TestRunContainer_authenticated(t *testing.T) { t.Run("build images with wrong credentials fails", func(tt *testing.T) { setAuthConfig(tt, registryHost, "foo", "bar") - redisC, err := testcontainers.GenericContainer(context.Background(), testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - FromDockerfile: testcontainers.FromDockerfile{ - Context: filepath.Join("testdata", "redis"), - BuildArgs: map[string]*string{ - "REGISTRY_HOST": ®istryHost, - }, + redisOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithDockerfile(testcontainers.FromDockerfile{ + Context: filepath.Join("testdata", "redis"), + BuildArgs: map[string]*string{ + "REGISTRY_HOST": ®istryHost, }, - AlwaysPullImage: true, // make sure the authentication takes place - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - }, - Started: true, - }) + }), + testcontainers.WithAlwaysPull(), // make sure the authentication takes place + testcontainers.WithExposedPorts("6379/tcp"), + testcontainers.WithWaitStrategy(wait.ForLog("Ready to accept connections")), + } + + redisC, err := testcontainers.Run(context.Background(), "", redisOpts...) testcontainers.CleanupContainer(tt, redisC) require.Error(tt, err) require.Contains(tt, err.Error(), "unauthorized: authentication required") @@ -120,20 +119,19 @@ func TestRunContainer_authenticated(t *testing.T) { // The container should start because the authentication // is correct. - redisC, err := testcontainers.GenericContainer(context.Background(), testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - FromDockerfile: testcontainers.FromDockerfile{ - Context: filepath.Join("testdata", "redis"), - BuildArgs: map[string]*string{ - "REGISTRY_HOST": ®istryHost, - }, + redisOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithDockerfile(testcontainers.FromDockerfile{ + Context: filepath.Join("testdata", "redis"), + BuildArgs: map[string]*string{ + "REGISTRY_HOST": ®istryHost, }, - AlwaysPullImage: true, // make sure the authentication takes place - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - }, - Started: true, - }) + }), + testcontainers.WithAlwaysPull(), // make sure the authentication takes place + testcontainers.WithExposedPorts("6379/tcp"), + testcontainers.WithWaitStrategy(wait.ForLog("Ready to accept connections")), + } + + redisC, err := testcontainers.Run(context.Background(), "", redisOpts...) testcontainers.CleanupContainer(tt, redisC) require.NoError(tt, err) @@ -192,20 +190,19 @@ func TestRunContainer_wrongData(t *testing.T) { // The container won't be able to start because the data // directory is wrong. - redisC, err := testcontainers.GenericContainer(context.Background(), testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - FromDockerfile: testcontainers.FromDockerfile{ - Context: filepath.Join("testdata", "redis"), - BuildArgs: map[string]*string{ - "REGISTRY_HOST": ®istryHost, - }, + redisOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithDockerfile(testcontainers.FromDockerfile{ + Context: filepath.Join("testdata", "redis"), + BuildArgs: map[string]*string{ + "REGISTRY_HOST": ®istryHost, }, - AlwaysPullImage: true, // make sure the authentication takes place - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), - }, - Started: true, - }) + }), + testcontainers.WithAlwaysPull(), // make sure the authentication takes place + testcontainers.WithExposedPorts("6379/tcp"), + testcontainers.WithWaitStrategy(wait.ForLog("Ready to accept connections")), + } + + redisC, err := testcontainers.Run(context.Background(), "", redisOpts...) testcontainers.CleanupContainer(t, redisC) require.ErrorContains(t, err, "manifest unknown") } diff --git a/modules/scylladb/scylladb.go b/modules/scylladb/scylladb.go index bf7c604014..99149219ba 100644 --- a/modules/scylladb/scylladb.go +++ b/modules/scylladb/scylladb.go @@ -108,43 +108,28 @@ func (c Container) AlternatorConnectionHost(ctx context.Context) (string, error) // Run starts a ScyllaDB container with the specified image and options func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{port}, - Cmd: []string{ - "--developer-mode=1", - "--overprovisioned=1", - "--smp=1", - "--memory=512M", - }, - WaitingFor: wait.ForAll( + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithCmd("--developer-mode=1", "--overprovisioned=1", "--smp=1", "--memory=512M"), + testcontainers.WithExposedPorts(port), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForListeningPort(port), wait.ForExec([]string{"cqlsh", "-e", "SELECT bootstrapped FROM system.local"}).WithResponseMatcher(func(body io.Reader) bool { data, _ := io.ReadAll(body) return strings.Contains(string(data), "COMPLETED") }), - ), + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container - if container != nil { - c = &Container{Container: container} + if ctr != nil { + c = &Container{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/socat/examples_test.go b/modules/socat/examples_test.go index 8c2d70be49..3341fdf59b 100644 --- a/modules/socat/examples_test.go +++ b/modules/socat/examples_test.go @@ -26,17 +26,12 @@ func ExampleRun() { } }() - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "testcontainers/helloworld:1.2.0", - ExposedPorts: []string{"8080/tcp"}, - Networks: []string{nw.Name}, - NetworkAliases: map[string][]string{ - nw.Name: {"helloworld"}, - }, - }, - Started: true, - }) + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8080/tcp"), + network.WithNetwork([]string{"helloworld"}, nw), + } + + ctr, err := testcontainers.Run(ctx, "testcontainers/helloworld:1.2.0", moduleOpts...) if err != nil { log.Printf("failed to create container: %v", err) return @@ -106,17 +101,11 @@ func ExampleRun_multipleTargets() { // } // createHelloWorldContainer { - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "testcontainers/helloworld:1.2.0", - ExposedPorts: []string{"8080/tcp"}, - Networks: []string{nw.Name}, - NetworkAliases: map[string][]string{ - nw.Name: {"helloworld"}, - }, - }, - Started: true, - }) + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8080/tcp"), + network.WithNetwork([]string{"helloworld"}, nw), + } + ctr, err := testcontainers.Run(ctx, "testcontainers/helloworld:1.2.0", moduleOpts...) if err != nil { log.Printf("failed to create container: %v", err) return diff --git a/modules/socat/socat.go b/modules/socat/socat.go index 2e36f914cb..8dc53f7caf 100644 --- a/modules/socat/socat.go +++ b/modules/socat/socat.go @@ -29,14 +29,12 @@ type Container struct { // Run creates an instance of the Socat container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: img, - Entrypoint: []string{"/bin/sh"}, - }, - Started: true, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithEntrypoint("/bin/sh"), } + moduleOpts = append(moduleOpts, opts...) + // Gather all config options (defaults and then apply provided options) settings := defaultOptions() for _, opt := range opts { @@ -45,27 +43,30 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom return nil, err } } - if err := opt.Customize(&req); err != nil { - return nil, err - } } + exposedPorts := []string{} + for k := range settings.targets { - req.ExposedPorts = append(req.ExposedPorts, fmt.Sprintf("%d/tcp", k)) + exposedPorts = append(exposedPorts, fmt.Sprintf("%d/tcp", k)) } + moduleOpts = append(moduleOpts, testcontainers.WithExposedPorts(exposedPorts...)) + if settings.targetsCmd != "" { - req.Cmd = append(req.Cmd, "-c", settings.targetsCmd) + moduleOpts = append(moduleOpts, testcontainers.WithCmdArgs("-c", settings.targetsCmd)) } - container, err := testcontainers.GenericContainer(ctx, req) + moduleOpts = append(moduleOpts, opts...) + + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container - if container != nil { - c = &Container{Container: container} + if ctr != nil { + c = &Container{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } // Only check if the socat binary is available if there are targets to expose. diff --git a/modules/socat/socat_test.go b/modules/socat/socat_test.go index 414cc4d7b6..d7195be2ba 100644 --- a/modules/socat/socat_test.go +++ b/modules/socat/socat_test.go @@ -30,17 +30,10 @@ func TestRun_helloWorld(t *testing.T) { testcontainers.CleanupNetwork(t, nw) require.NoError(t, err) - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "testcontainers/helloworld:1.2.0", - ExposedPorts: []string{"8080/tcp"}, - Networks: []string{nw.Name}, - NetworkAliases: map[string][]string{ - nw.Name: {"helloworld"}, - }, - }, - Started: true, - }) + ctr, err := testcontainers.Run(ctx, "testcontainers/helloworld:1.2.0", + testcontainers.WithExposedPorts("8080/tcp"), + network.WithNetwork([]string{"helloworld"}, nw), + ) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) @@ -78,17 +71,10 @@ func TestRun_helloWorldDifferentPort(t *testing.T) { testcontainers.CleanupNetwork(t, nw) require.NoError(t, err) - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "testcontainers/helloworld:1.2.0", - ExposedPorts: []string{"8080/tcp"}, - Networks: []string{nw.Name}, - NetworkAliases: map[string][]string{ - nw.Name: {"helloworld"}, - }, - }, - Started: true, - }) + ctr, err := testcontainers.Run(ctx, "testcontainers/helloworld:1.2.0", + testcontainers.WithExposedPorts("8080/tcp"), + network.WithNetwork([]string{"helloworld"}, nw), + ) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) @@ -132,17 +118,12 @@ func TestRun_helloWorld_WrongImage(t *testing.T) { testcontainers.CleanupNetwork(t, nw) require.NoError(t, err) - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "testcontainers/helloworld:1.2.0", - ExposedPorts: []string{"8080/tcp"}, - Networks: []string{nw.Name}, - NetworkAliases: map[string][]string{ - nw.Name: {"helloworld"}, - }, - }, - Started: true, - }) + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8080/tcp"), + network.WithNetwork([]string{"helloworld"}, nw), + } + + ctr, err := testcontainers.Run(ctx, "testcontainers/helloworld:1.2.0", moduleOpts...) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) @@ -167,17 +148,10 @@ func TestRun_multipleTargets(t *testing.T) { testcontainers.CleanupNetwork(t, nw) require.NoError(t, err) - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "testcontainers/helloworld:1.2.0", - ExposedPorts: []string{"8080/tcp"}, - Networks: []string{nw.Name}, - NetworkAliases: map[string][]string{ - nw.Name: {"helloworld"}, - }, - }, - Started: true, - }) + ctr, err := testcontainers.Run(ctx, "testcontainers/helloworld:1.2.0", + testcontainers.WithExposedPorts("8080/tcp"), + network.WithNetwork([]string{"helloworld"}, nw), + ) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) diff --git a/modules/surrealdb/surrealdb.go b/modules/surrealdb/surrealdb.go index 43ba20c421..feee144af8 100644 --- a/modules/surrealdb/surrealdb.go +++ b/modules/surrealdb/surrealdb.go @@ -80,42 +80,30 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the SurrealDB container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*SurrealDBContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithCmd("start"), + testcontainers.WithEnv(map[string]string{ "SURREAL_USER": "root", "SURREAL_PASS": "root", "SURREAL_AUTH": "false", "SURREAL_STRICT": "false", "SURREAL_CAPS_ALLOW_ALL": "false", "SURREAL_PATH": "memory", - }, - ExposedPorts: []string{"8000/tcp"}, - WaitingFor: wait.ForAll( - wait.ForLog("Started web server on "), - ), - Cmd: []string{"start"}, + }), + testcontainers.WithExposedPorts("8000/tcp"), + testcontainers.WithWaitStrategy(wait.ForLog("Started web server on ")), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *SurrealDBContainer - if container != nil { - c = &SurrealDBContainer{Container: container} + if ctr != nil { + c = &SurrealDBContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/toxiproxy/toxiproxy.go b/modules/toxiproxy/toxiproxy.go index d0157020db..708e78202f 100644 --- a/modules/toxiproxy/toxiproxy.go +++ b/modules/toxiproxy/toxiproxy.go @@ -54,29 +54,22 @@ func (c *Container) URI(ctx context.Context) (string, error) { // Run creates an instance of the Toxiproxy container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{ControlPort}, - WaitingFor: wait.ForHTTP("/version").WithPort(ControlPort).WithStatusCodeMatcher(func(status int) bool { + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(ControlPort), + testcontainers.WithWaitStrategy(wait.ForHTTP("/version").WithPort(ControlPort).WithStatusCodeMatcher(func(status int) bool { return status == http.StatusOK - }), + })), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) settings := defaultOptions() for _, opt := range opts { if apply, ok := opt.(Option); ok { if err := apply(&settings); err != nil { - return nil, fmt.Errorf("apply: %w", err) + return nil, fmt.Errorf("toxiproxy option: %w", err) } } - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } } // Expose the ports for the proxies, starting from the first proxied port @@ -87,7 +80,7 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom proxy.Listen = fmt.Sprintf("0.0.0.0:%d", proxiedPort) portsInRange = append(portsInRange, fmt.Sprintf("%d/tcp", proxiedPort)) } - genericContainerReq.ExposedPorts = append(genericContainerReq.ExposedPorts, portsInRange...) + moduleOpts = append(moduleOpts, testcontainers.WithExposedPorts(portsInRange...)) // Render the config file jsonData, err := json.MarshalIndent(settings.proxies, "", " ") @@ -97,22 +90,22 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom // Apply the config file to the container with the proxies. if len(settings.proxies) > 0 { - genericContainerReq.Files = append(genericContainerReq.Files, testcontainers.ContainerFile{ + moduleOpts = append(moduleOpts, testcontainers.WithFiles(testcontainers.ContainerFile{ Reader: bytes.NewReader(jsonData), ContainerFilePath: "/tmp/tc-toxiproxy.json", FileMode: 0o644, - }) - genericContainerReq.Cmd = append(genericContainerReq.Cmd, "-host=0.0.0.0", "-config=/tmp/tc-toxiproxy.json") + })) + moduleOpts = append(moduleOpts, testcontainers.WithCmdArgs("-host=0.0.0.0", "-config=/tmp/tc-toxiproxy.json")) } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container - if container != nil { - c = &Container{Container: container, proxiedEndpoints: make(map[int]string)} + if ctr != nil { + c = &Container{Container: ctr, proxiedEndpoints: make(map[int]string)} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } // Map the ports of the proxies to the container, so that we can use them in the tests diff --git a/modules/valkey/valkey.go b/modules/valkey/valkey.go index 674a4701df..7d7f18317d 100644 --- a/modules/valkey/valkey.go +++ b/modules/valkey/valkey.go @@ -61,27 +61,21 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Valkey container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*ValkeyContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{valkeyPort}, + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(valkeyPort), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) var settings options for _, opt := range opts { if opt, ok := opt.(Option); ok { if err := opt(&settings); err != nil { - return nil, err + return nil, fmt.Errorf("valkey option: %w", err) } } } - tcOpts := []testcontainers.ContainerCustomizer{} - waitStrategies := []wait.Strategy{ wait.ForListeningPort(valkeyPort).WithStartupTimeout(time.Second * 10), wait.ForLog("* Ready to accept connections"), @@ -109,8 +103,8 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom "--tls-auth-clients", "yes", } - tcOpts = append(tcOpts, testcontainers.WithCmdArgs(cmds...)) // Append the default CMD with the TLS certificates. - tcOpts = append(tcOpts, testcontainers.WithFiles( + moduleOpts = append(moduleOpts, testcontainers.WithCmdArgs(cmds...)) // Append the default CMD with the TLS certificates. + moduleOpts = append(moduleOpts, testcontainers.WithFiles( testcontainers.ContainerFile{ Reader: bytes.NewReader(caCert.Bytes), ContainerFilePath: "/tls/ca.crt", @@ -135,26 +129,16 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom } } - tcOpts = append(tcOpts, testcontainers.WithWaitStrategy(waitStrategies...)) - - // Append the customizers passed to the Run function. - tcOpts = append(tcOpts, opts...) - - // Apply the testcontainers customizers. - for _, opt := range tcOpts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, testcontainers.WithWaitStrategy(waitStrategies...)) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *ValkeyContainer - if container != nil { - c = &ValkeyContainer{Container: container, settings: settings} + if ctr != nil { + c = &ValkeyContainer{Container: ctr, settings: settings} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/vault/vault.go b/modules/vault/vault.go index c0ac52d716..decf7cd4e8 100644 --- a/modules/vault/vault.go +++ b/modules/vault/vault.go @@ -28,37 +28,27 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Vault container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*VaultContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{defaultPort + "/tcp"}, - HostConfigModifier: func(hc *container.HostConfig) { - hc.CapAdd = []string{"CAP_IPC_LOCK"} - }, - WaitingFor: wait.ForHTTP("/v1/sys/health").WithPort(defaultPort), - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(defaultPort + "/tcp"), + testcontainers.WithEnv(map[string]string{ "VAULT_ADDR": "http://0.0.0.0:" + defaultPort, - }, - } - - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, + }), + testcontainers.WithWaitStrategy(wait.ForHTTP("/v1/sys/health").WithPort(defaultPort)), + testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) { + hc.CapAdd = []string{"CAP_IPC_LOCK"} + }), } - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *VaultContainer - if container != nil { - c = &VaultContainer{Container: container} + if ctr != nil { + c = &VaultContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/vearch/vearch.go b/modules/vearch/vearch.go index 7735f7dc47..f8d76d4c64 100644 --- a/modules/vearch/vearch.go +++ b/modules/vearch/vearch.go @@ -24,45 +24,33 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Vearch container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*VearchContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - ExposedPorts: []string{"8817/tcp", "9001/tcp"}, - Cmd: []string{"-conf=/vearch/config.toml", "all"}, - HostConfigModifier: func(hc *container.HostConfig) { + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8817/tcp", "9001/tcp"), + testcontainers.WithCmd("-conf=/vearch/config.toml", "all"), + testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) { hc.Privileged = true - }, - Files: []testcontainers.ContainerFile{ - { - HostFilePath: "config.toml", - ContainerFilePath: "/vearch/config.toml", - FileMode: 0o666, - }, - }, - WaitingFor: wait.ForAll( + }), + testcontainers.WithFiles(testcontainers.ContainerFile{ + HostFilePath: "config.toml", + ContainerFilePath: "/vearch/config.toml", + FileMode: 0o666, + }), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForListeningPort("8817/tcp").WithStartupTimeout(5*time.Second), wait.ForListeningPort("9001/tcp").WithStartupTimeout(5*time.Second), - ), + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *VearchContainer - if container != nil { - c = &VearchContainer{Container: container} + if ctr != nil { + c = &VearchContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/weaviate/weaviate.go b/modules/weaviate/weaviate.go index 8651e6bb69..4da195857f 100644 --- a/modules/weaviate/weaviate.go +++ b/modules/weaviate/weaviate.go @@ -27,40 +27,30 @@ func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomize // Run creates an instance of the Weaviate container type func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*WeaviateContainer, error) { - req := testcontainers.ContainerRequest{ - Image: img, - Cmd: []string{"--host", "0.0.0.0", "--scheme", "http", "--port", "8080"}, - ExposedPorts: []string{httpPort, grpcPort}, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithCmd("--host", "0.0.0.0", "--scheme", "http", "--port", "8080"), + testcontainers.WithExposedPorts(httpPort, grpcPort), + testcontainers.WithEnv(map[string]string{ "AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED": "true", "PERSISTENCE_DATA_PATH": "/var/lib/weaviate", - }, - WaitingFor: wait.ForAll( + }), + testcontainers.WithWaitStrategy(wait.ForAll( wait.ForListeningPort(httpPort).WithStartupTimeout(5*time.Second), wait.ForListeningPort(grpcPort).WithStartupTimeout(5*time.Second), wait.ForHTTP("/v1/.well-known/ready").WithPort(httpPort), - ), + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } - - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *WeaviateContainer - if container != nil { - c = &WeaviateContainer{Container: container} + if ctr != nil { + c = &WeaviateContainer{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/modules/yugabytedb/options.go b/modules/yugabytedb/options.go index 485b979468..077b9f7684 100644 --- a/modules/yugabytedb/options.go +++ b/modules/yugabytedb/options.go @@ -4,50 +4,79 @@ import ( "github.com/testcontainers/testcontainers-go" ) +type options struct { + envs map[string]string +} + +func defaultOptions() options { + return options{ + envs: map[string]string{ + ycqlKeyspaceEnv: ycqlKeyspace, + ycqlUserNameEnv: ycqlUserName, + ycqlPasswordEnv: ycqlPassword, + ysqlDatabaseNameEnv: ysqlDatabaseName, + ysqlDatabaseUserEnv: ysqlDatabaseUser, + ysqlDatabasePasswordEnv: ysqlDatabasePassword, + }, + } +} + +// Compiler check to ensure that Option implements the testcontainers.ContainerCustomizer interface. +var _ testcontainers.ContainerCustomizer = (Option)(nil) + +// Option is an option for the Redpanda container. +type Option func(*options) error + +// Customize is a NOOP. It's defined to satisfy the testcontainers.ContainerCustomizer interface. +func (o Option) Customize(*testcontainers.GenericContainerRequest) error { + // NOOP to satisfy interface. + return nil +} + // WithDatabaseName sets the initial database name for the yugabyteDB container. -func WithDatabaseName(dbName string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env[ysqlDatabaseNameEnv] = dbName +func WithDatabaseName(dbName string) Option { + return func(o *options) error { + o.envs[ysqlDatabaseNameEnv] = dbName return nil } } // WithDatabaseUser sets the initial database user for the yugabyteDB container. -func WithDatabaseUser(dbUser string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env[ysqlDatabaseUserEnv] = dbUser +func WithDatabaseUser(dbUser string) Option { + return func(o *options) error { + o.envs[ysqlDatabaseUserEnv] = dbUser return nil } } // WithDatabasePassword sets the initial database password for the yugabyteDB container. -func WithDatabasePassword(dbPassword string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env[ysqlDatabasePasswordEnv] = dbPassword +func WithDatabasePassword(dbPassword string) Option { + return func(o *options) error { + o.envs[ysqlDatabasePasswordEnv] = dbPassword return nil } } // WithKeyspace sets the initial keyspace for the yugabyteDB container. -func WithKeyspace(keyspace string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env[ycqlKeyspaceEnv] = keyspace +func WithKeyspace(keyspace string) Option { + return func(o *options) error { + o.envs[ycqlKeyspaceEnv] = keyspace return nil } } // WithUser sets the initial user for the yugabyteDB container. -func WithUser(user string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env[ycqlUserNameEnv] = user +func WithUser(user string) Option { + return func(o *options) error { + o.envs[ycqlUserNameEnv] = user return nil } } // WithPassword sets the initial password for the yugabyteDB container. -func WithPassword(password string) testcontainers.CustomizeRequestOption { - return func(req *testcontainers.GenericContainerRequest) error { - req.Env[ycqlPasswordEnv] = password +func WithPassword(password string) Option { + return func(o *options) error { + o.envs[ycqlPasswordEnv] = password return nil } } diff --git a/modules/yugabytedb/yugabytedb.go b/modules/yugabytedb/yugabytedb.go index 8a49d7fb0d..51e82ee6f6 100644 --- a/modules/yugabytedb/yugabytedb.go +++ b/modules/yugabytedb/yugabytedb.go @@ -49,50 +49,51 @@ type Container struct { // [*Container.YSQLConnectionString] and [*Container.YCQLConfigureClusterConfig] // methods to use the container in their respective clients. func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - Cmd: []string{"bin/yugabyted", "start", "--background=false"}, - WaitingFor: wait.ForAll( - wait.ForLog("YugabyteDB Started").WithOccurrence(1), - wait.ForLog("Data placement constraint successfully verified").WithOccurrence(1), - wait.ForListeningPort(ysqlPort), - wait.ForListeningPort(ycqlPort), - ), - ExposedPorts: []string{ycqlPort, ysqlPort}, - Env: map[string]string{ + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithCmd("bin/yugabyted", "start", "--background=false"), + testcontainers.WithExposedPorts(ycqlPort, ysqlPort), + testcontainers.WithEnv(map[string]string{ ycqlKeyspaceEnv: ycqlKeyspace, ycqlUserNameEnv: ycqlUserName, ycqlPasswordEnv: ycqlPassword, ysqlDatabaseNameEnv: ysqlDatabaseName, ysqlDatabaseUserEnv: ysqlDatabaseUser, ysqlDatabasePasswordEnv: ysqlDatabasePassword, - }, + }), + testcontainers.WithWaitStrategy(wait.ForAll( + wait.ForLog("YugabyteDB Started").WithOccurrence(1), + wait.ForLog("Data placement constraint successfully verified").WithOccurrence(1), + wait.ForListeningPort(ysqlPort), + wait.ForListeningPort(ycqlPort), + )), } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + moduleOpts = append(moduleOpts, opts...) + settings := defaultOptions() for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) + if apply, ok := opt.(Option); ok { + if err := apply(&settings); err != nil { + return nil, fmt.Errorf("yugabytedb option: %w", err) + } } } - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + moduleOpts = append(moduleOpts, testcontainers.WithEnv(settings.envs)) + + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container - if container != nil { + if ctr != nil { c = &Container{ - Container: container, - ysqlDatabaseName: req.Env[ysqlDatabaseNameEnv], - ysqlDatabaseUser: req.Env[ysqlDatabaseUserEnv], - ysqlDatabasePassword: req.Env[ysqlDatabasePasswordEnv], + Container: ctr, + ysqlDatabaseName: settings.envs[ysqlDatabaseNameEnv], + ysqlDatabaseUser: settings.envs[ysqlDatabaseUserEnv], + ysqlDatabasePassword: settings.envs[ysqlDatabasePasswordEnv], } } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run: %w", err) } return c, nil diff --git a/mounts_test.go b/mounts_test.go index d155618b12..954b90e6d3 100644 --- a/mounts_test.go +++ b/mounts_test.go @@ -222,26 +222,20 @@ func TestContainerMounts_PrepareMounts(t *testing.T) { } func TestCreateContainerWithVolume(t *testing.T) { - volumeName := "test-volume" // volumeMounts { - req := testcontainers.ContainerRequest{ - Image: "alpine", - Mounts: testcontainers.ContainerMounts{ - { - Source: testcontainers.GenericVolumeMountSource{ - Name: volumeName, - }, - Target: "/data", - }, - }, - } - // } + volumeName := "test-volume" ctx := context.Background() - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + + c, err := testcontainers.Run(ctx, "alpine", + testcontainers.WithMounts(testcontainers.ContainerMount{ + Source: testcontainers.GenericVolumeMountSource{ + Name: volumeName, + }, + Target: "/data", + }), + ) + // } testcontainers.CleanupContainer(t, c, testcontainers.RemoveVolumes(volumeName)) require.NoError(t, err) @@ -257,17 +251,6 @@ func TestCreateContainerWithVolume(t *testing.T) { func TestMountsReceiveRyukLabels(t *testing.T) { volumeName := "app-data" - req := testcontainers.ContainerRequest{ - Image: "alpine", - Mounts: testcontainers.ContainerMounts{ - { - Source: testcontainers.GenericVolumeMountSource{ - Name: volumeName, - }, - Target: "/data", - }, - }, - } ctx := context.Background() client, err := testcontainers.NewDockerClientWithOpts(ctx) @@ -279,10 +262,14 @@ func TestMountsReceiveRyukLabels(t *testing.T) { err = client.VolumeRemove(ctx, volumeName, true) require.NoError(t, err) - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + c, err := testcontainers.Run(ctx, "alpine", + testcontainers.WithMounts(testcontainers.ContainerMount{ + Source: testcontainers.GenericVolumeMountSource{ + Name: volumeName, + }, + Target: "/data", + }), + ) testcontainers.CleanupContainer(t, c, testcontainers.RemoveVolumes(volumeName)) require.NoError(t, err) diff --git a/network/network_test.go b/network/network_test.go index 2b5059f36c..ab02639ca9 100644 --- a/network/network_test.go +++ b/network/network_test.go @@ -2,9 +2,11 @@ package network_test import ( "context" + "os" "testing" "time" + "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" dockernetwork "github.com/docker/docker/api/types/network" "github.com/stretchr/testify/require" @@ -35,18 +37,11 @@ func TestNew(t *testing.T) { networkName := net.Name - nginxC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - "80/tcp", - }, - Networks: []string{ - networkName, - }, - }, - Started: true, - }) + nginxC, err := testcontainers.Run( + ctx, nginxAlpineImage, + testcontainers.WithExposedPorts("80/tcp"), + network.WithNetwork([]string{"nginx"}, net), + ) testcontainers.CleanupContainer(t, nginxC) require.NoError(t, err) @@ -81,23 +76,11 @@ func TestContainerAttachedToNewNetwork(t *testing.T) { aliases := []string{"alias1", "alias2", "alias3"} - gcr := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - Networks: []string{ - networkName, - }, - NetworkAliases: map[string][]string{ - networkName: aliases, - }, - }, - Started: true, - } - - nginx, err := testcontainers.GenericContainer(ctx, gcr) + nginx, err := testcontainers.Run( + ctx, nginxAlpineImage, + testcontainers.WithExposedPorts(nginxDefaultPort), + network.WithNetwork(aliases, newNetwork), + ) testcontainers.CleanupContainer(t, nginx) require.NoError(t, err) @@ -133,28 +116,30 @@ func TestContainerIPs(t *testing.T) { require.NoError(t, err) testcontainers.CleanupNetwork(t, newNetwork) - networkName := newNetwork.Name - - nginx, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - Networks: []string{ - "bridge", - networkName, - }, - WaitingFor: wait.ForListeningPort(nginxDefaultPort), - }, - Started: true, + t.Run("bridge/error/network-scoped-alias", func(t *testing.T) { + nginx, err := testcontainers.Run( + ctx, nginxAlpineImage, + testcontainers.WithExposedPorts(nginxDefaultPort), + network.WithNetworkName([]string{"nginx-bridge"}, "bridge"), + network.WithNetwork([]string{"nginx-network"}, newNetwork), + ) + testcontainers.CleanupContainer(t, nginx) + require.Error(t, err) }) - testcontainers.CleanupContainer(t, nginx) - require.NoError(t, err) - ips, err := nginx.ContainerIPs(ctx) - require.NoError(t, err) - require.Len(t, ips, 2) + t.Run("bridge/success", func(t *testing.T) { + nginx, err := testcontainers.Run( + ctx, nginxAlpineImage, + testcontainers.WithExposedPorts(nginxDefaultPort), + network.WithNetwork([]string{"nginx-network"}, newNetwork), + ) + testcontainers.CleanupContainer(t, nginx) + require.NoError(t, err) + + ips, err := nginx.ContainerIPs(ctx) + require.NoError(t, err) + require.Len(t, ips, 1) + }) } func TestContainerWithReaperNetwork(t *testing.T) { @@ -163,6 +148,15 @@ func TestContainerWithReaperNetwork(t *testing.T) { } ctx := context.Background() + + opts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts(nginxDefaultPort), + testcontainers.WithWaitStrategy(wait.ForAll( + wait.ForListeningPort(nginxDefaultPort), + wait.ForLog("Configuration complete; ready for start up"), + )), + } + networks := []string{} maxNetworksCount := 2 @@ -173,20 +167,11 @@ func TestContainerWithReaperNetwork(t *testing.T) { testcontainers.CleanupNetwork(t, n) networks = append(networks, n.Name) + + opts = append(opts, network.WithNetwork([]string{"nginx-network"}, n)) } - nginx, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{nginxDefaultPort}, - WaitingFor: wait.ForAll( - wait.ForListeningPort(nginxDefaultPort), - wait.ForLog("Configuration complete; ready for start up"), - ), - Networks: networks, - }, - Started: true, - }) + nginx, err := testcontainers.Run(ctx, nginxAlpineImage, opts...) testcontainers.CleanupContainer(t, nginx) require.NoError(t, err) @@ -212,23 +197,17 @@ func TestMultipleContainersInTheNewNetwork(t *testing.T) { networkName := net.Name - c1, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: nginxAlpineImage, - Networks: []string{networkName}, - }, - Started: true, - }) + c1, err := testcontainers.Run( + ctx, nginxAlpineImage, + network.WithNetwork([]string{"c1"}, net), + ) testcontainers.CleanupContainer(t, c1) require.NoError(t, err) - c2, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: nginxAlpineImage, - Networks: []string{networkName}, - }, - Started: true, - }) + c2, err := testcontainers.Run( + ctx, nginxAlpineImage, + network.WithNetwork([]string{"c2"}, net), + ) testcontainers.CleanupContainer(t, c2) require.NoError(t, err) @@ -273,17 +252,11 @@ func TestNew_withOptions(t *testing.T) { networkName := net.Name - nginx, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - "80/tcp", - }, - Networks: []string{ - networkName, - }, - }, - }) + nginx, err := testcontainers.Run( + ctx, nginxAlpineImage, + testcontainers.WithExposedPorts(nginxDefaultPort), + network.WithNetwork([]string{"nginx"}, net), + ) testcontainers.CleanupContainer(t, nginx) require.NoError(t, err) @@ -380,6 +353,31 @@ func TestWithNetworkName(t *testing.T) { }) } +func TestContainerWithNetworkModeAndNetworkTogether(t *testing.T) { + if os.Getenv("XDG_RUNTIME_DIR") != "" { + t.Skip("Skipping test that requires host network access when running in a container") + } + + // skipIfDockerDesktop { + ctx := context.Background() + testcontainers.SkipIfDockerDesktop(t, ctx) + // } + + nginx, err := testcontainers.Run( + ctx, nginxAlpineImage, + testcontainers.WithWaitStrategy(wait.ForExposedPort()), + network.WithNetworkName([]string{"nginx"}, "new-network"), + testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) { + hc.NetworkMode = "host" + }), + ) + testcontainers.CleanupContainer(t, nginx) + if err != nil { + // Error when NetworkMode = host and Network = []string{"bridge"} + t.Logf("Can't use Network and NetworkMode together, %s\n", err) + } +} + func TestWithSyntheticNetwork(t *testing.T) { nw := &testcontainers.DockerNetwork{ Name: "synthetic-network", @@ -387,35 +385,42 @@ func TestWithSyntheticNetwork(t *testing.T) { networkName := nw.Name - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: nginxAlpineImage, - }, - } + t.Run("unit-test", func(t *testing.T) { + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: nginxAlpineImage, + }, + } - err := network.WithNetwork([]string{"alias"}, nw)(&req) - require.NoError(t, err) + err := network.WithNetwork([]string{"alias"}, nw)(&req) + require.NoError(t, err) - require.Len(t, req.Networks, 1) - require.Equal(t, networkName, req.Networks[0]) + require.Len(t, req.Networks, 1) + require.Equal(t, networkName, req.Networks[0]) - require.Len(t, req.NetworkAliases, 1) - require.Equal(t, map[string][]string{networkName: {"alias"}}, req.NetworkAliases) + require.Len(t, req.NetworkAliases, 1) + require.Equal(t, map[string][]string{networkName: {"alias"}}, req.NetworkAliases) + }) - // verify that the network is created only once - client, err := testcontainers.NewDockerClientWithOpts(context.Background()) - require.NoError(t, err) + t.Run("integration-test", func(t *testing.T) { + // verify that the network is created only once + client, err := testcontainers.NewDockerClientWithOpts(context.Background()) + require.NoError(t, err) - resources, err := client.NetworkList(context.Background(), dockernetwork.ListOptions{ - Filters: filters.NewArgs(filters.Arg("name", networkName)), - }) - require.NoError(t, err) - require.Empty(t, resources) // no Docker network was created + resources, err := client.NetworkList(context.Background(), dockernetwork.ListOptions{ + Filters: filters.NewArgs(filters.Arg("name", networkName)), + }) + require.NoError(t, err) + require.Empty(t, resources) // no Docker network was created - c, err := testcontainers.GenericContainer(context.Background(), req) - testcontainers.CleanupContainer(t, c) - require.NoError(t, err) - require.NotNil(t, c) + c, err := testcontainers.Run( + context.Background(), nginxAlpineImage, + network.WithNetwork([]string{"alias"}, nw), + ) + testcontainers.CleanupContainer(t, c) + require.NoError(t, err) + require.NotNil(t, c) + }) } func TestWithNewNetwork(t *testing.T) { diff --git a/options.go b/options.go index f7775f8665..eadc927e48 100644 --- a/options.go +++ b/options.go @@ -2,7 +2,6 @@ package testcontainers import ( "context" - "errors" "fmt" "maps" "net/url" @@ -109,7 +108,7 @@ func WithHostPortAccess(ports ...int) CustomizeRequestOption { func WithName(containerName string) CustomizeRequestOption { return func(req *GenericContainerRequest) error { if containerName == "" { - return errors.New("container name must be provided") + return ErrReuseEmptyName } req.Name = containerName return nil diff --git a/options_test.go b/options_test.go index 4e7864a5b0..375c310d8b 100644 --- a/options_test.go +++ b/options_test.go @@ -78,26 +78,33 @@ func (lc *msgsLogConsumer) Accept(l testcontainers.Log) { } func TestWithLogConsumers(t *testing.T) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "mysql:8.0.36", - WaitingFor: wait.ForLog("port: 3306 MySQL Community Server - GPL"), - }, - Started: true, - } - lc := &msgsLogConsumer{} + t.Run("unit-test", func(t *testing.T) { + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "mysql:8.0.36", + WaitingFor: wait.ForLog("port: 3306 MySQL Community Server - GPL"), + }, + Started: true, + } - err := testcontainers.WithLogConsumers(lc)(&req) - require.NoError(t, err) + err := testcontainers.WithLogConsumers(lc)(&req) + require.NoError(t, err) + }) + + t.Run("integration-test", func(t *testing.T) { + ctx := context.Background() - ctx := context.Background() - c, err := testcontainers.GenericContainer(ctx, req) - testcontainers.CleanupContainer(t, c) - // we expect an error because the MySQL environment variables are not set - // but this is expected because we just want to test the log consumer - require.ErrorContains(t, err, "container exited with code 1") - require.NotEmpty(t, lc.msgs) + c, err := testcontainers.Run(ctx, "mysql:8.0.36", + testcontainers.WithWaitStrategy(wait.ForLog("port: 3306 MySQL Community Server - GPL")), + testcontainers.WithLogConsumers(lc), + ) + testcontainers.CleanupContainer(t, c) + // we expect an error because the MySQL environment variables are not set + // but this is expected because we just want to test the log consumer + require.ErrorContains(t, err, "container exited with code 1") + require.NotEmpty(t, lc.msgs) + }) } func TestWithLogConsumerConfig(t *testing.T) { @@ -138,61 +145,77 @@ func TestWithLogConsumerConfig(t *testing.T) { } func TestWithStartupCommand(t *testing.T) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "alpine", - Entrypoint: []string{"tail", "-f", "/dev/null"}, - }, - Started: true, - } + t.Run("unit-test", func(t *testing.T) { + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "alpine", + Entrypoint: []string{"tail", "-f", "/dev/null"}, + }, + Started: true, + } - testExec := testcontainers.NewRawCommand([]string{"touch", ".testcontainers"}, exec.WithWorkingDir("/tmp")) + testExec := testcontainers.NewRawCommand([]string{"touch", ".testcontainers"}, exec.WithWorkingDir("/tmp")) - err := testcontainers.WithStartupCommand(testExec)(&req) - require.NoError(t, err) + err := testcontainers.WithStartupCommand(testExec)(&req) + require.NoError(t, err) - require.Len(t, req.LifecycleHooks, 1) - require.Len(t, req.LifecycleHooks[0].PostStarts, 1) + require.Len(t, req.LifecycleHooks, 1) + require.Len(t, req.LifecycleHooks[0].PostStarts, 1) + }) - c, err := testcontainers.GenericContainer(context.Background(), req) - testcontainers.CleanupContainer(t, c) - require.NoError(t, err) + t.Run("integration-test", func(t *testing.T) { + c, err := testcontainers.Run( + context.Background(), "alpine", + testcontainers.WithEntrypoint("tail", "-f", "/dev/null"), + testcontainers.WithStartupCommand(testcontainers.NewRawCommand([]string{"touch", "/tmp/.testcontainers"})), + ) + testcontainers.CleanupContainer(t, c) + require.NoError(t, err) - _, reader, err := c.Exec(context.Background(), []string{"ls", "/tmp/.testcontainers"}, exec.Multiplexed()) - require.NoError(t, err) + _, reader, err := c.Exec(context.Background(), []string{"ls", "/tmp/.testcontainers"}, exec.Multiplexed()) + require.NoError(t, err) - content, err := io.ReadAll(reader) - require.NoError(t, err) - require.Equal(t, "/tmp/.testcontainers\n", string(content)) + content, err := io.ReadAll(reader) + require.NoError(t, err) + require.Equal(t, "/tmp/.testcontainers\n", string(content)) + }) } func TestWithAfterReadyCommand(t *testing.T) { - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: "alpine", - Entrypoint: []string{"tail", "-f", "/dev/null"}, - }, - Started: true, - } + t.Run("unit-test", func(t *testing.T) { + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "alpine", + Entrypoint: []string{"tail", "-f", "/dev/null"}, + }, + Started: true, + } - testExec := testcontainers.NewRawCommand([]string{"touch", "/tmp/.testcontainers"}) + testExec := testcontainers.NewRawCommand([]string{"touch", "/tmp/.testcontainers"}) - err := testcontainers.WithAfterReadyCommand(testExec)(&req) - require.NoError(t, err) + err := testcontainers.WithAfterReadyCommand(testExec)(&req) + require.NoError(t, err) - require.Len(t, req.LifecycleHooks, 1) - require.Len(t, req.LifecycleHooks[0].PostReadies, 1) + require.Len(t, req.LifecycleHooks, 1) + require.Len(t, req.LifecycleHooks[0].PostReadies, 1) + }) - c, err := testcontainers.GenericContainer(context.Background(), req) - testcontainers.CleanupContainer(t, c) - require.NoError(t, err) + t.Run("integration-test", func(t *testing.T) { + c, err := testcontainers.Run( + context.Background(), "alpine", + testcontainers.WithEntrypoint("tail", "-f", "/dev/null"), + testcontainers.WithAfterReadyCommand(testcontainers.NewRawCommand([]string{"touch", "/tmp/.testcontainers"})), + ) + testcontainers.CleanupContainer(t, c) + require.NoError(t, err) - _, reader, err := c.Exec(context.Background(), []string{"ls", "/tmp/.testcontainers"}, exec.Multiplexed()) - require.NoError(t, err) + _, reader, err := c.Exec(context.Background(), []string{"ls", "/tmp/.testcontainers"}, exec.Multiplexed()) + require.NoError(t, err) - content, err := io.ReadAll(reader) - require.NoError(t, err) - require.Equal(t, "/tmp/.testcontainers\n", string(content)) + content, err := io.ReadAll(reader) + require.NoError(t, err) + require.Equal(t, "/tmp/.testcontainers\n", string(content)) + }) } func TestWithEnv(t *testing.T) { @@ -753,7 +776,7 @@ func TestWithReuseByName_ErrorsWithoutContainerNameProvided(t *testing.T) { opt := testcontainers.WithReuseByName("") err := opt.Customize(req) - require.ErrorContains(t, err, "container name must be provided") + require.ErrorIs(t, err, testcontainers.ErrReuseEmptyName) require.False(t, req.Reuse) require.Empty(t, req.Name) } @@ -772,7 +795,7 @@ func TestWithName(t *testing.T) { opt := testcontainers.WithName("") err := opt.Customize(req) - require.ErrorContains(t, err, "container name must be provided") + require.ErrorIs(t, err, testcontainers.ErrReuseEmptyName) }) } diff --git a/parallel.go b/parallel.go index a75d011f9d..4c41708f5d 100644 --- a/parallel.go +++ b/parallel.go @@ -45,7 +45,7 @@ func parallelContainersRunner( ) { defer wg.Done() for req := range requests { - c, err := GenericContainer(ctx, req) + c, err := genericContainer(ctx, req) res := parallelContainersResult{Container: c} if err != nil { res.Request = req diff --git a/port_forwarding.go b/port_forwarding.go index 107bd42d1b..635f6de597 100644 --- a/port_forwarding.go +++ b/port_forwarding.go @@ -176,30 +176,22 @@ func exposeHostPorts(ctx context.Context, req *ContainerRequest, ports ...int) ( // newSshdContainer creates a new SSHD container with the provided options. func newSshdContainer(ctx context.Context, opts ...ContainerCustomizer) (*sshdContainer, error) { - req := GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - Image: sshdImage, - ExposedPorts: []string{sshPort}, - Env: map[string]string{"PASSWORD": sshPassword}, - WaitingFor: wait.ForListeningPort(sshPort), - }, - Started: true, + moduleOpts := []ContainerCustomizer{ + WithExposedPorts(sshPort), + WithEnv(map[string]string{"PASSWORD": sshPassword}), + WithWaitStrategy(wait.ForListeningPort(sshPort)), } - for _, opt := range opts { - if err := opt.Customize(&req); err != nil { - return nil, err - } - } + moduleOpts = append(moduleOpts, opts...) - c, err := GenericContainer(ctx, req) + c, err := Run(ctx, sshdImage, moduleOpts...) var sshd *sshdContainer if c != nil { sshd = &sshdContainer{Container: c} } if err != nil { - return sshd, fmt.Errorf("generic container: %w", err) + return sshd, fmt.Errorf("run: %w", err) } if err = sshd.clientConfig(ctx); err != nil { diff --git a/port_forwarding_test.go b/port_forwarding_test.go index 662bc64119..9b3c6bed4e 100644 --- a/port_forwarding_test.go +++ b/port_forwarding_test.go @@ -76,15 +76,12 @@ func testExposeHostPorts(t *testing.T, hostPorts []int, hasNetwork, hasHostAcces if hasHostAccess { hostAccessPorts = hostPorts } - req := testcontainers.GenericContainerRequest{ + + opts := []testcontainers.ContainerCustomizer{ + testcontainers.WithCmd("top"), // hostAccessPorts { - ContainerRequest: testcontainers.ContainerRequest{ - Image: "alpine:3.17", - HostAccessPorts: hostAccessPorts, - Cmd: []string{"top"}, - }, + testcontainers.WithHostPortAccess(hostAccessPorts...), // } - Started: true, } if hasNetwork { @@ -92,11 +89,13 @@ func testExposeHostPorts(t *testing.T, hostPorts []int, hasNetwork, hasHostAcces require.NoError(t, err) testcontainers.CleanupNetwork(t, nw) - req.Networks = []string{nw.Name} - req.NetworkAliases = map[string][]string{nw.Name: {"myalpine"}} + opts = append(opts, network.WithNetwork([]string{"myalpine"}, nw)) } - c, err := testcontainers.GenericContainer(ctx, req) + c, err := testcontainers.Run( + ctx, "alpine:3.17", + opts..., + ) testcontainers.CleanupContainer(t, c) require.NoError(t, err) diff --git a/reaper_test.go b/reaper_test.go index 59c780fa3e..1eea122f0e 100644 --- a/reaper_test.go +++ b/reaper_test.go @@ -107,16 +107,7 @@ func testContainerStart(t *testing.T) { t.Helper() ctx := context.Background() - ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - }, - Started: true, - }) + ctr, err := Run(ctx, nginxAlpineImage, WithExposedPorts(nginxDefaultPort)) CleanupContainer(t, ctr) require.NoError(t, err) } @@ -171,16 +162,7 @@ func testContainerStop(t *testing.T) { ctx := context.Background() - nginxA, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - }, - Started: true, - }) + nginxA, err := Run(ctx, nginxAlpineImage, WithExposedPorts(nginxDefaultPort)) CleanupContainer(t, nginxA) require.NoError(t, err) @@ -203,16 +185,7 @@ func testContainerTerminate(t *testing.T) { t.Helper() ctx := context.Background() - nginxA, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, - ContainerRequest: ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{ - nginxDefaultPort, - }, - }, - Started: true, - }) + nginxA, err := Run(ctx, nginxAlpineImage, WithExposedPorts(nginxDefaultPort)) CleanupContainer(t, nginxA) require.NoError(t, err) diff --git a/reuse_test.go b/reuse_test.go index bb4ccddc7f..3053551cc8 100644 --- a/reuse_test.go +++ b/reuse_test.go @@ -12,19 +12,14 @@ import ( ) func TestGenericContainer_stop_start_withReuse(t *testing.T) { - containerName := "my-nginx" + containerName := "my-nginx-stop_start_withReuse" - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{"8080/tcp"}, - Name: containerName, - }, - Reuse: true, - Started: true, + opts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8080/tcp"), + testcontainers.WithReuseByName(containerName), } - ctr, err := testcontainers.GenericContainer(context.Background(), req) + ctr, err := testcontainers.Run(context.Background(), nginxAlpineImage, opts...) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) require.NotNil(t, ctr) @@ -34,26 +29,21 @@ func TestGenericContainer_stop_start_withReuse(t *testing.T) { // Run another container with same container name: // The checks for the exposed ports must not fail when restarting the container. - ctr1, err := testcontainers.GenericContainer(context.Background(), req) + ctr1, err := testcontainers.Run(context.Background(), nginxAlpineImage, opts...) testcontainers.CleanupContainer(t, ctr1) require.NoError(t, err) require.NotNil(t, ctr1) } func TestGenericContainer_pause_start_withReuse(t *testing.T) { - containerName := "my-nginx" + containerName := "my-nginx-pause_start_withReuse" - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: nginxAlpineImage, - ExposedPorts: []string{"8080/tcp"}, - Name: containerName, - }, - Reuse: true, - Started: true, + opts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("8080/tcp"), + testcontainers.WithReuseByName(containerName), } - ctr, err := testcontainers.GenericContainer(context.Background(), req) + ctr, err := testcontainers.Run(context.Background(), nginxAlpineImage, opts...) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) require.NotNil(t, ctr) @@ -67,7 +57,7 @@ func TestGenericContainer_pause_start_withReuse(t *testing.T) { require.NoError(t, err) // Because the container is paused, it should not be possible to start it again. - ctr1, err := testcontainers.GenericContainer(context.Background(), req) + ctr1, err := testcontainers.Run(context.Background(), nginxAlpineImage, opts...) testcontainers.CleanupContainer(t, ctr1) require.ErrorIs(t, err, errors.ErrUnsupported) } diff --git a/wait/exec_test.go b/wait/exec_test.go index bf8d4a8e7b..1af31b44b1 100644 --- a/wait/exec_test.go +++ b/wait/exec_test.go @@ -21,16 +21,10 @@ import ( func ExampleExecStrategy() { ctx := context.Background() - req := testcontainers.ContainerRequest{ - Image: "alpine:latest", - Entrypoint: []string{"tail", "-f", "/dev/null"}, // needed for the container to stay alive - WaitingFor: wait.ForExec([]string{"ls", "/"}).WithStartupTimeout(1 * time.Second), - } - - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + ctr, err := testcontainers.Run(ctx, "alpine:latest", + testcontainers.WithEntrypoint("tail", "-f", "/dev/null"), + testcontainers.WithWaitStrategy(wait.ForExec([]string{"ls", "/"}).WithStartupTimeout(1*time.Second)), + ) defer func() { if err := testcontainers.TerminateContainer(ctr); err != nil { log.Printf("failed to terminate container: %s", err) @@ -177,23 +171,19 @@ func TestExecStrategyWaitUntilReady_withExitCode(t *testing.T) { func TestExecStrategyWaitUntilReady_CustomResponseMatcher(t *testing.T) { // waitForExecExitCodeResponse { - dockerReq := testcontainers.ContainerRequest{ - Image: "nginx:latest", - WaitingFor: wait.ForExec([]string{"echo", "hello world!"}). - WithStartupTimeout(time.Second * 10). + ctx := context.Background() + ctr, err := testcontainers.Run(ctx, "nginx:latest", + testcontainers.WithWaitStrategy(wait.ForExec([]string{"echo", "hello world!"}). + WithStartupTimeout(time.Second*10). WithExitCodeMatcher(func(exitCode int) bool { return exitCode == 0 }). WithResponseMatcher(func(body io.Reader) bool { data, _ := io.ReadAll(body) return bytes.Equal(data, []byte("hello world!\n")) - }), - } + })), + ) // } - - ctx := context.Background() - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: dockerReq, Started: true}) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) - // } } diff --git a/wait/file_test.go b/wait/file_test.go index 2c7457ace0..acfe5cf72a 100644 --- a/wait/file_test.go +++ b/wait/file_test.go @@ -77,26 +77,24 @@ func TestForFile(t *testing.T) { func TestFileStrategyWaitUntilReady_WithMatcher(t *testing.T) { // waitForFileWithMatcher { + ctx := context.Background() var out bytes.Buffer - dockerReq := testcontainers.ContainerRequest{ - Image: "nginx:latest", - WaitingFor: wait.ForFile("/etc/nginx/nginx.conf"). - WithStartupTimeout(time.Second * 10). + + c, err := testcontainers.Run(ctx, "nginx:latest", + testcontainers.WithWaitStrategy(wait.ForFile("/etc/nginx/nginx.conf"). + WithStartupTimeout(time.Second*10). WithPollInterval(time.Second). WithMatcher(func(r io.Reader) error { if _, err := io.Copy(&out, r); err != nil { return fmt.Errorf("copy: %w", err) } return nil - }), - } + })), + ) // } - - ctx := context.Background() - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: dockerReq, Started: true}) - if container != nil { + if c != nil { t.Cleanup(func() { - require.NoError(t, container.Terminate(context.Background())) + require.NoError(t, c.Terminate(context.Background())) }) } require.NoError(t, err) diff --git a/wait/http_test.go b/wait/http_test.go index 87580edd0a..59a904ac91 100644 --- a/wait/http_test.go +++ b/wait/http_test.go @@ -31,16 +31,10 @@ var caBytes []byte func ExampleHTTPStrategy() { // waitForHTTPWithDefaultPort { ctx := context.Background() - req := testcontainers.ContainerRequest{ - Image: "nginx:latest", - ExposedPorts: []string{"80/tcp"}, - WaitingFor: wait.ForHTTP("/").WithStartupTimeout(10 * time.Second), - } - - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + c, err := testcontainers.Run(ctx, "nginx:latest", + testcontainers.WithExposedPorts("80/tcp"), + testcontainers.WithWaitStrategy(wait.ForHTTP("/").WithStartupTimeout(10*time.Second)), + ) defer func() { if err := testcontainers.TerminateContainer(c); err != nil { log.Printf("failed to terminate container: %s", err) @@ -82,26 +76,21 @@ func ExampleHTTPStrategy_WithHeaders() { // waitForHTTPHeaders { tlsconfig := &tls.Config{RootCAs: certpool, ServerName: "testcontainer.go.test"} - req := testcontainers.ContainerRequest{ - FromDockerfile: testcontainers.FromDockerfile{ + c, err := testcontainers.Run(ctx, "", + testcontainers.WithDockerfile(testcontainers.FromDockerfile{ Context: "testdata/http", - }, - ExposedPorts: []string{"6443/tcp"}, - WaitingFor: wait.ForHTTP("/headers"). + }), + testcontainers.WithExposedPorts("6443/tcp"), + testcontainers.WithWaitStrategy(wait.ForHTTP("/headers"). WithTLS(true, tlsconfig). WithPort("6443/tcp"). WithHeaders(map[string]string{"X-request-header": "value"}). WithResponseHeadersMatcher(func(headers http.Header) bool { return headers.Get("X-response-header") == "value" - }, - ), - } + }), + ), + ) // } - - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) defer func() { if err := testcontainers.TerminateContainer(c); err != nil { log.Printf("failed to terminate container: %s", err) @@ -127,16 +116,10 @@ func ExampleHTTPStrategy_WithHeaders() { func ExampleHTTPStrategy_WithPort() { // waitForHTTPWithPort { ctx := context.Background() - req := testcontainers.ContainerRequest{ - Image: "nginx:latest", - ExposedPorts: []string{"8080/tcp", "80/tcp"}, - WaitingFor: wait.ForHTTP("/").WithPort("80/tcp"), - } - - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + c, err := testcontainers.Run(ctx, "nginx:latest", + testcontainers.WithExposedPorts("8080/tcp", "80/tcp"), + testcontainers.WithWaitStrategy(wait.ForHTTP("/").WithPort("80/tcp")), + ) defer func() { if err := testcontainers.TerminateContainer(c); err != nil { log.Printf("failed to terminate container: %s", err) @@ -162,16 +145,10 @@ func ExampleHTTPStrategy_WithPort() { func ExampleHTTPStrategy_WithForcedIPv4LocalHost() { ctx := context.Background() - req := testcontainers.ContainerRequest{ - Image: "nginx:latest", - ExposedPorts: []string{"8080/tcp", "80/tcp"}, - WaitingFor: wait.ForHTTP("/").WithForcedIPv4LocalHost(), - } - - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + c, err := testcontainers.Run(ctx, "nginx:latest", + testcontainers.WithExposedPorts("8080/tcp", "80/tcp"), + testcontainers.WithWaitStrategy(wait.ForHTTP("/").WithForcedIPv4LocalHost()), + ) defer func() { if err := testcontainers.TerminateContainer(c); err != nil { log.Printf("failed to terminate container: %s", err) @@ -197,16 +174,10 @@ func ExampleHTTPStrategy_WithForcedIPv4LocalHost() { func ExampleHTTPStrategy_WithBasicAuth() { // waitForBasicAuth { ctx := context.Background() - req := testcontainers.ContainerRequest{ - Image: "gogs/gogs:0.11.91", - ExposedPorts: []string{"3000/tcp"}, - WaitingFor: wait.ForHTTP("/").WithBasicAuth("username", "password"), - } - - gogs, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + gogs, err := testcontainers.Run(ctx, "gogs/gogs:0.11.91", + testcontainers.WithExposedPorts("3000/tcp"), + testcontainers.WithWaitStrategy(wait.ForHTTP("/").WithBasicAuth("username", "password")), + ) defer func() { if err := testcontainers.TerminateContainer(gogs); err != nil { log.Printf("failed to terminate container: %s", err) @@ -235,12 +206,12 @@ func TestHTTPStrategyWaitUntilReady(t *testing.T) { require.Truef(t, certpool.AppendCertsFromPEM(caBytes), "the ca file isn't valid") tlsconfig := &tls.Config{RootCAs: certpool, ServerName: "testcontainer.go.test"} - dockerReq := testcontainers.ContainerRequest{ - FromDockerfile: testcontainers.FromDockerfile{ + ctr, err := testcontainers.Run(context.Background(), "", + testcontainers.WithDockerfile(testcontainers.FromDockerfile{ Context: "testdata/http", - }, - ExposedPorts: []string{"6443/tcp"}, - WaitingFor: wait.NewHTTPStrategy("/auth-ping").WithTLS(true, tlsconfig). + }), + testcontainers.WithExposedPorts("6443/tcp"), + testcontainers.WithWaitStrategy(wait.NewHTTPStrategy("/auth-ping").WithTLS(true, tlsconfig). WithStartupTimeout(time.Second*10).WithPort("6443/tcp"). WithResponseMatcher(func(body io.Reader) bool { data, _ := io.ReadAll(body) @@ -248,10 +219,8 @@ func TestHTTPStrategyWaitUntilReady(t *testing.T) { }). WithBasicAuth("admin", "admin"). WithMethod(http.MethodPost).WithBody(bytes.NewReader([]byte("ping"))), - } - - ctr, err := testcontainers.GenericContainer(context.Background(), - testcontainers.GenericContainerRequest{ContainerRequest: dockerReq, Started: true}) + ), + ) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) @@ -289,22 +258,19 @@ func TestHTTPStrategyWaitUntilReadyWithQueryString(t *testing.T) { require.Truef(t, certpool.AppendCertsFromPEM(caBytes), "the ca file isn't valid") tlsconfig := &tls.Config{RootCAs: certpool, ServerName: "testcontainer.go.test"} - dockerReq := testcontainers.ContainerRequest{ - FromDockerfile: testcontainers.FromDockerfile{ + ctr, err := testcontainers.Run(context.Background(), "", + testcontainers.WithDockerfile(testcontainers.FromDockerfile{ Context: "testdata/http", - }, - - ExposedPorts: []string{"6443/tcp"}, - WaitingFor: wait.NewHTTPStrategy("/query-params-ping?v=pong").WithTLS(true, tlsconfig). - WithStartupTimeout(time.Second * 10).WithPort("6443/tcp"). + }), + testcontainers.WithExposedPorts("6443/tcp"), + testcontainers.WithWaitStrategy(wait.NewHTTPStrategy("/query-params-ping?v=pong").WithTLS(true, tlsconfig). + WithStartupTimeout(time.Second*10).WithPort("6443/tcp"). WithResponseMatcher(func(body io.Reader) bool { data, _ := io.ReadAll(body) return bytes.Equal(data, []byte("pong")) }), - } - - ctr, err := testcontainers.GenericContainer(context.Background(), - testcontainers.GenericContainerRequest{ContainerRequest: dockerReq, Started: true}) + ), + ) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) @@ -344,13 +310,14 @@ func TestHTTPStrategyWaitUntilReadyNoBasicAuth(t *testing.T) { // waitForHTTPStatusCode { tlsconfig := &tls.Config{RootCAs: certpool, ServerName: "testcontainer.go.test"} var i int - dockerReq := testcontainers.ContainerRequest{ - FromDockerfile: testcontainers.FromDockerfile{ + ctx := context.Background() + ctr, err := testcontainers.Run(context.Background(), "", + testcontainers.WithDockerfile(testcontainers.FromDockerfile{ Context: "testdata/http", - }, - ExposedPorts: []string{"6443/tcp"}, - WaitingFor: wait.NewHTTPStrategy("/ping").WithTLS(true, tlsconfig). - WithStartupTimeout(time.Second * 10). + }), + testcontainers.WithExposedPorts("6443/tcp"), + testcontainers.WithWaitStrategy(wait.NewHTTPStrategy("/ping").WithTLS(true, tlsconfig). + WithStartupTimeout(time.Second*10). WithResponseMatcher(func(body io.Reader) bool { data, _ := io.ReadAll(body) return bytes.Equal(data, []byte("pong")) @@ -360,11 +327,9 @@ func TestHTTPStrategyWaitUntilReadyNoBasicAuth(t *testing.T) { return i > 1 && status == 200 }). WithMethod(http.MethodPost).WithBody(bytes.NewReader([]byte("ping"))), - } + ), + ) // } - - ctx := context.Background() - ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: dockerReq, Started: true}) testcontainers.CleanupContainer(t, ctr) require.NoError(t, err) diff --git a/wait/tls_test.go b/wait/tls_test.go index 59c68500e5..957f8f41b4 100644 --- a/wait/tls_test.go +++ b/wait/tls_test.go @@ -106,18 +106,12 @@ func ExampleForTLSCert() { // be copied to in the container as detailed by the Dockerfile. forCert := wait.ForTLSCert("/app/tls.pem", "/app/tls-key.pem"). WithServerName("testcontainer.go.test") - req := testcontainers.ContainerRequest{ - FromDockerfile: testcontainers.FromDockerfile{ + c, err := testcontainers.Run(ctx, "", + testcontainers.WithDockerfile(testcontainers.FromDockerfile{ Context: "testdata/http", - }, - WaitingFor: forCert, - } - // } - - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) + }), + testcontainers.WithWaitStrategy(forCert), + ) defer func() { if err := testcontainers.TerminateContainer(c); err != nil { log.Printf("failed to terminate container: %s", err) @@ -127,6 +121,7 @@ func ExampleForTLSCert() { log.Printf("failed to start container: %s", err) return } + // } state, err := c.State(ctx) if err != nil {