diff --git a/container.go b/container.go index 5ee0aac881..35be60fb81 100644 --- a/container.go +++ b/container.go @@ -77,7 +77,7 @@ type ImageBuildInfo interface { GetDockerfile() string // the relative path to the Dockerfile, including the file itself GetRepo() string // get repo label for image GetTag() string // get tag label for image - ShouldPrintBuildLog() bool // allow build log to be printed to stdout + BuildLogWriter() io.Writer // for output of build log, use io.Discard to disable the output ShouldBuildImage() bool // return true if the image needs to be built GetBuildArgs() map[string]*string // return the environment args used to build the from Dockerfile GetAuthConfigs() map[string]registry.AuthConfig // Deprecated. Testcontainers will detect registry credentials automatically. Return the auth configs to be able to pull from an authenticated docker registry @@ -92,7 +92,8 @@ type FromDockerfile struct { Repo string // the repo label for image, defaults to UUID Tag string // the tag label for image, defaults to UUID BuildArgs map[string]*string // enable user to pass build args to docker daemon - PrintBuildLog bool // enable user to print build log + PrintBuildLog bool // Deprecated: Use BuildLogWriter instead + BuildLogWriter io.Writer // for output of build log, defaults to io.Discard AuthConfigs map[string]registry.AuthConfig // Deprecated. Testcontainers will detect registry credentials automatically. Enable auth configs to be able to pull from an authenticated docker registry // KeepImage describes whether DockerContainer.Terminate should not delete the // container image. Useful for images that are built from a Dockerfile and take a @@ -410,8 +411,20 @@ func (c *ContainerRequest) ShouldKeepBuiltImage() bool { return c.FromDockerfile.KeepImage } -func (c *ContainerRequest) ShouldPrintBuildLog() bool { - return c.FromDockerfile.PrintBuildLog +// BuildLogWriter returns the io.Writer for output of log when building a Docker image from +// a Dockerfile. It returns the BuildLogWriter from the ContainerRequest, defaults to io.Discard. +// For backward compatibility, if BuildLogWriter is default and PrintBuildLog is true, +// the function returns os.Stderr. +func (c *ContainerRequest) BuildLogWriter() io.Writer { + if c.FromDockerfile.BuildLogWriter != nil { + return c.FromDockerfile.BuildLogWriter + } + if c.FromDockerfile.PrintBuildLog { + c.FromDockerfile.BuildLogWriter = os.Stderr + } else { + c.FromDockerfile.BuildLogWriter = io.Discard + } + return c.FromDockerfile.BuildLogWriter } // BuildOptions returns the image build options when building a Docker image from a Dockerfile. diff --git a/docker.go b/docker.go index 296fe6743c..01b3d3d4d2 100644 --- a/docker.go +++ b/docker.go @@ -1004,10 +1004,7 @@ func (p *DockerProvider) BuildImage(ctx context.Context, img ImageBuildInfo) (st } defer resp.Body.Close() - output := io.Discard - if img.ShouldPrintBuildLog() { - output = os.Stderr - } + output := img.BuildLogWriter() // Always process the output, even if it is not printed // to ensure that errors during the build process are @@ -1498,7 +1495,11 @@ func (p *DockerProvider) daemonHostLocked(ctx context.Context) (string, error) { p.hostCache = daemonURL.Hostname() case "unix", "npipe": if core.InAContainer() { - ip, err := p.GetGatewayIP(ctx) + defaultNetwork, err := p.ensureDefaultNetworkLocked(ctx) + if err != nil { + return "", fmt.Errorf("ensure default network: %w", err) + } + ip, err := p.getGatewayIP(ctx, defaultNetwork) if err != nil { ip, err = core.DefaultGatewayIP() if err != nil { @@ -1598,7 +1599,10 @@ func (p *DockerProvider) GetGatewayIP(ctx context.Context) (string, error) { if err != nil { return "", fmt.Errorf("ensure default network: %w", err) } + return p.getGatewayIP(ctx, defaultNetwork) +} +func (p *DockerProvider) getGatewayIP(ctx context.Context, defaultNetwork string) (string, error) { nw, err := p.GetNetwork(ctx, NetworkRequest{Name: defaultNetwork}) if err != nil { return "", err @@ -1624,7 +1628,10 @@ func (p *DockerProvider) GetGatewayIP(ctx context.Context) (string, error) { func (p *DockerProvider) ensureDefaultNetwork(ctx context.Context) (string, error) { p.mtx.Lock() defer p.mtx.Unlock() + return p.ensureDefaultNetworkLocked(ctx) +} +func (p *DockerProvider) ensureDefaultNetworkLocked(ctx context.Context) (string, error) { if p.defaultNetwork != "" { // Already set. return p.defaultNetwork, nil diff --git a/docker_test.go b/docker_test.go index 3fa686632f..8fcd60c558 100644 --- a/docker_test.go +++ b/docker_test.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/internal/core" "github.com/testcontainers/testcontainers-go/wait" ) @@ -35,6 +36,7 @@ const ( nginxAlpineImage = "nginx:alpine" nginxDefaultPort = "80/tcp" nginxHighPort = "8080/tcp" + golangImage = "golang" daemonMaxVersion = "1.41" ) @@ -705,6 +707,37 @@ func Test_BuildContainerFromDockerfileWithBuildLog(t *testing.T) { assert.Regexpf(t, `^Step\s*1/\d+\s*:\s*FROM alpine$`, temp[0], "Expected stdout first line to be %s. Got '%s'.", "Step 1/* : FROM alpine", temp[0]) } +func Test_BuildContainerFromDockerfileWithBuildLogWriter(t *testing.T) { + var buffer bytes.Buffer + + ctx := context.Background() + + // fromDockerfile { + req := ContainerRequest{ + FromDockerfile: FromDockerfile{ + Context: filepath.Join(".", "testdata"), + Dockerfile: "buildlog.Dockerfile", + BuildLogWriter: &buffer, + }, + } + // } + + genContainerReq := GenericContainerRequest{ + ProviderType: providerType, + ContainerRequest: req, + Started: true, + } + + c, err := GenericContainer(ctx, genContainerReq) + CleanupContainer(t, c) + require.NoError(t, err) + + out := buffer.String() + temp := strings.Split(out, "\n") + require.NotEmpty(t, temp) + require.Regexpf(t, `^Step\s*1/\d+\s*:\s*FROM alpine$`, temp[0], "Expected stdout first line to be %s. Got '%s'.", "Step 1/* : FROM alpine", temp[0]) +} + func TestContainerCreationWaitsForLogAndPortContextTimeout(t *testing.T) { ctx := context.Background() req := ContainerRequest{ @@ -2125,3 +2158,39 @@ func TestCustomPrefixTrailingSlashIsProperlyRemovedIfPresent(t *testing.T) { dockerContainer := c.(*DockerContainer) require.Equal(t, fmt.Sprintf("%s%s", hubPrefixWithTrailingSlash, dockerImage), dockerContainer.Image) } + +// TODO: remove this skip check when context rework is merged alongside [core.DockerEnvFile] removal. +func Test_Provider_DaemonHost_Issue2897(t *testing.T) { + ctx := context.Background() + provider, err := NewDockerProvider() + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, provider.Close()) + }) + + orig := core.DockerEnvFile + core.DockerEnvFile = filepath.Join(t.TempDir(), ".dockerenv") + t.Cleanup(func() { + core.DockerEnvFile = orig + }) + + f, err := os.Create(core.DockerEnvFile) + require.NoError(t, err) + require.NoError(t, f.Close()) + t.Cleanup(func() { + require.NoError(t, os.Remove(f.Name())) + }) + + errCh := make(chan error, 1) + go func() { + _, err := provider.DaemonHost(ctx) + errCh <- err + }() + + select { + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for DaemonHost") + case err := <-errCh: + require.NoError(t, err) + } +} diff --git a/docs/features/wait/log.md b/docs/features/wait/log.md index f1d40ff360..8466d68511 100644 --- a/docs/features/wait/log.md +++ b/docs/features/wait/log.md @@ -3,10 +3,11 @@ The Log wait strategy will check if a string occurs in the container logs for a desired number of times, and allows to set the following conditions: - the string to be waited for in the container log. -- the number of occurrences of the string to wait for, default is `1`. +- the number of occurrences of the string to wait for, default is `1` (ignored for Submatch). - look for the string using a regular expression, default is `false`. - the startup timeout to be used in seconds, default is 60 seconds. - the poll interval to be used in milliseconds, default is 100 milliseconds. +- the regular expression submatch callback, default nil (occurrences is ignored). ```golang req := ContainerRequest{ @@ -33,3 +34,40 @@ req := ContainerRequest{ WaitingFor: wait.ForLog(`.*MySQL Community Server`).AsRegexp(), } ``` + +Using regular expression with submatch: + +```golang +var host, port string +req := ContainerRequest{ + Image: "ollama/ollama:0.1.25", + ExposedPorts: []string{"11434/tcp"}, + WaitingFor: wait.ForLog(`Listening on (.*:\d+) \(version\s(.*)\)`).Submatch(func(pattern string, submatches [][][]byte) error { + var err error + for _, matches := range submatches { + if len(matches) != 3 { + err = fmt.Errorf("`%s` matched %d times, expected %d", pattern, len(matches), 3) + continue + } + host, port, err = net.SplitHostPort(string(matches[1])) + if err != nil { + return wait.NewPermanentError(fmt.Errorf("split host port: %w", err)) + } + + // Host and port successfully extracted from log. + return nil + } + + if err != nil { + // Return the last error encountered. + return err + } + + return fmt.Errorf("address and version not found: `%s` no matches", pattern) + }), +} +``` + +If the return from a Submatch callback function is a `wait.PermanentError` the +wait will stop and the error will be returned. Use `wait.NewPermanentError(err error)` +to achieve this. diff --git a/internal/core/docker_host.go b/internal/core/docker_host.go index 3088a3742b..765626da57 100644 --- a/internal/core/docker_host.go +++ b/internal/core/docker_host.go @@ -309,10 +309,15 @@ func testcontainersHostFromProperties(ctx context.Context) (string, error) { return "", ErrTestcontainersHostNotSetInProperties } +// DockerEnvFile is the file that is created when running inside a container. +// It's a variable to allow testing. +// TODO: Remove this once context rework is done, which eliminates need for the default network creation. +var DockerEnvFile = "/.dockerenv" + // InAContainer returns true if the code is running inside a container // See https://github.com/docker/docker/blob/a9fa38b1edf30b23cae3eade0be48b3d4b1de14b/daemon/initlayer/setup_unix.go#L25 func InAContainer() bool { - return inAContainer("/.dockerenv") + return inAContainer(DockerEnvFile) } func inAContainer(path string) bool { diff --git a/network/network_test.go b/network/network_test.go index bbe5d45c7c..8b83056f43 100644 --- a/network/network_test.go +++ b/network/network_test.go @@ -440,3 +440,8 @@ func TestWithNewNetworkContextTimeout(t *testing.T) { require.Empty(t, req.Networks) require.Empty(t, req.NetworkAliases) } + +func TestCleanupWithNil(t *testing.T) { + var network *testcontainers.DockerNetwork + testcontainers.CleanupNetwork(t, network) +} diff --git a/testing.go b/testing.go index 35ce4f0a39..8502f018d9 100644 --- a/testing.go +++ b/testing.go @@ -83,7 +83,9 @@ func CleanupNetwork(tb testing.TB, network Network) { tb.Helper() tb.Cleanup(func() { - noErrorOrIgnored(tb, network.Remove(context.Background())) + if !isNil(network) { + noErrorOrIgnored(tb, network.Remove(context.Background())) + } }) }