From 69f3ddbe727b0d163c78844ca689504e4ce18fce Mon Sep 17 00:00:00 2001 From: oGi4i Date: Wed, 15 Jun 2022 15:01:53 +0300 Subject: [PATCH] feat: add reaper for compose --- compose.go | 44 +++++++- compose_test.go | 276 +++++++++++++++++++++++++++++------------------- docker.go | 4 +- reaper.go | 60 +++++++++-- 4 files changed, 264 insertions(+), 120 deletions(-) diff --git a/compose.go b/compose.go index 160decbc27..5dfa2229cb 100644 --- a/compose.go +++ b/compose.go @@ -16,6 +16,7 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/client" + "github.com/google/uuid" "gopkg.in/yaml.v3" "github.com/testcontainers/testcontainers-go/wait" @@ -54,6 +55,7 @@ type LocalDockerCompose struct { Services map[string]interface{} waitStrategySupplied bool WaitStrategyMap map[waitService]wait.Strategy + reaper *Reaper } type ( @@ -83,7 +85,7 @@ func (f LocalDockerComposeOptionsFunc) ApplyToLocalCompose(opts *LocalDockerComp // Docker Compose execution. The identifier represents the name of the execution, // which will define the name of the underlying Docker network and the name of the // running Compose services. -func NewLocalDockerCompose(filePaths []string, identifier string, opts ...LocalDockerComposeOption) *LocalDockerCompose { +func NewLocalDockerCompose(filePaths []string, identifier string, opts ...LocalDockerComposeOption) (*LocalDockerCompose, error) { dc := &LocalDockerCompose{ LocalDockerComposeOptions: &LocalDockerComposeOptions{ Logger: Logger, @@ -113,7 +115,27 @@ func NewLocalDockerCompose(filePaths []string, identifier string, opts ...LocalD dc.waitStrategySupplied = false dc.WaitStrategyMap = make(map[waitService]wait.Strategy) - return dc + reaperProvider, err := NewDockerProvider(WithLogger(dc.Logger)) + if err != nil { + return nil, fmt.Errorf("%w: creating reaper provider failed", err) + } + + sessionID := uuid.New() + dc.reaper, err = NewReaper( + context.Background(), + sessionID.String(), + reaperProvider, + withCustomLabels( + map[string]string{ + "com.docker.compose.project": dc.Identifier, + }, + ), + ) + if err != nil { + return nil, fmt.Errorf("%w: creating reaper failed", err) + } + + return dc, nil } // Down executes docker-compose down @@ -185,7 +207,13 @@ func (dc *LocalDockerCompose) applyStrategyToRunningContainer() error { // Invoke invokes the docker compose func (dc *LocalDockerCompose) Invoke() ExecError { - return executeCompose(dc, dc.Cmd) + execErr := executeCompose(dc, dc.Cmd) + err := dc.connectToReaper() + if err != nil && execErr.Error == nil { + execErr.Error = err + } + + return execErr } // WaitForService sets the strategy for the service that is to be waited on @@ -246,6 +274,16 @@ func (dc *LocalDockerCompose) validate() error { return nil } +func (dc *LocalDockerCompose) connectToReaper() error { + terminationSignal, err := dc.reaper.Connect() + if err != nil { + return fmt.Errorf("%w: connecting to reaper failed", err) + } + + close(terminationSignal) + return nil +} + // ExecError is super struct that holds any information about an execution error, so the client code // can handle the result type ExecError struct { diff --git a/compose_test.go b/compose_test.go index 792bd2950f..933057e37b 100644 --- a/compose_test.go +++ b/compose_test.go @@ -16,7 +16,7 @@ import ( func ExampleNewLocalDockerCompose() { path := "/path/to/docker-compose.yml" - _ = NewLocalDockerCompose([]string{path}, "my_project") + _, _ = NewLocalDockerCompose([]string{path}, "my_project") } func ExampleLocalDockerCompose() { @@ -42,7 +42,10 @@ func ExampleLocalDockerCompose() { func ExampleLocalDockerCompose_Down() { path := "/path/to/docker-compose.yml" - compose := NewLocalDockerCompose([]string{path}, "my_project") + compose, err := NewLocalDockerCompose([]string{path}, "my_project") + if err != nil { + _ = fmt.Errorf("Failed when creating local docker compose: %v", err) + } execError := compose.WithCommand([]string{"up", "-d"}).Invoke() if execError.Error != nil { @@ -58,7 +61,10 @@ func ExampleLocalDockerCompose_Down() { func ExampleLocalDockerCompose_Invoke() { path := "/path/to/docker-compose.yml" - compose := NewLocalDockerCompose([]string{path}, "my_project") + compose, err := NewLocalDockerCompose([]string{path}, "my_project") + if err != nil { + _ = fmt.Errorf("Failed when creating local docker compose: %v", err) + } execError := compose. WithCommand([]string{"up", "-d"}). @@ -74,7 +80,10 @@ func ExampleLocalDockerCompose_Invoke() { func ExampleLocalDockerCompose_WithCommand() { path := "/path/to/docker-compose.yml" - compose := NewLocalDockerCompose([]string{path}, "my_project") + compose, err := NewLocalDockerCompose([]string{path}, "my_project") + if err != nil { + _ = fmt.Errorf("Failed when creating local docker compose: %v", err) + } compose.WithCommand([]string{"up", "-d"}) } @@ -82,7 +91,10 @@ func ExampleLocalDockerCompose_WithCommand() { func ExampleLocalDockerCompose_WithEnv() { path := "/path/to/docker-compose.yml" - compose := NewLocalDockerCompose([]string{path}, "my_project") + compose, err := NewLocalDockerCompose([]string{path}, "my_project") + if err != nil { + _ = fmt.Errorf("Failed when creating local docker compose: %v", err) + } compose.WithEnv(map[string]string{ "FOO": "foo", @@ -95,36 +107,37 @@ func TestLocalDockerCompose(t *testing.T) { identifier := strings.ToLower(uuid.New().String()) - compose := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) - destroyFn := func() { - err := compose.Down() - checkIfError(t, err) - } - defer destroyFn() + compose, err := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) - err := compose. + defer func() { + checkIfError(t, compose.Down()) + }() + + execErr := compose. WithCommand([]string{"up", "-d"}). Invoke() - checkIfError(t, err) + checkIfError(t, execErr) } + func TestDockerComposeStrategyForInvalidService(t *testing.T) { path := "./testresources/docker-compose-simple.yml" identifier := strings.ToLower(uuid.New().String()) - compose := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) - destroyFn := func() { - err := compose.Down() - checkIfError(t, err) - } - defer destroyFn() + compose, err := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) - err := compose. + defer func() { + checkIfError(t, compose.Down()) + }() + + execErr := compose. WithCommand([]string{"up", "-d"}). // Appending with _1 as given in the Java Test-Containers Example WithExposedService("mysql_1", 13306, wait.NewLogStrategy("started").WithStartupTimeout(10*time.Second).WithOccurrence(1)). Invoke() - assert.NotEqual(t, err.Error, nil, "Expected error to be thrown because service with wait strategy is not running") + assert.NotEqual(t, execErr.Error, nil, "Expected error to be thrown because service with wait strategy is not running") assert.Equal(t, 1, len(compose.Services)) assert.Contains(t, compose.Services, "nginx") @@ -135,19 +148,19 @@ func TestDockerComposeWithWaitLogStrategy(t *testing.T) { identifier := strings.ToLower(uuid.New().String()) - compose := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) - destroyFn := func() { - err := compose.Down() - checkIfError(t, err) - } - defer destroyFn() + compose, err := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) - err := compose. + defer func() { + checkIfError(t, compose.Down()) + }() + + execErr := compose. WithCommand([]string{"up", "-d"}). // Appending with _1 as given in the Java Test-Containers Example WithExposedService("mysql_1", 13306, wait.NewLogStrategy("started").WithStartupTimeout(10*time.Second).WithOccurrence(1)). Invoke() - checkIfError(t, err) + checkIfError(t, execErr) assert.Equal(t, 2, len(compose.Services)) assert.Contains(t, compose.Services, "nginx") @@ -159,21 +172,21 @@ func TestDockerComposeWithWaitForService(t *testing.T) { identifier := strings.ToLower(uuid.New().String()) - compose := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) - destroyFn := func() { - err := compose.Down() - checkIfError(t, err) - } - defer destroyFn() + compose, err := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) + + defer func() { + checkIfError(t, compose.Down()) + }() - err := compose. + execErr := compose. WithCommand([]string{"up", "-d"}). WithEnv(map[string]string{ "bar": "BAR", }). WaitForService("nginx_1", wait.NewHTTPStrategy("/").WithPort("80/tcp").WithStartupTimeout(10*time.Second)). Invoke() - checkIfError(t, err) + checkIfError(t, execErr) assert.Equal(t, 1, len(compose.Services)) assert.Contains(t, compose.Services, "nginx") @@ -184,21 +197,21 @@ func TestDockerComposeWithWaitHTTPStrategy(t *testing.T) { identifier := strings.ToLower(uuid.New().String()) - compose := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) - destroyFn := func() { - err := compose.Down() - checkIfError(t, err) - } - defer destroyFn() + compose, err := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) - err := compose. + defer func() { + checkIfError(t, compose.Down()) + }() + + execErr := compose. WithCommand([]string{"up", "-d"}). WithEnv(map[string]string{ "bar": "BAR", }). WithExposedService("nginx_1", 9080, wait.NewHTTPStrategy("/").WithPort("80/tcp").WithStartupTimeout(10*time.Second)). Invoke() - checkIfError(t, err) + checkIfError(t, execErr) assert.Equal(t, 1, len(compose.Services)) assert.Contains(t, compose.Services, "nginx") @@ -209,21 +222,21 @@ func TestDockerComposeWithContainerName(t *testing.T) { identifier := strings.ToLower(uuid.New().String()) - compose := NewLocalDockerCompose([]string{path}, identifier) - destroyFn := func() { - err := compose.Down() - checkIfError(t, err) - } - defer destroyFn() + compose, err := NewLocalDockerCompose([]string{path}, identifier) + assert.Nil(t, err) - err := compose. + defer func() { + checkIfError(t, compose.Down()) + }() + + execErr := compose. WithCommand([]string{"up", "-d"}). WithEnv(map[string]string{ "bar": "BAR", }). WithExposedService("nginxy", 9080, wait.NewHTTPStrategy("/").WithPort("80/tcp").WithStartupTimeout(10*time.Second)). Invoke() - checkIfError(t, err) + checkIfError(t, execErr) assert.Equal(t, 1, len(compose.Services)) assert.Contains(t, compose.Services, "nginx") @@ -234,18 +247,18 @@ func TestDockerComposeWithWaitStrategy_NoExposedPorts(t *testing.T) { identifier := strings.ToLower(uuid.New().String()) - compose := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) - destroyFn := func() { - err := compose.Down() - checkIfError(t, err) - } - defer destroyFn() + compose, err := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) + + defer func() { + checkIfError(t, compose.Down()) + }() - err := compose. + execErr := compose. WithCommand([]string{"up", "-d"}). WithExposedService("nginx_1", 9080, wait.ForLog("Configuration complete; ready for start up")). Invoke() - checkIfError(t, err) + checkIfError(t, execErr) assert.Equal(t, 1, len(compose.Services)) assert.Contains(t, compose.Services, "nginx") @@ -256,19 +269,19 @@ func TestDockerComposeWithMultipleWaitStrategies(t *testing.T) { identifier := strings.ToLower(uuid.New().String()) - compose := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) - destroyFn := func() { - err := compose.Down() - checkIfError(t, err) - } - defer destroyFn() + compose, err := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) - err := compose. + defer func() { + checkIfError(t, compose.Down()) + }() + + execErr := compose. WithCommand([]string{"up", "-d"}). WithExposedService("mysql_1", 13306, wait.NewLogStrategy("started").WithStartupTimeout(10*time.Second)). WithExposedService("nginx_1", 9080, wait.NewHTTPStrategy("/").WithPort("80/tcp").WithStartupTimeout(10*time.Second)). Invoke() - checkIfError(t, err) + checkIfError(t, execErr) assert.Equal(t, 2, len(compose.Services)) assert.Contains(t, compose.Services, "nginx") @@ -280,14 +293,14 @@ func TestDockerComposeWithFailedStrategy(t *testing.T) { identifier := strings.ToLower(uuid.New().String()) - compose := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) - destroyFn := func() { - err := compose.Down() - checkIfError(t, err) - } - defer destroyFn() + compose, err := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) + + defer func() { + checkIfError(t, compose.Down()) + }() - err := compose. + execErr := compose. WithCommand([]string{"up", "-d"}). WithEnv(map[string]string{ "bar": "BAR", @@ -296,7 +309,7 @@ func TestDockerComposeWithFailedStrategy(t *testing.T) { Invoke() // Verify that an error is thrown and not nil // A specific error message matcher is not asserted since the docker library can change the return message, breaking this test - assert.NotEqual(t, err.Error, nil, "Expected error to be thrown because of a wrong suplied wait strategy") + assert.NotEqual(t, execErr.Error, nil, "Expected error to be thrown because of a wrong suplied wait strategy") assert.Equal(t, 1, len(compose.Services)) assert.Contains(t, compose.Services, "nginx") @@ -307,17 +320,17 @@ func TestLocalDockerComposeComplex(t *testing.T) { identifier := strings.ToLower(uuid.New().String()) - compose := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) - destroyFn := func() { - err := compose.Down() - checkIfError(t, err) - } - defer destroyFn() + compose, err := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) + + defer func() { + checkIfError(t, compose.Down()) + }() - err := compose. + execErr := compose. WithCommand([]string{"up", "-d"}). Invoke() - checkIfError(t, err) + checkIfError(t, execErr) assert.Equal(t, 2, len(compose.Services)) assert.Contains(t, compose.Services, "nginx") @@ -329,20 +342,20 @@ func TestLocalDockerComposeWithEnvironment(t *testing.T) { identifier := strings.ToLower(uuid.New().String()) - compose := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) - destroyFn := func() { - err := compose.Down() - checkIfError(t, err) - } - defer destroyFn() + compose, err := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) - err := compose. + defer func() { + checkIfError(t, compose.Down()) + }() + + execErr := compose. WithCommand([]string{"up", "-d"}). WithEnv(map[string]string{ "bar": "BAR", }). Invoke() - checkIfError(t, err) + checkIfError(t, execErr) assert.Equal(t, 1, len(compose.Services)) assert.Contains(t, compose.Services, "nginx") @@ -365,21 +378,21 @@ func TestLocalDockerComposeWithMultipleComposeFiles(t *testing.T) { identifier := strings.ToLower(uuid.New().String()) - compose := NewLocalDockerCompose(composeFiles, identifier, WithLogger(TestLogger(t))) - destroyFn := func() { - err := compose.Down() - checkIfError(t, err) - } - defer destroyFn() + compose, err := NewLocalDockerCompose(composeFiles, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) - err := compose. + defer func() { + checkIfError(t, compose.Down()) + }() + + execErr := compose. WithCommand([]string{"up", "-d"}). WithEnv(map[string]string{ "bar": "BAR", "foo": "FOO", }). Invoke() - checkIfError(t, err) + checkIfError(t, execErr) assert.Equal(t, 3, len(compose.Services)) assert.Contains(t, compose.Services, "nginx") @@ -401,18 +414,53 @@ func TestLocalDockerComposeWithVolume(t *testing.T) { identifier := strings.ToLower(uuid.New().String()) - compose := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) - destroyFn := func() { - err := compose.Down() - checkIfError(t, err) + compose, err := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) + + defer func() { + checkIfError(t, compose.Down()) assertVolumeDoesNotExist(t, fmt.Sprintf("%s_mydata", identifier)) - } - defer destroyFn() + }() - err := compose. + execErr := compose. WithCommand([]string{"up", "-d"}). Invoke() - checkIfError(t, err) + checkIfError(t, execErr) +} + +func TestLocalDockerComposeWithReaperCleanup(t *testing.T) { + path := "./testresources/docker-compose-simple.yml" + + identifier := strings.ToLower(uuid.New().String()) + + compose, err := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) + + execErr := compose. + WithCommand([]string{"up", "-d"}). + Invoke() + checkIfError(t, execErr) + + time.Sleep(11 * time.Second) + assertContainerDoesNotExist(t, identifier+"-nginx-1") +} + +func TestLocalDockerComposeWithVolumeAndReaperCleanup(t *testing.T) { + path := "./testresources/docker-compose-volume.yml" + + identifier := strings.ToLower(uuid.New().String()) + + compose, err := NewLocalDockerCompose([]string{path}, identifier, WithLogger(TestLogger(t))) + assert.Nil(t, err) + + execErr := compose. + WithCommand([]string{"up", "-d"}). + Invoke() + checkIfError(t, execErr) + + time.Sleep(11 * time.Second) + assertContainerDoesNotExist(t, identifier+"-nginx-1") + assertVolumeDoesNotExist(t, fmt.Sprintf("%s_mydata", identifier)) } func assertVolumeDoesNotExist(t *testing.T, volume string) { @@ -441,6 +489,16 @@ func assertContainerEnvironmentVariables(t *testing.T, containerName string, pre } } +func assertContainerDoesNotExist(t *testing.T, containerName string) { + args := []string{"ps", "-q", "--filter", "name=" + containerName} + + output, err := executeAndGetOutput("docker", args) + checkIfError(t, err) + if len(output) > 0 { + t.Fatalf("Expected container %q to not exist", containerName) + } +} + func checkIfError(t *testing.T, err ExecError) { if err.Error != nil { t.Fatalf("Failed when running %v: %v", err.Command, err.Error) diff --git a/docker.go b/docker.go index 5ae5cb4a62..d4bce39913 100644 --- a/docker.go +++ b/docker.go @@ -777,7 +777,7 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque var termSignal chan bool if !req.SkipReaper { - r, err := NewReaper(ctx, sessionID.String(), p, req.ReaperImage) + r, err := NewReaper(ctx, sessionID.String(), p, withCustomReaperImageName(req.ReaperImage)) if err != nil { return nil, fmt.Errorf("%w: creating reaper failed", err) } @@ -1056,7 +1056,7 @@ func (p *DockerProvider) CreateNetwork(ctx context.Context, req NetworkRequest) var termSignal chan bool if !req.SkipReaper { - r, err := NewReaper(ctx, sessionID.String(), p, req.ReaperImage) + r, err := NewReaper(ctx, sessionID.String(), p, withCustomReaperImageName(req.ReaperImage)) if err != nil { return nil, fmt.Errorf("%w: creating network reaper failed", err) } diff --git a/reaper.go b/reaper.go index 55c7ac2937..4ea230581f 100644 --- a/reaper.go +++ b/reaper.go @@ -36,17 +36,50 @@ type ReaperProvider interface { // Reaper is used to start a sidecar container that cleans up resources type Reaper struct { + ReaperOptions Provider ReaperProvider SessionID string Endpoint string } +// ReaperOptions defines options applicable to Reaper +type ReaperOptions struct { + CustomLabels map[string]string + CustomReaperImageName string +} + +// ReaperOption defines a function to modify ReaperOptions +// These options can be passed to NewReaper in a variadic way to customize the returned Reaper instance +type ReaperOption func(opts *ReaperOptions) + +func withCustomReaperImageName(imageName string) ReaperOption { + return func(opts *ReaperOptions) { + opts.CustomReaperImageName = imageName + } +} + +func withCustomLabels(labels map[string]string) ReaperOption { + return func(opts *ReaperOptions) { + if opts.CustomLabels == nil { + opts.CustomLabels = labels + } else { + for k, v := range labels { + opts.CustomLabels[k] = v + } + } + } +} + // NewReaper creates a Reaper with a sessionID to identify containers and a provider to use -func NewReaper(ctx context.Context, sessionID string, provider ReaperProvider, reaperImageName string) (*Reaper, error) { +func NewReaper(ctx context.Context, sessionID string, provider ReaperProvider, opts ...ReaperOption) (*Reaper, error) { mutex.Lock() defer mutex.Unlock() // If reaper already exists re-use it if reaper != nil { + for idx := range opts { + opts[idx](&reaper.ReaperOptions) + } + return reaper, nil } @@ -56,10 +89,14 @@ func NewReaper(ctx context.Context, sessionID string, provider ReaperProvider, r SessionID: sessionID, } + for idx := range opts { + opts[idx](&reaper.ReaperOptions) + } + listeningPort := nat.Port("8080/tcp") req := ContainerRequest{ - Image: reaperImage(reaperImageName), + Image: reaper.imageName(), ExposedPorts: []string{string(listeningPort)}, Labels: map[string]string{ TestcontainerLabel: "true", @@ -93,12 +130,12 @@ func NewReaper(ctx context.Context, sessionID string, provider ReaperProvider, r return reaper, nil } -func reaperImage(reaperImageName string) string { - if reaperImageName == "" { +func (r *Reaper) imageName() string { + if r.CustomReaperImageName == "" { return ReaperDefaultImage - } else { - return reaperImageName } + + return r.CustomReaperImageName } // Connect runs a goroutine which can be terminated by sending true into the returned channel @@ -144,6 +181,17 @@ func (r *Reaper) Connect() (chan bool, error) { // Labels returns the container labels to use so that this Reaper cleans them up func (r *Reaper) Labels() map[string]string { + if r.CustomLabels != nil { + mutex.Lock() + defer mutex.Unlock() + + labels := make(map[string]string, len(r.CustomLabels)) + for k, v := range r.CustomLabels { + labels[k] = v + } + return labels + } + return map[string]string{ TestcontainerLabel: "true", TestcontainerLabelSessionID: r.SessionID,