diff --git a/container_test.go b/container_test.go index d7fb9310ef..4f44af9fc8 100644 --- a/container_test.go +++ b/container_test.go @@ -467,60 +467,6 @@ func TestShouldStartContainersInParallel(t *testing.T) { } } -func TestOverrideContainerRequest(t *testing.T) { - req := GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - Env: map[string]string{ - "BAR": "BAR", - }, - Image: "foo", - ExposedPorts: []string{"12345/tcp"}, - WaitingFor: wait.ForNop( - func(ctx context.Context, target wait.StrategyTarget) error { - return nil - }, - ), - Networks: []string{"foo", "bar", "baaz"}, - NetworkAliases: map[string][]string{ - "foo": {"foo0", "foo1", "foo2", "foo3"}, - }, - }, - } - - toBeMergedRequest := GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - Env: map[string]string{ - "FOO": "FOO", - }, - Image: "bar", - ExposedPorts: []string{"67890/tcp"}, - Networks: []string{"foo1", "bar1"}, - NetworkAliases: map[string][]string{ - "foo1": {"bar"}, - }, - WaitingFor: wait.ForLog("foo"), - }, - } - - // the toBeMergedRequest should be merged into the req - CustomizeRequest(toBeMergedRequest)(&req) - - // toBeMergedRequest should not be changed - assert.Equal(t, "", toBeMergedRequest.Env["BAR"]) - assert.Equal(t, 1, len(toBeMergedRequest.ExposedPorts)) - assert.Equal(t, "67890/tcp", toBeMergedRequest.ExposedPorts[0]) - - // req should be merged with toBeMergedRequest - assert.Equal(t, "FOO", req.Env["FOO"]) - assert.Equal(t, "BAR", req.Env["BAR"]) - assert.Equal(t, "bar", req.Image) - assert.Equal(t, []string{"12345/tcp", "67890/tcp"}, req.ExposedPorts) - assert.Equal(t, []string{"foo", "bar", "baaz", "foo1", "bar1"}, req.Networks) - assert.Equal(t, []string{"foo0", "foo1", "foo2", "foo3"}, req.NetworkAliases["foo"]) - assert.Equal(t, []string{"bar"}, req.NetworkAliases["foo1"]) - assert.Equal(t, wait.ForLog("foo"), req.WaitingFor) -} - func TestParseDockerIgnore(t *testing.T) { testCases := []struct { filePath string diff --git a/generic.go b/generic.go index cedc96e704..01a4267f17 100644 --- a/generic.go +++ b/generic.go @@ -4,17 +4,10 @@ import ( "context" "errors" "fmt" - "strings" "sync" - "time" - - "dario.cat/mergo" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" "github.com/testcontainers/testcontainers-go/internal/testcontainersdocker" "github.com/testcontainers/testcontainers-go/internal/testcontainerssession" - "github.com/testcontainers/testcontainers-go/wait" ) var ( @@ -31,147 +24,6 @@ type GenericContainerRequest struct { Reuse bool // reuse an existing container if it exists or create a new one. a container name mustn't be empty } -// ContainerCustomizer is an interface that can be used to configure the Testcontainers container -// request. The passed request will be merged with the default one. -type ContainerCustomizer interface { - Customize(req *GenericContainerRequest) -} - -// CustomizeRequestOption is a type that can be used to configure the Testcontainers container request. -// The passed request will be merged with the default one. -type CustomizeRequestOption func(req *GenericContainerRequest) - -func (opt CustomizeRequestOption) Customize(req *GenericContainerRequest) { - opt(req) -} - -// CustomizeRequest returns a function that can be used to merge the passed container request with the one that is used by the container. -// Slices and Maps will be appended. -func CustomizeRequest(src GenericContainerRequest) CustomizeRequestOption { - return func(req *GenericContainerRequest) { - if err := mergo.Merge(req, &src, mergo.WithOverride, mergo.WithAppendSlice); err != nil { - fmt.Printf("error merging container request, keeping the original one. Error: %v", err) - return - } - } -} - -// WithImage sets the image for a container -func WithImage(image string) CustomizeRequestOption { - return func(req *GenericContainerRequest) { - req.Image = image - } -} - -// imageSubstitutor { -// ImageSubstitutor represents a way to substitute container image names -type ImageSubstitutor interface { - // Description returns the name of the type and a short description of how it modifies the image. - // Useful to be printed in logs - Description() string - Substitute(image string) (string, error) -} - -// } - -// WithImageSubstitutors sets the image substitutors for a container -func WithImageSubstitutors(fn ...ImageSubstitutor) CustomizeRequestOption { - return func(req *GenericContainerRequest) { - req.ImageSubstitutors = fn - } -} - -// WithConfigModifier allows to override the default container config -func WithConfigModifier(modifier func(config *container.Config)) CustomizeRequestOption { - return func(req *GenericContainerRequest) { - req.ConfigModifier = modifier - } -} - -// WithEndpointSettingsModifier allows to override the default endpoint settings -func WithEndpointSettingsModifier(modifier func(settings map[string]*network.EndpointSettings)) CustomizeRequestOption { - return func(req *GenericContainerRequest) { - req.EnpointSettingsModifier = modifier - } -} - -// WithHostConfigModifier allows to override the default host config -func WithHostConfigModifier(modifier func(hostConfig *container.HostConfig)) CustomizeRequestOption { - return func(req *GenericContainerRequest) { - req.HostConfigModifier = modifier - } -} - -// WithNetwork creates a network with the given name and attaches the container to it, setting the network alias -// on that network to the given alias. -// If the network already exists, checking if the network name already exists, it will be reused. -func WithNetwork(networkName string, alias string) CustomizeRequestOption { - return func(req *GenericContainerRequest) { - _, err := GenericNetwork(context.Background(), GenericNetworkRequest{ - NetworkRequest: NetworkRequest{ - Name: networkName, - CheckDuplicate: true, // force the Docker provider to reuse an existing network - }, - }) - if err != nil && !strings.Contains(err.Error(), "already exists") { - logger := req.Logger - if logger == nil { - logger = Logger - } - logger.Printf("Failed to create network '%s'. Container won't be attached to this network: %v", networkName, err) - return - } - - // attaching to the network because it was created with success or it already existed. - req.Networks = append(req.Networks, networkName) - - if req.NetworkAliases == nil { - req.NetworkAliases = make(map[string][]string) - } - req.NetworkAliases[networkName] = []string{alias} - } -} - -// Executable represents an executable command to be sent to a container -// as part of the PostStart lifecycle hook. -type Executable interface { - AsCommand() []string -} - -// WithStartupCommand will execute the command representation of each Executable into the container. -// It will leverage the container lifecycle hooks to call the command right after the container -// is started. -func WithStartupCommand(execs ...Executable) CustomizeRequestOption { - return func(req *GenericContainerRequest) { - startupCommandsHook := ContainerLifecycleHooks{ - PostStarts: []ContainerHook{}, - } - - for _, exec := range execs { - execFn := func(ctx context.Context, c Container) error { - _, _, err := c.Exec(ctx, exec.AsCommand()) - return err - } - - startupCommandsHook.PostStarts = append(startupCommandsHook.PostStarts, execFn) - } - - req.LifecycleHooks = append(req.LifecycleHooks, startupCommandsHook) - } -} - -// WithWaitStrategy sets the wait strategy for a container, using 60 seconds as deadline -func WithWaitStrategy(strategies ...wait.Strategy) CustomizeRequestOption { - return WithWaitStrategyAndDeadline(60*time.Second, strategies...) -} - -// WithWaitStrategyAndDeadline sets the wait strategy for a container, including deadline -func WithWaitStrategyAndDeadline(deadline time.Duration, strategies ...wait.Strategy) CustomizeRequestOption { - return func(req *GenericContainerRequest) { - req.WaitingFor = wait.ForAll(strategies...).WithDeadline(deadline) - } -} - // GenericNetworkRequest represents parameters to a generic network type GenericNetworkRequest struct { NetworkRequest // embedded request for provider diff --git a/generic_test.go b/generic_test.go index 4564c0dfd9..77faa919c2 100644 --- a/generic_test.go +++ b/generic_test.go @@ -3,13 +3,10 @@ package testcontainers import ( "context" "errors" - "io" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/testcontainers/testcontainers-go/exec" "github.com/testcontainers/testcontainers-go/wait" ) @@ -99,41 +96,3 @@ func TestGenericReusableContainer(t *testing.T) { }) } } - -type testExecutable struct { - cmds []string -} - -func (t testExecutable) AsCommand() []string { - return t.cmds -} - -func TestWithStartupCommand(t *testing.T) { - req := GenericContainerRequest{ - ContainerRequest: ContainerRequest{ - Image: "alpine", - Entrypoint: []string{"tail", "-f", "/dev/null"}, - }, - Started: true, - } - - testExec := testExecutable{ - cmds: []string{"touch", "/tmp/.testcontainers"}, - } - - WithStartupCommand(testExec)(&req) - - c, err := GenericContainer(context.Background(), req) - require.NoError(t, err) - defer func() { - err = c.Terminate(context.Background()) - 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) - assert.Equal(t, "/tmp/.testcontainers\n", string(content)) -} diff --git a/options.go b/options.go new file mode 100644 index 0000000000..3b2e2de262 --- /dev/null +++ b/options.go @@ -0,0 +1,155 @@ +package testcontainers + +import ( + "context" + "fmt" + "strings" + "time" + + "dario.cat/mergo" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + + "github.com/testcontainers/testcontainers-go/wait" +) + +// ContainerCustomizer is an interface that can be used to configure the Testcontainers container +// request. The passed request will be merged with the default one. +type ContainerCustomizer interface { + Customize(req *GenericContainerRequest) +} + +// CustomizeRequestOption is a type that can be used to configure the Testcontainers container request. +// The passed request will be merged with the default one. +type CustomizeRequestOption func(req *GenericContainerRequest) + +func (opt CustomizeRequestOption) Customize(req *GenericContainerRequest) { + opt(req) +} + +// CustomizeRequest returns a function that can be used to merge the passed container request with the one that is used by the container. +// Slices and Maps will be appended. +func CustomizeRequest(src GenericContainerRequest) CustomizeRequestOption { + return func(req *GenericContainerRequest) { + if err := mergo.Merge(req, &src, mergo.WithOverride, mergo.WithAppendSlice); err != nil { + fmt.Printf("error merging container request, keeping the original one. Error: %v", err) + return + } + } +} + +// WithConfigModifier allows to override the default container config +func WithConfigModifier(modifier func(config *container.Config)) CustomizeRequestOption { + return func(req *GenericContainerRequest) { + req.ConfigModifier = modifier + } +} + +// WithEndpointSettingsModifier allows to override the default endpoint settings +func WithEndpointSettingsModifier(modifier func(settings map[string]*network.EndpointSettings)) CustomizeRequestOption { + return func(req *GenericContainerRequest) { + req.EnpointSettingsModifier = modifier + } +} + +// WithHostConfigModifier allows to override the default host config +func WithHostConfigModifier(modifier func(hostConfig *container.HostConfig)) CustomizeRequestOption { + return func(req *GenericContainerRequest) { + req.HostConfigModifier = modifier + } +} + +// WithImage sets the image for a container +func WithImage(image string) CustomizeRequestOption { + return func(req *GenericContainerRequest) { + req.Image = image + } +} + +// imageSubstitutor { +// ImageSubstitutor represents a way to substitute container image names +type ImageSubstitutor interface { + // Description returns the name of the type and a short description of how it modifies the image. + // Useful to be printed in logs + Description() string + Substitute(image string) (string, error) +} + +// } + +// WithImageSubstitutors sets the image substitutors for a container +func WithImageSubstitutors(fn ...ImageSubstitutor) CustomizeRequestOption { + return func(req *GenericContainerRequest) { + req.ImageSubstitutors = fn + } +} + +// WithNetwork creates a network with the given name and attaches the container to it, setting the network alias +// on that network to the given alias. +// If the network already exists, checking if the network name already exists, it will be reused. +func WithNetwork(networkName string, alias string) CustomizeRequestOption { + return func(req *GenericContainerRequest) { + _, err := GenericNetwork(context.Background(), GenericNetworkRequest{ + NetworkRequest: NetworkRequest{ + Name: networkName, + CheckDuplicate: true, // force the Docker provider to reuse an existing network + }, + }) + if err != nil && !strings.Contains(err.Error(), "already exists") { + logger := req.Logger + if logger == nil { + logger = Logger + } + logger.Printf("Failed to create network '%s'. Container won't be attached to this network: %v", networkName, err) + return + } + + // attaching to the network because it was created with success or it already existed. + req.Networks = append(req.Networks, networkName) + + if req.NetworkAliases == nil { + req.NetworkAliases = make(map[string][]string) + } + req.NetworkAliases[networkName] = []string{alias} + } +} + +// Executable represents an executable command to be sent to a container +// as part of the PostStart lifecycle hook. +type Executable interface { + AsCommand() []string +} + +// WithStartupCommand will execute the command representation of each Executable into the container. +// It will leverage the container lifecycle hooks to call the command right after the container +// is started. +func WithStartupCommand(execs ...Executable) CustomizeRequestOption { + return func(req *GenericContainerRequest) { + startupCommandsHook := ContainerLifecycleHooks{ + PostStarts: []ContainerHook{}, + } + + for _, exec := range execs { + execFn := func(ctx context.Context, c Container) error { + _, _, err := c.Exec(ctx, exec.AsCommand()) + return err + } + + startupCommandsHook.PostStarts = append(startupCommandsHook.PostStarts, execFn) + } + + req.LifecycleHooks = append(req.LifecycleHooks, startupCommandsHook) + } +} + +// WithWaitStrategy sets the wait strategy for a container, using 60 seconds as deadline +func WithWaitStrategy(strategies ...wait.Strategy) CustomizeRequestOption { + return WithWaitStrategyAndDeadline(60*time.Second, strategies...) +} + +// WithWaitStrategyAndDeadline sets the wait strategy for a container, including deadline +func WithWaitStrategyAndDeadline(deadline time.Duration, strategies ...wait.Strategy) CustomizeRequestOption { + return func(req *GenericContainerRequest) { + req.WaitingFor = wait.ForAll(strategies...).WithDeadline(deadline) + } +} diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000000..4d61c18a8e --- /dev/null +++ b/options_test.go @@ -0,0 +1,162 @@ +package testcontainers_test + +import ( + "context" + "io" + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/exec" + "github.com/testcontainers/testcontainers-go/wait" +) + +func TestOverrideContainerRequest(t *testing.T) { + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Env: map[string]string{ + "BAR": "BAR", + }, + Image: "foo", + ExposedPorts: []string{"12345/tcp"}, + WaitingFor: wait.ForNop( + func(ctx context.Context, target wait.StrategyTarget) error { + return nil + }, + ), + Networks: []string{"foo", "bar", "baaz"}, + NetworkAliases: map[string][]string{ + "foo": {"foo0", "foo1", "foo2", "foo3"}, + }, + }, + } + + toBeMergedRequest := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Env: map[string]string{ + "FOO": "FOO", + }, + Image: "bar", + ExposedPorts: []string{"67890/tcp"}, + Networks: []string{"foo1", "bar1"}, + NetworkAliases: map[string][]string{ + "foo1": {"bar"}, + }, + WaitingFor: wait.ForLog("foo"), + }, + } + + // the toBeMergedRequest should be merged into the req + testcontainers.CustomizeRequest(toBeMergedRequest)(&req) + + // toBeMergedRequest should not be changed + assert.Equal(t, "", toBeMergedRequest.Env["BAR"]) + assert.Equal(t, 1, len(toBeMergedRequest.ExposedPorts)) + assert.Equal(t, "67890/tcp", toBeMergedRequest.ExposedPorts[0]) + + // req should be merged with toBeMergedRequest + assert.Equal(t, "FOO", req.Env["FOO"]) + assert.Equal(t, "BAR", req.Env["BAR"]) + assert.Equal(t, "bar", req.Image) + assert.Equal(t, []string{"12345/tcp", "67890/tcp"}, req.ExposedPorts) + assert.Equal(t, []string{"foo", "bar", "baaz", "foo1", "bar1"}, req.Networks) + assert.Equal(t, []string{"foo0", "foo1", "foo2", "foo3"}, req.NetworkAliases["foo"]) + assert.Equal(t, []string{"bar"}, req.NetworkAliases["foo1"]) + assert.Equal(t, wait.ForLog("foo"), req.WaitingFor) +} + +func TestWithNetwork(t *testing.T) { + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{}, + } + + testcontainers.WithNetwork("new-network", "alias")(&req) + + assert.Equal(t, []string{"new-network"}, req.Networks) + assert.Equal(t, map[string][]string{"new-network": {"alias"}}, req.NetworkAliases) + + client, err := testcontainers.NewDockerClientWithOpts(context.Background()) + require.NoError(t, err) + + args := filters.NewArgs() + args.Add("name", "new-network") + + resources, err := client.NetworkList(context.Background(), types.NetworkListOptions{ + Filters: args, + }) + require.NoError(t, err) + assert.Len(t, resources, 1) + + assert.Equal(t, "new-network", resources[0].Name) +} + +func TestWithNetworkMultipleCallsWithSameNameReuseTheNetwork(t *testing.T) { + for int := 0; int < 100; int++ { + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{}, + } + + testcontainers.WithNetwork("new-network", "alias")(&req) + assert.Equal(t, []string{"new-network"}, req.Networks) + assert.Equal(t, map[string][]string{"new-network": {"alias"}}, req.NetworkAliases) + } + + client, err := testcontainers.NewDockerClientWithOpts(context.Background()) + require.NoError(t, err) + + args := filters.NewArgs() + args.Add("name", "new-network") + + resources, err := client.NetworkList(context.Background(), types.NetworkListOptions{ + Filters: args, + }) + require.NoError(t, err) + assert.Len(t, resources, 1) + + assert.Equal(t, "new-network", resources[0].Name) +} + +type testExecutable struct { + cmds []string +} + +func (t testExecutable) AsCommand() []string { + return t.cmds +} + +func TestWithStartupCommand(t *testing.T) { + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "alpine", + Entrypoint: []string{"tail", "-f", "/dev/null"}, + }, + Started: true, + } + + testExec := testExecutable{ + cmds: []string{"touch", "/tmp/.testcontainers"}, + } + + testcontainers.WithStartupCommand(testExec)(&req) + + assert.Equal(t, 1, len(req.LifecycleHooks)) + assert.Equal(t, 1, len(req.LifecycleHooks[0].PostStarts)) + + c, err := testcontainers.GenericContainer(context.Background(), req) + require.NoError(t, err) + defer func() { + err = c.Terminate(context.Background()) + 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) + assert.Equal(t, "/tmp/.testcontainers\n", string(content)) +}