From e647f52a5cc2fd61c1b8e04b4d690ceba43fd1f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20de=20la=20Pe=C3=B1a?= Date: Tue, 14 Nov 2023 13:29:16 +0100 Subject: [PATCH 1/5] feat: support for executing commands in a container with user, workDir and env --- docker.go | 26 ++++++++++-------- docker_exec_test.go | 67 +++++++++++++++++++++++++++++++++++++++++++++ exec/processor.go | 45 +++++++++++++++++++++++++++++- 3 files changed, 126 insertions(+), 12 deletions(-) diff --git a/docker.go b/docker.go index cb739a46d6..2d657abad8 100644 --- a/docker.go +++ b/docker.go @@ -466,12 +466,16 @@ func (c *DockerContainer) NetworkAliases(ctx context.Context) (map[string][]stri func (c *DockerContainer) Exec(ctx context.Context, cmd []string, options ...tcexec.ProcessOption) (int, io.Reader, error) { cli := c.provider.client - response, err := cli.ContainerExecCreate(ctx, c.ID, types.ExecConfig{ - Cmd: cmd, - Detach: false, - AttachStdout: true, - AttachStderr: true, - }) + + processOptions := tcexec.NewProcessOptions(cmd) + + // processing all the options in a first loop because for the multiplexed option + // we first need to have a containerExecCreateResponse + for _, o := range options { + o.Apply(processOptions) + } + + response, err := cli.ContainerExecCreate(ctx, c.ID, processOptions.ExecConfig) if err != nil { return 0, nil, err } @@ -481,12 +485,12 @@ func (c *DockerContainer) Exec(ctx context.Context, cmd []string, options ...tce return 0, nil, err } - opt := &tcexec.ProcessOptions{ - Reader: hijack.Reader, - } + processOptions.Reader = hijack.Reader + // second loop to process the multiplexed option, as now we have a reader + // from the created exec response. for _, o := range options { - o.Apply(opt) + o.Apply(processOptions) } var exitCode int @@ -504,7 +508,7 @@ func (c *DockerContainer) Exec(ctx context.Context, cmd []string, options ...tce time.Sleep(100 * time.Millisecond) } - return exitCode, opt.Reader, nil + return exitCode, processOptions.Reader, nil } type FileFromContainer struct { diff --git a/docker_exec_test.go b/docker_exec_test.go index e925fc0f1f..c5f22f7122 100644 --- a/docker_exec_test.go +++ b/docker_exec_test.go @@ -39,6 +39,73 @@ func TestExecWithMultiplexedResponse(t *testing.T) { require.Equal(t, "html\n", str) } +func TestExecWithOptions(t *testing.T) { + tests := []struct { + name string + cmds []string + opts []tcexec.ProcessOption + want string + }{ + { + name: "with user", + cmds: []string{"whoami"}, + opts: []tcexec.ProcessOption{ + tcexec.WithUser("nginx"), + }, + want: "nginx\n", + }, + { + name: "with working dir", + cmds: []string{"pwd"}, + opts: []tcexec.ProcessOption{ + tcexec.WithWorkingDir("/var/log/nginx"), + }, + want: "/var/log/nginx\n", + }, + { + name: "with env", + cmds: []string{"env"}, + opts: []tcexec.ProcessOption{ + tcexec.WithEnv([]string{"TEST_ENV=test"}), + }, + want: "TEST_ENV=test\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + req := ContainerRequest{ + Image: nginxAlpineImage, + } + + container, err := GenericContainer(ctx, GenericContainerRequest{ + ProviderType: providerType, + ContainerRequest: req, + Started: true, + }) + + require.NoError(t, err) + terminateContainerOnEnd(t, ctx, container) + + // always append the multiplexed option for having the output + // in a readable format + opts := append(tt.opts, tcexec.Multiplexed()) + + code, reader, err := container.Exec(ctx, tt.cmds, opts...) + require.NoError(t, err) + require.Zero(t, code) + require.NotNil(t, reader) + + b, err := io.ReadAll(reader) + require.NoError(t, err) + require.NotNil(t, b) + + str := string(b) + require.Contains(t, str, tt.want) + }) + } +} + func TestExecWithNonMultiplexedResponse(t *testing.T) { ctx := context.Background() req := ContainerRequest{ diff --git a/exec/processor.go b/exec/processor.go index 63987e461f..cb8375167b 100644 --- a/exec/processor.go +++ b/exec/processor.go @@ -4,12 +4,30 @@ import ( "bytes" "io" + "github.com/docker/docker/api/types" "github.com/docker/docker/pkg/stdcopy" ) // ProcessOptions defines options applicable to the reader processor type ProcessOptions struct { - Reader io.Reader + ExecConfig types.ExecConfig + Reader io.Reader +} + +// NewProcessOptions returns a new ProcessOptions instance +// with the given command and default options: +// - detach: false +// - attach stdout: true +// - attach stderr: true +func NewProcessOptions(cmd []string) *ProcessOptions { + return &ProcessOptions{ + ExecConfig: types.ExecConfig{ + Cmd: cmd, + Detach: false, + AttachStdout: true, + AttachStderr: true, + }, + } } // ProcessOption defines a common interface to modify the reader processor @@ -24,8 +42,33 @@ func (fn ProcessOptionFunc) Apply(opts *ProcessOptions) { fn(opts) } +func WithUser(user string) ProcessOption { + return ProcessOptionFunc(func(opts *ProcessOptions) { + opts.ExecConfig.User = user + }) +} + +func WithWorkingDir(workingDir string) ProcessOption { + return ProcessOptionFunc(func(opts *ProcessOptions) { + opts.ExecConfig.WorkingDir = workingDir + }) +} + +func WithEnv(env []string) ProcessOption { + return ProcessOptionFunc(func(opts *ProcessOptions) { + opts.ExecConfig.Env = env + }) +} + func Multiplexed() ProcessOption { return ProcessOptionFunc(func(opts *ProcessOptions) { + // returning fast to bypass those options with a nil reader, + // which could be the case when other options are used + // to configure the exec creation. + if opts.Reader == nil { + return + } + done := make(chan struct{}) var outBuff bytes.Buffer From 2c43b297920bdb931a0b4a45fcbec1f48f120e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20de=20la=20Pe=C3=B1a?= Date: Tue, 14 Nov 2023 13:43:58 +0100 Subject: [PATCH 2/5] fix: lint --- docker_exec_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker_exec_test.go b/docker_exec_test.go index c5f22f7122..9ceaf97ee6 100644 --- a/docker_exec_test.go +++ b/docker_exec_test.go @@ -89,9 +89,9 @@ func TestExecWithOptions(t *testing.T) { // always append the multiplexed option for having the output // in a readable format - opts := append(tt.opts, tcexec.Multiplexed()) + tt.opts = append(tt.opts, tcexec.Multiplexed()) - code, reader, err := container.Exec(ctx, tt.cmds, opts...) + code, reader, err := container.Exec(ctx, tt.cmds, tt.opts...) require.NoError(t, err) require.Zero(t, code) require.NotNil(t, reader) From 767d698ba25ec77821a740978042b14d5aad85f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20de=20la=20Pe=C3=B1a?= Date: Wed, 15 Nov 2023 11:15:44 +0100 Subject: [PATCH 3/5] break: update Executable interface to accept ProcessOptions --- docs/features/common_functional_options.md | 5 +++- modules/cassandra/executable.go | 3 +++ modules/rabbitmq/examples_test.go | 5 +++- modules/rabbitmq/rabbitmq_test.go | 2 +- modules/rabbitmq/types_test.go | 19 ++++++++++++-- options.go | 30 +++++++++++++++++++--- options_test.go | 12 +-------- 7 files changed, 57 insertions(+), 19 deletions(-) diff --git a/docs/features/common_functional_options.md b/docs/features/common_functional_options.md index 1eedfbd8ea..085cf3cb61 100644 --- a/docs/features/common_functional_options.md +++ b/docs/features/common_functional_options.md @@ -24,7 +24,10 @@ Testcontainers exposes the `WithStartupCommand(e ...Executable)` option to run a !!!info To better understand how this feature works, please read the [Create containers: Lifecycle Hooks](/features/creating_container/#lifecycle-hooks) documentation. -It also exports an `Executable` interface, defining one single method: `AsCommand()`, which returns a slice of strings to represent the command and positional arguments to be executed in the container. +It also exports an `Executable` interface, defining the following methods: + +- `AsCommand()`, which returns a slice of strings to represent the command and positional arguments to be executed in the container; +- `Options()` to set the command options, such as the working directory, environment variables, user executing the command, etc. It returns a slice of functional options to configure the command. You could use this feature to run a custom script, or to run a command that is not supported by the module right after the container is started. diff --git a/modules/cassandra/executable.go b/modules/cassandra/executable.go index 7645eb22ba..1a85829e80 100644 --- a/modules/cassandra/executable.go +++ b/modules/cassandra/executable.go @@ -2,9 +2,12 @@ package cassandra import ( "strings" + + "github.com/testcontainers/testcontainers-go" ) type initScript struct { + testcontainers.ExecOptions File string } diff --git a/modules/rabbitmq/examples_test.go b/modules/rabbitmq/examples_test.go index d239bc1f3a..3e9ae1451d 100644 --- a/modules/rabbitmq/examples_test.go +++ b/modules/rabbitmq/examples_test.go @@ -130,7 +130,10 @@ func ExampleRunContainer_withPlugins() { testcontainers.WithImage("rabbitmq:3.7.25-management-alpine"), // Multiple test implementations of the Executable interface, specific to RabbitMQ, exist in the types_test.go file. // Please refer to them for more examples. - testcontainers.WithStartupCommand(testcontainers.RawCommand{"rabbitmq_shovel"}, testcontainers.RawCommand{"rabbitmq_random_exchange"}), + testcontainers.WithStartupCommand( + testcontainers.NewRawCommand([]string{"rabbitmq_shovel"}), + testcontainers.NewRawCommand([]string{"rabbitmq_random_exchange"}), + ), ) if err != nil { panic(err) diff --git a/modules/rabbitmq/rabbitmq_test.go b/modules/rabbitmq/rabbitmq_test.go index 64fe27b1f6..5fb338ca75 100644 --- a/modules/rabbitmq/rabbitmq_test.go +++ b/modules/rabbitmq/rabbitmq_test.go @@ -124,7 +124,7 @@ func TestRunContainer_withAllSettings(t *testing.T) { }), // } // enablePlugins { - testcontainers.WithStartupCommand(Plugin("rabbitmq_shovel"), Plugin("rabbitmq_random_exchange")), + testcontainers.WithStartupCommand(Plugin{Name: "rabbitmq_shovel"}, Plugin{Name: "rabbitmq_random_exchange"}), // } ) if err != nil { diff --git a/modules/rabbitmq/types_test.go b/modules/rabbitmq/types_test.go index ca9fed49d4..7ed92f3805 100644 --- a/modules/rabbitmq/types_test.go +++ b/modules/rabbitmq/types_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/testcontainers/testcontainers-go" ) // The following structs are added as a demonstration for the RabbitMQ management API therefore, @@ -15,6 +17,7 @@ import ( // --------- Bindings --------- type Binding struct { + testcontainers.ConfigurableExec VHost string Source string Destination string @@ -72,6 +75,7 @@ func (b Binding) AsCommand() []string { // --------- Exchange --------- type Exchange struct { + testcontainers.ConfigurableExec Name string VHost string Type string @@ -117,6 +121,7 @@ func (e Exchange) AsCommand() []string { // --------- OperatorPolicy --------- type OperatorPolicy struct { + testcontainers.ConfigurableExec Name string Pattern string Definition map[string]interface{} @@ -151,6 +156,7 @@ func (op OperatorPolicy) AsCommand() []string { // --------- Parameter --------- type Parameter struct { + testcontainers.ConfigurableExec Component string Name string Value string @@ -176,6 +182,7 @@ func (p Parameter) AsCommand() []string { // --------- Permission --------- type Permission struct { + testcontainers.ConfigurableExec VHost string User string Configure string @@ -205,10 +212,13 @@ func (p Permission) AsCommand() []string { // --------- Plugin --------- -type Plugin string +type Plugin struct { + testcontainers.ConfigurableExec + Name string +} func (p Plugin) AsCommand() []string { - return []string{"rabbitmq-plugins", "enable", string(p)} + return []string{"rabbitmq-plugins", "enable", p.Name} } // --------- Plugin --------- @@ -216,6 +226,7 @@ func (p Plugin) AsCommand() []string { // --------- Policy --------- type Policy struct { + testcontainers.ConfigurableExec VHost string Name string Pattern string @@ -257,6 +268,7 @@ func (p Policy) AsCommand() []string { // --------- Queue --------- type Queue struct { + testcontainers.ConfigurableExec Name string VHost string AutoDelete bool @@ -297,6 +309,7 @@ func (q Queue) AsCommand() []string { // --------- User --------- type User struct { + testcontainers.ConfigurableExec Name string Password string Tags []string @@ -325,6 +338,7 @@ func (u User) AsCommand() []string { // --------- Virtual Hosts -------- type VirtualHost struct { + testcontainers.ConfigurableExec Name string Tracing bool } @@ -340,6 +354,7 @@ func (v VirtualHost) AsCommand() []string { } type VirtualHostLimit struct { + testcontainers.ConfigurableExec VHost string Name string Value int diff --git a/options.go b/options.go index 0e15fc9080..43c0b42b77 100644 --- a/options.go +++ b/options.go @@ -10,6 +10,7 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/network" + tcexec "github.com/testcontainers/testcontainers-go/exec" "github.com/testcontainers/testcontainers-go/wait" ) @@ -118,14 +119,37 @@ func WithNetwork(networkName string, alias string) CustomizeRequestOption { // as part of the PostStart lifecycle hook. type Executable interface { AsCommand() []string + Options() []tcexec.ProcessOption +} + +// ExecOptions is a struct that provides a default implementation for the Options method +// of the Executable interface. +type ExecOptions struct { + opts []tcexec.ProcessOption +} + +func (ce ExecOptions) Options() []tcexec.ProcessOption { + return ce.opts } // RawCommand is a type that implements Executable and represents a command to be sent to a container -type RawCommand []string +type RawCommand struct { + ExecOptions + cmds []string +} + +func NewRawCommand(cmds []string) RawCommand { + return RawCommand{ + cmds: cmds, + ExecOptions: ExecOptions{ + opts: []tcexec.ProcessOption{}, + }, + } +} // AsCommand returns the command as a slice of strings func (r RawCommand) AsCommand() []string { - return r + return r.cmds } // WithStartupCommand will execute the command representation of each Executable into the container. @@ -139,7 +163,7 @@ func WithStartupCommand(execs ...Executable) CustomizeRequestOption { for _, exec := range execs { execFn := func(ctx context.Context, c Container) error { - _, _, err := c.Exec(ctx, exec.AsCommand()) + _, _, err := c.Exec(ctx, exec.AsCommand(), exec.Options()...) return err } diff --git a/options_test.go b/options_test.go index 4d61c18a8e..c29b930be1 100644 --- a/options_test.go +++ b/options_test.go @@ -120,14 +120,6 @@ func TestWithNetworkMultipleCallsWithSameNameReuseTheNetwork(t *testing.T) { 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{ @@ -137,9 +129,7 @@ func TestWithStartupCommand(t *testing.T) { Started: true, } - testExec := testExecutable{ - cmds: []string{"touch", "/tmp/.testcontainers"}, - } + testExec := testcontainers.NewRawCommand([]string{"touch", "/tmp/.testcontainers"}) testcontainers.WithStartupCommand(testExec)(&req) From f600e9ed1211d021e54f517a5b5403de302d2df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20de=20la=20Pe=C3=B1a?= Date: Wed, 15 Nov 2023 13:11:32 +0100 Subject: [PATCH 4/5] fix: update old name --- modules/rabbitmq/types_test.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/modules/rabbitmq/types_test.go b/modules/rabbitmq/types_test.go index 7ed92f3805..8b607f6632 100644 --- a/modules/rabbitmq/types_test.go +++ b/modules/rabbitmq/types_test.go @@ -17,7 +17,7 @@ import ( // --------- Bindings --------- type Binding struct { - testcontainers.ConfigurableExec + testcontainers.ExecOptions VHost string Source string Destination string @@ -75,7 +75,7 @@ func (b Binding) AsCommand() []string { // --------- Exchange --------- type Exchange struct { - testcontainers.ConfigurableExec + testcontainers.ExecOptions Name string VHost string Type string @@ -121,7 +121,7 @@ func (e Exchange) AsCommand() []string { // --------- OperatorPolicy --------- type OperatorPolicy struct { - testcontainers.ConfigurableExec + testcontainers.ExecOptions Name string Pattern string Definition map[string]interface{} @@ -156,7 +156,7 @@ func (op OperatorPolicy) AsCommand() []string { // --------- Parameter --------- type Parameter struct { - testcontainers.ConfigurableExec + testcontainers.ExecOptions Component string Name string Value string @@ -182,7 +182,7 @@ func (p Parameter) AsCommand() []string { // --------- Permission --------- type Permission struct { - testcontainers.ConfigurableExec + testcontainers.ExecOptions VHost string User string Configure string @@ -213,7 +213,7 @@ func (p Permission) AsCommand() []string { // --------- Plugin --------- type Plugin struct { - testcontainers.ConfigurableExec + testcontainers.ExecOptions Name string } @@ -226,7 +226,7 @@ func (p Plugin) AsCommand() []string { // --------- Policy --------- type Policy struct { - testcontainers.ConfigurableExec + testcontainers.ExecOptions VHost string Name string Pattern string @@ -268,7 +268,7 @@ func (p Policy) AsCommand() []string { // --------- Queue --------- type Queue struct { - testcontainers.ConfigurableExec + testcontainers.ExecOptions Name string VHost string AutoDelete bool @@ -309,7 +309,7 @@ func (q Queue) AsCommand() []string { // --------- User --------- type User struct { - testcontainers.ConfigurableExec + testcontainers.ExecOptions Name string Password string Tags []string @@ -338,7 +338,7 @@ func (u User) AsCommand() []string { // --------- Virtual Hosts -------- type VirtualHost struct { - testcontainers.ConfigurableExec + testcontainers.ExecOptions Name string Tracing bool } @@ -354,7 +354,7 @@ func (v VirtualHost) AsCommand() []string { } type VirtualHostLimit struct { - testcontainers.ConfigurableExec + testcontainers.ExecOptions VHost string Name string Value int From d2c0612c04a1a8548ff471d3cbebe2f38df9512b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20de=20la=20Pe=C3=B1a?= Date: Fri, 17 Nov 2023 12:22:01 +0100 Subject: [PATCH 5/5] docs: improve docs about process options --- docs/features/common_functional_options.md | 2 +- options.go | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/features/common_functional_options.md b/docs/features/common_functional_options.md index 085cf3cb61..e3ebbde1e7 100644 --- a/docs/features/common_functional_options.md +++ b/docs/features/common_functional_options.md @@ -27,7 +27,7 @@ Testcontainers exposes the `WithStartupCommand(e ...Executable)` option to run a It also exports an `Executable` interface, defining the following methods: - `AsCommand()`, which returns a slice of strings to represent the command and positional arguments to be executed in the container; -- `Options()` to set the command options, such as the working directory, environment variables, user executing the command, etc. It returns a slice of functional options to configure the command. +- `Options()`, which returns the slice of functional options with the Docker's ExecConfigs used to create the command in the container (the working directory, environment variables, user executing the command, etc) and the possible output format (Multiplexed). You could use this feature to run a custom script, or to run a command that is not supported by the module right after the container is started. diff --git a/options.go b/options.go index 43c0b42b77..093ef9ab3f 100644 --- a/options.go +++ b/options.go @@ -115,10 +115,13 @@ func WithNetwork(networkName string, alias string) CustomizeRequestOption { } } -// Executable represents an executable command to be sent to a container -// as part of the PostStart lifecycle hook. +// Executable represents an executable command to be sent to a container, including options, +// as part of the different lifecycle hooks. type Executable interface { AsCommand() []string + // Options can container two different types of options: + // - Docker's ExecConfigs (WithUser, WithWorkingDir, WithEnv, etc.) + // - testcontainers' ProcessOptions (i.e. Multiplexed response) Options() []tcexec.ProcessOption }