diff --git a/docker.go b/docker.go index a559b8d7ea..33a73cae73 100644 --- a/docker.go +++ b/docker.go @@ -14,6 +14,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strings" "sync" "time" @@ -49,6 +50,8 @@ const ( logStoppedForOutOfSyncMessage = "Stopping log consumer: Headers out of sync" ) +var createContainerFailDueToNameConflictRegex = regexp.MustCompile("Conflict. The container name .* is already in use by container .*") + // DockerContainer represents a container started using Docker type DockerContainer struct { // Container ID from Docker @@ -1153,13 +1156,40 @@ func (p *DockerProvider) findContainerByName(ctx context.Context, name string) ( return nil, nil } +func (p *DockerProvider) waitContainerCreation(ctx context.Context, name string) (*types.Container, error) { + var container *types.Container + return container, backoff.Retry(func() error { + c, err := p.findContainerByName(ctx, name) + if err != nil { + return err + } + + if c == nil { + return fmt.Errorf("container %s not found", name) + } + + container = c + return nil + }, backoff.WithContext(backoff.NewExponentialBackOff(), ctx)) +} + func (p *DockerProvider) ReuseOrCreateContainer(ctx context.Context, req ContainerRequest) (Container, error) { c, err := p.findContainerByName(ctx, req.Name) if err != nil { return nil, err } if c == nil { - return p.CreateContainer(ctx, req) + createdContainer, err := p.CreateContainer(ctx, req) + if err == nil { + return createdContainer, nil + } + if !createContainerFailDueToNameConflictRegex.MatchString(err.Error()) { + return nil, err + } + c, err = p.waitContainerCreation(ctx, req.Name) + if err != nil { + return nil, err + } } sessionID := core.SessionID() @@ -1178,6 +1208,13 @@ func (p *DockerProvider) ReuseOrCreateContainer(ctx context.Context, req Contain } } + // default hooks include logger hook and pre-create hook + defaultHooks := []ContainerLifecycleHooks{ + DefaultLoggingHook(p.Logger), + defaultReadinessHook(), + defaultLogConsumersHook(req.LogConsumerCfg), + } + dc := &DockerContainer{ ID: c.ID, WaitingFor: req.WaitingFor, @@ -1187,7 +1224,19 @@ func (p *DockerProvider) ReuseOrCreateContainer(ctx context.Context, req Contain terminationSignal: termSignal, stopLogProductionCh: nil, logger: p.Logger, - isRunning: c.State == "running", + lifecycleHooks: []ContainerLifecycleHooks{combineContainerHooks(defaultHooks, req.LifecycleHooks)}, + } + + err = dc.startedHook(ctx) + if err != nil { + return nil, err + } + + dc.isRunning = true + + err = dc.readiedHook(ctx) + if err != nil { + return nil, err } return dc, nil diff --git a/generic_test.go b/generic_test.go index cb38e29faf..72688876ec 100644 --- a/generic_test.go +++ b/generic_test.go @@ -3,6 +3,11 @@ package testcontainers import ( "context" "errors" + "net/http" + "os" + "os/exec" + "strings" + "sync" "testing" "time" @@ -117,3 +122,70 @@ func TestGenericContainerShouldReturnRefOnError(t *testing.T) { require.NotNil(t, c) terminateContainerOnEnd(t, context.Background(), c) } + +func TestGenericReusableContainerInSubprocess(t *testing.T) { + wg := sync.WaitGroup{} + wg.Add(10) + for i := 0; i < 10; i++ { + go func() { + defer wg.Done() + + // create containers in subprocesses, as "go test ./..." does. + output := createReuseContainerInSubprocess(t) + + // check is reuse container with WaitingFor work correctly. + require.True(t, strings.Contains(output, "🚧 Waiting for container id")) + require.True(t, strings.Contains(output, "🔔 Container is ready")) + }() + } + + wg.Wait() +} + +func createReuseContainerInSubprocess(t *testing.T) string { + cmd := exec.Command(os.Args[0], "-test.run=TestHelperContainerStarterProcess") + cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"} + + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + + return string(output) +} + +// TestHelperContainerStarterProcess is a helper function +// to start a container in a subprocess. It's not a real test. +func TestHelperContainerStarterProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + t.Skip("Skipping helper test function. It's not a real test") + } + + ctx := context.Background() + + nginxC, err := GenericContainer(ctx, GenericContainerRequest{ + ProviderType: providerType, + ContainerRequest: ContainerRequest{ + Image: nginxDelayedImage, + ExposedPorts: []string{nginxDefaultPort}, + WaitingFor: wait.ForListeningPort(nginxDefaultPort), // default startupTimeout is 60s + Name: reusableContainerName, + }, + Started: true, + Reuse: true, + }) + require.NoError(t, err) + require.True(t, nginxC.IsRunning()) + + origin, err := nginxC.PortEndpoint(ctx, nginxDefaultPort, "http") + require.NoError(t, err) + + // check is reuse container with WaitingFor work correctly. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, origin, nil) + require.NoError(t, err) + req.Close = true + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/reaper.go b/reaper.go index 724d2635a1..3dff6a7c9d 100644 --- a/reaper.go +++ b/reaper.go @@ -6,7 +6,6 @@ import ( "fmt" "math/rand" "net" - "regexp" "strings" "sync" "time" @@ -186,8 +185,6 @@ func reuseOrCreateReaper(ctx context.Context, sessionID string, provider ReaperP return reaperInstance, nil } -var createContainerFailDueToNameConflictRegex = regexp.MustCompile("Conflict. The container name .* is already in use by container .*") - // reuseReaperContainer constructs a Reaper from an already running reaper // DockerContainer. func reuseReaperContainer(ctx context.Context, sessionID string, provider ReaperProvider, reaperContainer *DockerContainer) (*Reaper, error) {