From 9532a873f66b5102ec85f12e286d771db0a1bc4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20de=20la=20Pe=C3=B1a?= Date: Mon, 22 Apr 2024 13:43:31 +0200 Subject: [PATCH 1/2] feat: support passing io.Reader when creating a compose instance --- docs/features/docker_compose.md | 19 ++++++++++- modules/compose/compose.go | 40 ++++++++++++++--------- modules/compose/compose_api.go | 50 +++++++++++++++++++++++++++++ modules/compose/compose_api_test.go | 41 +++++++++++++++++++++++ 4 files changed, 134 insertions(+), 16 deletions(-) diff --git a/docs/features/docker_compose.md b/docs/features/docker_compose.md index 352f2c84eb..b7d9c22d65 100644 --- a/docs/features/docker_compose.md +++ b/docs/features/docker_compose.md @@ -53,7 +53,24 @@ func TestSomething(t *testing.T) { } ``` -Use the advanced `NewDockerComposeWith(...)` constructor allowing you to specify an identifier: +Use the advanced `NewDockerComposeWith(...)` constructor allowing you to customise the compose execution with options: + +- `StackIdentifier`: the identifier for the stack, which is used to name the network and containers. If not passed, a random identifier is generated. +- `WithStackFiles`: specify the Docker Compose stack files to use, as a variadic argument of string paths where the stack files are located. +- `WithStackReaders`: specify the Docker Compose stack files to use, as a variadic argument of `io.Reader` instances. It will create a temporary file in the temp dir of the given O.S., that will be removed after the `Down` method is called. You can use both `WithComposeStackFiles` and `WithComposeStackReaders` at the same time. + +#### Compose Up options + +- `RemoveOrphans`: remove orphaned containers after the stack is stopped. +- `Wait`: will wait until the containers reached the running|healthy state. + +#### Compose Down options + +- `RemoveImages`: remove images after the stack is stopped. The `RemoveImagesAll` option will remove all images, while `RemoveImagesLocal` will remove only the images that don't have a tag. +- `RemoveOrphans`: remove orphaned containers after the stack is stopped. +- `RemoveVolumes`: remove volumes after the stack is stopped. + +#### Example ```go package example_test diff --git a/modules/compose/compose.go b/modules/compose/compose.go index 94e2021b90..90f3fd804d 100644 --- a/modules/compose/compose.go +++ b/modules/compose/compose.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "path/filepath" "runtime" "strings" @@ -27,9 +28,10 @@ const ( var ErrNoStackConfigured = errors.New("no stack files configured") type composeStackOptions struct { - Identifier string - Paths []string - Logger testcontainers.Logging + Identifier string + Paths []string + temporaryPaths map[string]bool + Logger testcontainers.Logging } type ComposeStackOption interface { @@ -95,14 +97,21 @@ func WithStackFiles(filePaths ...string) ComposeStackOption { return ComposeStackFiles(filePaths) } +// WithStackReaders supports reading the compose file/s from a reader. +// This function will panic if it's no possible to read the content from the reader. +func WithStackReaders(readers ...io.Reader) ComposeStackOption { + return ComposeStackReaders(readers) +} + func NewDockerCompose(filePaths ...string) (*dockerCompose, error) { return NewDockerComposeWith(WithStackFiles(filePaths...)) } func NewDockerComposeWith(opts ...ComposeStackOption) (*dockerCompose, error) { composeOptions := composeStackOptions{ - Identifier: uuid.New().String(), - Logger: testcontainers.Logger, + Identifier: uuid.New().String(), + temporaryPaths: make(map[string]bool), + Logger: testcontainers.Logger, } for i := range opts { @@ -142,16 +151,17 @@ func NewDockerComposeWith(opts ...ComposeStackOption) (*dockerCompose, error) { } composeAPI := &dockerCompose{ - name: composeOptions.Identifier, - configs: composeOptions.Paths, - logger: composeOptions.Logger, - composeService: compose.NewComposeService(dockerCli), - dockerClient: dockerCli.Client(), - waitStrategies: make(map[string]wait.Strategy), - containers: make(map[string]*testcontainers.DockerContainer), - networks: make(map[string]*testcontainers.DockerNetwork), - sessionID: testcontainers.SessionID(), - reaper: composeReaper, + name: composeOptions.Identifier, + configs: composeOptions.Paths, + temporaryConfigs: composeOptions.temporaryPaths, + logger: composeOptions.Logger, + composeService: compose.NewComposeService(dockerCli), + dockerClient: dockerCli.Client(), + waitStrategies: make(map[string]wait.Strategy), + containers: make(map[string]*testcontainers.DockerContainer), + networks: make(map[string]*testcontainers.DockerNetwork), + sessionID: testcontainers.SessionID(), + reaper: composeReaper, } return composeAPI, nil diff --git a/modules/compose/compose_api.go b/modules/compose/compose_api.go index 62a8061e97..8f8ea5320b 100644 --- a/modules/compose/compose_api.go +++ b/modules/compose/compose_api.go @@ -3,9 +3,14 @@ package compose import ( "context" "fmt" + "io" + "os" + "path/filepath" "sort" + "strconv" "strings" "sync" + "time" "github.com/compose-spec/compose-go/v2/cli" "github.com/compose-spec/compose-go/v2/types" @@ -43,9 +48,12 @@ func RunServices(serviceNames ...string) StackUpOption { }) } +// Deprecated: will be removed in the next major release // IgnoreOrphans - Ignore legacy containers for services that are not defined in the project type IgnoreOrphans bool +// Deprecated: will be removed in the next major release +// //nolint:unused func (io IgnoreOrphans) applyToStackUp(co *api.CreateOptions, _ *api.StartOptions) { co.IgnoreOrphans = bool(io) @@ -87,6 +95,40 @@ func (ri RemoveImages) applyToStackDown(o *stackDownOptions) { } } +type ComposeStackReaders []io.Reader + +func (r ComposeStackReaders) applyToComposeStack(o *composeStackOptions) { + f := make([]string, len(r)) + baseName := "docker-compose-%d.yml" + for i, reader := range r { + tmp := os.TempDir() + tmp = filepath.Join(tmp, strconv.FormatInt(time.Now().UnixNano(), 10)) + err := os.MkdirAll(tmp, 0755) + if err != nil { + panic(err) + } + + name := fmt.Sprintf(baseName, i) + + bs, err := io.ReadAll(reader) + if err != nil { + panic(err) + } + + err = os.WriteFile(filepath.Join(tmp, name), bs, 0644) + if err != nil { + panic(err) + } + + f[i] = filepath.Join(tmp, name) + + // mark the file for removal as it was generated on the fly + o.temporaryPaths[f[i]] = true + } + + o.Paths = f +} + type ComposeStackFiles []string func (f ComposeStackFiles) applyToComposeStack(o *composeStackOptions) { @@ -121,6 +163,9 @@ type dockerCompose struct { // paths to stack files that will be considered when compiling the final compose project configs []string + // used to remove temporary files that were generated on the fly + temporaryConfigs map[string]bool + // used to set logger in DockerContainer logger testcontainers.Logging @@ -186,6 +231,11 @@ func (d *dockerCompose) Down(ctx context.Context, opts ...StackDownOption) error for i := range opts { opts[i].applyToStackDown(&options) } + defer func() { + for cfg := range d.temporaryConfigs { + _ = os.Remove(cfg) + } + }() return d.composeService.Down(ctx, d.name, options.DownOptions) } diff --git a/modules/compose/compose_api_test.go b/modules/compose/compose_api_test.go index 0cf9fcf4f1..d703934669 100644 --- a/modules/compose/compose_api_test.go +++ b/modules/compose/compose_api_test.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "hash/fnv" + "os" "path/filepath" + "strings" "testing" "time" @@ -429,6 +431,45 @@ func TestDockerComposeAPIComplex(t *testing.T) { assert.Contains(t, serviceNames, "api-mysql") } +func TestDockerComposeAPIWithStackReader(t *testing.T) { + identifier := testNameHash(t.Name()) + + composeContent := `version: '3.7' +services: + api-nginx: + image: docker.io/nginx:stable-alpine + environment: + bar: ${bar} + foo: ${foo} +` + + compose, err := NewDockerComposeWith(WithStackReaders(strings.NewReader(composeContent)), identifier) + require.NoError(t, err, "NewDockerCompose()") + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + err = compose. + WithEnv(map[string]string{ + "foo": "FOO", + "bar": "BAR", + }). + Up(ctx, Wait(true)) + require.NoError(t, err, "compose.Up()") + + serviceNames := compose.Services() + + assert.Len(t, serviceNames, 1) + assert.Contains(t, serviceNames, "api-nginx") + + require.NoError(t, compose.Down(context.Background(), RemoveOrphans(true), RemoveImagesLocal), "compose.Down()") + + // check files where removed + f, err := os.Stat(compose.configs[0]) + require.Error(t, err, "File should be removed") + require.True(t, os.IsNotExist(err), "File should be removed") + require.Nil(t, f, "File should be removed") +} func TestDockerComposeAPIWithEnvironment(t *testing.T) { identifier := testNameHash(t.Name()) From 326d9247d205016aeeb5c3b8fba82c488a212db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20de=20la=20Pe=C3=B1a?= Date: Mon, 22 Apr 2024 17:07:46 +0200 Subject: [PATCH 2/2] docs: change title --- docs/features/docker_compose.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/docker_compose.md b/docs/features/docker_compose.md index b7d9c22d65..bfb4f90b16 100644 --- a/docs/features/docker_compose.md +++ b/docs/features/docker_compose.md @@ -20,7 +20,7 @@ Because `compose` v2 is implemented in Go it's possible for _Testcontainers for 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 +### Usage Use the convenience `NewDockerCompose(...)` constructor which creates a random identifier and takes a variable number of stack files: