diff --git a/compose.go b/compose.go index 98c422b69c..fa329fa891 100644 --- a/compose.go +++ b/compose.go @@ -2,6 +2,7 @@ package testcontainers import ( "context" + "errors" "path/filepath" "runtime" "strings" @@ -10,6 +11,7 @@ import ( "github.com/docker/cli/cli/flags" "github.com/docker/compose/v2/pkg/api" "github.com/docker/compose/v2/pkg/compose" + "github.com/google/uuid" "github.com/testcontainers/testcontainers-go/wait" ) @@ -19,6 +21,17 @@ const ( envComposeFile = "COMPOSE_FILE" ) +var ErrNoStackConfigured = errors.New("no stack files configured") + +type composeStackOptions struct { + Identifier string + Paths []string +} + +type ComposeStackOption interface { + applyToComposeStack(o *composeStackOptions) +} + type stackUpOptions struct { api.CreateOptions api.StartOptions @@ -43,6 +56,7 @@ type ComposeStack interface { Services() []string WaitForService(s string, strategy wait.Strategy) ComposeStack WithEnv(m map[string]string) ComposeStack + WithOsEnv() ComposeStack ServiceContainer(ctx context.Context, svcName string) (*DockerContainer, error) } @@ -63,7 +77,27 @@ type waitService struct { publishedPort int } -func NewDockerComposeAPI(filePaths []string, identifier string) (*dockerComposeAPI, error) { +func WithStackFiles(filePaths ...string) ComposeStackOption { + return ComposeStackFiles(filePaths) +} + +func NewDockerComposeAPI(filePaths ...string) (*dockerComposeAPI, error) { + return NewDockerComposeAPIWith(WithStackFiles(filePaths...)) +} + +func NewDockerComposeAPIWith(opts ...ComposeStackOption) (*dockerComposeAPI, error) { + composeOptions := composeStackOptions{ + Identifier: uuid.New().String(), + } + + for i := range opts { + opts[i].applyToComposeStack(&composeOptions) + } + + if len(composeOptions.Paths) < 1 { + return nil, ErrNoStackConfigured + } + dockerCli, err := command.NewDockerCli() if err != nil { return nil, err @@ -76,8 +110,8 @@ func NewDockerComposeAPI(filePaths []string, identifier string) (*dockerComposeA } composeAPI := &dockerComposeAPI{ - name: identifier, - configs: filePaths, + name: composeOptions.Identifier, + configs: composeOptions.Paths, composeService: compose.NewComposeService(dockerCli), dockerClient: dockerCli.Client(), waitStrategies: make(map[string]wait.Strategy), diff --git a/compose_api.go b/compose_api.go index 8f1d383b90..7129520c41 100644 --- a/compose_api.go +++ b/compose_api.go @@ -75,6 +75,22 @@ func (ri RemoveImages) applyToStackDown(o *stackDownOptions) { } } +type ComposeStackFiles []string + +func (f ComposeStackFiles) applyToComposeStack(o *composeStackOptions) { + o.Paths = f +} + +type StackIdentifier string + +func (f StackIdentifier) applyToComposeStack(o *composeStackOptions) { + o.Identifier = string(f) +} + +func (f StackIdentifier) String() string { + return string(f) +} + const ( // RemoveImagesAll - remove all images used by the stack RemoveImagesAll RemoveImages = iota @@ -184,6 +200,11 @@ func (d *dockerComposeAPI) WithEnv(m map[string]string) ComposeStack { return d } +func (d *dockerComposeAPI) WithOsEnv() ComposeStack { + d.projectOptions = append(d.projectOptions, cli.WithOsEnv) + return d +} + func (d *dockerComposeAPI) ServiceContainer(ctx context.Context, svcName string) (*DockerContainer, error) { d.lock.Lock() defer d.lock.Unlock() diff --git a/compose_api_test.go b/compose_api_test.go index 6d25d2855b..32c8f2929a 100644 --- a/compose_api_test.go +++ b/compose_api_test.go @@ -13,11 +13,7 @@ import ( ) func TestDockerComposeAPI(t *testing.T) { - path := "./testresources/docker-compose-simple.yml" - - identifier := testNameHash(t.Name()) - - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPI("./testresources/docker-compose-simple.yml") assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -31,11 +27,7 @@ func TestDockerComposeAPI(t *testing.T) { } func TestDockerComposeAPIStrategyForInvalidService(t *testing.T) { - path := "./testresources/docker-compose-simple.yml" - - identifier := testNameHash(t.Name()) - - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPI("./testresources/docker-compose-simple.yml") assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -59,11 +51,7 @@ func TestDockerComposeAPIStrategyForInvalidService(t *testing.T) { } func TestDockerComposeAPIWithWaitLogStrategy(t *testing.T) { - path := "./testresources/docker-compose-complex.yml" - - identifier := testNameHash(t.Name()) - - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPI("./testresources/docker-compose-complex.yml") assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -87,11 +75,7 @@ func TestDockerComposeAPIWithWaitLogStrategy(t *testing.T) { } func TestDockerComposeAPIWithRunServices(t *testing.T) { - path := "./testresources/docker-compose-complex.yml" - - identifier := testNameHash(t.Name()) - - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPI("./testresources/docker-compose-complex.yml") assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -117,11 +101,7 @@ func TestDockerComposeAPIWithRunServices(t *testing.T) { } func TestDockerComposeAPIWithWaitForService(t *testing.T) { - path := "./testresources/docker-compose-simple.yml" - - identifier := testNameHash(t.Name()) - - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPI("./testresources/docker-compose-simple.yml") assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -147,11 +127,7 @@ func TestDockerComposeAPIWithWaitForService(t *testing.T) { } func TestDockerComposeAPIWithWaitHTTPStrategy(t *testing.T) { - path := "./testresources/docker-compose-simple.yml" - - identifier := testNameHash(t.Name()) - - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPI("./testresources/docker-compose-simple.yml") assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -177,11 +153,7 @@ func TestDockerComposeAPIWithWaitHTTPStrategy(t *testing.T) { } func TestDockerComposeAPIWithContainerName(t *testing.T) { - path := "./testresources/docker-compose-container-name.yml" - - identifier := testNameHash(t.Name()) - - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPI("./testresources/docker-compose-container-name.yml") assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -207,11 +179,7 @@ func TestDockerComposeAPIWithContainerName(t *testing.T) { } func TestDockerComposeAPIWithWaitStrategy_NoExposedPorts(t *testing.T) { - path := "./testresources/docker-compose-no-exposed-ports.yml" - - identifier := testNameHash(t.Name()) - - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPI("./testresources/docker-compose-no-exposed-ports.yml") assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -234,11 +202,7 @@ func TestDockerComposeAPIWithWaitStrategy_NoExposedPorts(t *testing.T) { } func TestDockerComposeAPIWithMultipleWaitStrategies(t *testing.T) { - path := "./testresources/docker-compose-complex.yml" - - identifier := testNameHash(t.Name()) - - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPI("./testresources/docker-compose-complex.yml") assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -263,11 +227,7 @@ func TestDockerComposeAPIWithMultipleWaitStrategies(t *testing.T) { } func TestDockerComposeAPIWithFailedStrategy(t *testing.T) { - path := "./testresources/docker-compose-simple.yml" - - identifier := testNameHash(t.Name()) - - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPI("./testresources/docker-compose-simple.yml") assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -295,11 +255,7 @@ func TestDockerComposeAPIWithFailedStrategy(t *testing.T) { } func TestDockerComposeAPIComplex(t *testing.T) { - path := "./testresources/docker-compose-complex.yml" - - identifier := testNameHash(t.Name()) - - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPI("./testresources/docker-compose-complex.yml") assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -319,11 +275,9 @@ func TestDockerComposeAPIComplex(t *testing.T) { } func TestDockerComposeAPIWithEnvironment(t *testing.T) { - path := "./testresources/docker-compose-simple.yml" - identifier := testNameHash(t.Name()) - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPIWith(WithStackFiles("./testresources/docker-compose-simple.yml"), identifier) assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -350,11 +304,11 @@ func TestDockerComposeAPIWithEnvironment(t *testing.T) { "bar": "BAR", } absent := map[string]string{} - assertContainerEnvironmentVariables(t, identifier, "nginx", present, absent) + assertContainerEnvironmentVariables(t, identifier.String(), "nginx", present, absent) } func TestDockerComposeAPIWithMultipleComposeFiles(t *testing.T) { - composeFiles := []string{ + composeFiles := ComposeStackFiles{ "testresources/docker-compose-simple.yml", "testresources/docker-compose-postgres.yml", "testresources/docker-compose-override.yml", @@ -362,7 +316,7 @@ func TestDockerComposeAPIWithMultipleComposeFiles(t *testing.T) { identifier := testNameHash(t.Name()) - compose, err := NewDockerComposeAPI(composeFiles, identifier) + compose, err := NewDockerComposeAPIWith(composeFiles, identifier) assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -392,15 +346,11 @@ func TestDockerComposeAPIWithMultipleComposeFiles(t *testing.T) { "foo": "FOO", } absent := map[string]string{} - assertContainerEnvironmentVariables(t, identifier, "nginx", present, absent) + assertContainerEnvironmentVariables(t, identifier.String(), "nginx", present, absent) } func TestDockerComposeAPIWithVolume(t *testing.T) { - path := "./testresources/docker-compose-volume.yml" - - identifier := testNameHash(t.Name()) - - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPI("./testresources/docker-compose-volume.yml") assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -415,11 +365,7 @@ func TestDockerComposeAPIWithVolume(t *testing.T) { } func TestDockerComposeAPIWithBuild(t *testing.T) { - path := "./testresources/docker-compose-build.yml" - - identifier := testNameHash(t.Name()) - - compose, err := NewDockerComposeAPI([]string{path}, identifier) + compose, err := NewDockerComposeAPI("./testresources/docker-compose-build.yml") assert.NoError(t, err, "NewDockerComposeAPI()") t.Cleanup(func() { @@ -436,6 +382,6 @@ func TestDockerComposeAPIWithBuild(t *testing.T) { assert.NoError(t, err, "compose.Up()") } -func testNameHash(name string) string { - return fmt.Sprintf("%x", fnv.New32a().Sum([]byte(name))) +func testNameHash(name string) StackIdentifier { + return StackIdentifier(fmt.Sprintf("%x", fnv.New32a().Sum([]byte(name)))) } diff --git a/docs/features/docker_compose.md b/docs/features/docker_compose.md index fc85ace7a6..7090e7b252 100644 --- a/docs/features/docker_compose.md +++ b/docs/features/docker_compose.md @@ -7,12 +7,145 @@ This is intended to be useful on projects where Docker Compose is already used in dev or other environments to define services that an application may be dependent upon. +## Using `docker-compose` directly + +Because `docker-compose` v2 is implemented in Go it's possible for _testcontainers-go_ to +use [`github.com/docker/compose`](https://github.com/docker/compose) directly and skip any process execution/_ +docker-compose-in-a-container_ scenario. +The `ComposeStack` API exposes this variant of using `docker-compose` in an easy way. + +### Basic examples + +Use the convenience `NewDockerComposeAPI(...)` constructor which creates a random identifier and takes a variable number +of stack files: + +```go +package example_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + tc "github.com/testcontainers/testcontainers-go" +) + +func TestSomething(t *testing.T) { + compose, err := tc.NewDockerComposeAPI("testresources/docker-compose.yml") + assert.NoError(t, err, "NewDockerComposeAPI()") + + t.Cleanup(func() { + assert.NoError(t, compose.Down(context.Background(), tc.RemoveOrphans(true), tc.RemoveImagesLocal), "compose.Down()") + }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + assert.NoError(t, compose.Up(ctx, tc.Wait(true)), "compose.Up()") + + // do some testing here +} +``` + +Use the advanced `NewDockerComposeAPIWith(...)` constructor allowing you to specify an identifier: + +```go +package example_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + tc "github.com/testcontainers/testcontainers-go" +) + +func TestSomethingElse(t *testing.T) { + identifier := tc.StackIdentifier("some_ident") + compose, err := tc.NewDockerComposeAPIWith(tc.WithStackFiles("./testresources/docker-compose-simple.yml"), identifier) + assert.NoError(t, err, "NewDockerComposeAPIWith()") + + t.Cleanup(func() { + assert.NoError(t, compose.Down(context.Background(), tc.RemoveOrphans(true), tc.RemoveImagesLocal), "compose.Down()") + }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + assert.NoError(t, compose.Up(ctx, tc.Wait(true)), "compose.Up()") + + // do some testing here +} +``` + +### Wait strategies + +Just like with regular test containers you can also apply wait strategies to `docker-compose` services. +The `ComposeStack.WaitForService(...)` function allows you to apply a wait strategy to a service by name. +All wait strategies are executed in parallel to both improve startup performance by not blocking too long and to fail +early if something's wrong. + +#### Example + +```go +package example_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + tc "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +func TestSomethingWithWaiting(t *testing.T) { + identifier := tc.StackIdentifier("some_ident") + compose, err := tc.NewDockerComposeAPIWith(tc.WithStackFiles("./testresources/docker-compose-simple.yml"), identifier) + assert.NoError(t, err, "NewDockerComposeAPIWith()") + + t.Cleanup(func() { + assert.NoError(t, compose.Down(context.Background(), tc.RemoveOrphans(true), tc.RemoveImagesLocal), "compose.Down()") + }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + err = compose. + WaitForService("nginx", wait.NewHTTPStrategy("/").WithPort("80/tcp").WithStartupTimeout(10*time.Second)). + Up(ctx, tc.Wait(true)) + + assert.NoError(t, err, "compose.Up()") + + // do some testing here +} +``` + +### Compose environment + +`docker-compose` supports expansion based on environment variables. +The `ComposeStack` supports this as well in two different variants: + +- `ComposeStack.WithEnv(m map[string]string) ComposeStack` to parameterize stacks from your test code +- `ComposeStack.WithOsEnv() ComposeStack` to parameterize tests from the OS environment e.g. in CI environments + +### Docs + +Also have a look at [ComposeStack](https://pkg.go.dev/github.com/testcontainers/testcontainers-go#ComposeStack) docs for +further information. + +## Usage of `docker-compose` binary + +_Node:_ this API is deprecated and superseded by `ComposeStack` which takes advantage of `docker-compose` v2 being +implemented in Go as well by directly using the upstream project. + You can override Testcontainers' default behaviour and make it use a docker-compose binary installed on the local machine. This will generally yield an experience that is closer to running docker-compose locally, with the caveat that Docker Compose needs to be present on dev and CI machines. -## Examples +### Examples ```go composeFilePaths := []string {"testresources/docker-compose.yml"} @@ -20,15 +153,15 @@ identifier := strings.ToLower(uuid.New().String()) compose := tc.NewLocalDockerCompose(composeFilePaths, identifier) execError := compose. - WithCommand([]string{"up", "-d"}). - WithEnv(map[string]string { - "key1": "value1", - "key2": "value2", - }). - Invoke() +WithCommand([]string{"up", "-d"}). +WithEnv(map[string]string { +"key1": "value1", +"key2": "value2", +}). +Invoke() err := execError.Error if err != nil { - return fmt.Errorf("Could not run compose file: %v - %v", composeFilePaths, err) +return fmt.Errorf("Could not run compose file: %v - %v", composeFilePaths, err) } return nil ``` @@ -46,7 +179,7 @@ compose := tc.NewLocalDockerCompose(composeFilePaths, identifierFromExistingRunn execError := compose.Down() err := execError.Error if err != nil { - return fmt.Errorf("Could not run compose file: %v - %v", composeFilePaths, err) +return fmt.Errorf("Could not run compose file: %v - %v", composeFilePaths, err) } return nil ``` diff --git a/go.sum b/go.sum index dd86c36065..8af28c358f 100644 --- a/go.sum +++ b/go.sum @@ -2225,6 +2225,7 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/gotestsum v1.8.1 h1:C6dYd5K39WAv52jikEUuWgyMqJDhY90eauUjsFzwluc= +gotest.tools/gotestsum v1.8.1/go.mod h1:ctqdxBSCPv80kAFjYvFNpPntBrE5HAQnLiOKBGLmOBs= gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= gotest.tools/v3 v3.1.0/go.mod h1:fHy7eyTmJFO5bQbUsEGQ1v4m2J3Jz9eWL54TP2/ZuYQ=