diff --git a/docker.go b/docker.go index e89b9f4649..eacf9b755c 100644 --- a/docker.go +++ b/docker.go @@ -157,11 +157,7 @@ func (c *DockerContainer) PortEndpoint(ctx context.Context, port nat.Port, proto // Warning: this is based on your Docker host setting. Will fail if using an SSH tunnel // You can use the "TESTCONTAINERS_HOST_OVERRIDE" env variable to set this yourself func (c *DockerContainer) Host(ctx context.Context) (string, error) { - host, err := c.provider.DaemonHost(ctx) - if err != nil { - return "", err - } - return host, nil + return GetDockerHostIP(), nil } // Inspect gets the raw container info @@ -186,7 +182,12 @@ func (c *DockerContainer) MappedPort(ctx context.Context, port nat.Port) (nat.Po ports := inspect.NetworkSettings.Ports - for k, p := range ports { + boundPorts, err := core.BoundPortsFromBindings(ports) + if err != nil { + return "", err + } + + for k, p := range boundPorts { if k.Port() != port.Port() { continue } @@ -196,7 +197,7 @@ func (c *DockerContainer) MappedPort(ctx context.Context, port nat.Port) (nat.Po if len(p) == 0 { continue } - return nat.NewPort(k.Proto(), p[0].HostPort) + return nat.NewPort(k.Proto(), p.Port()) } return "", errors.New("port not found") diff --git a/docker_client.go b/docker_client.go index c8e8e825b0..1a12dad9cd 100644 --- a/docker_client.go +++ b/docker_client.go @@ -59,7 +59,7 @@ func (c *DockerClient) Info(ctx context.Context) (system.Info, error) { Operating System: %v Total Memory: %v MB%s Testcontainers for Go Version: v%s - Resolved Docker Host: %s + Resolved Docker Host: %s - %s Resolved Docker Socket Path: %s Test SessionID: %s Test ProcessID: %s @@ -73,13 +73,16 @@ func (c *DockerClient) Info(ctx context.Context) (system.Info, error) { } } + dockerHost := core.ExtractDockerHost(ctx) + Logger.Printf(infoMessage, packagePath, dockerInfo.ServerVersion, c.Client.ClientVersion(), dockerInfo.OperatingSystem, dockerInfo.MemTotal/1024/1024, infoLabels, internal.Version, - core.ExtractDockerHost(ctx), + dockerHost, + core.GetDockerHostIPs(), core.ExtractDockerSocket(ctx), core.SessionID(), core.ProcessID(), diff --git a/internal/core/bound_ports.go b/internal/core/bound_ports.go new file mode 100644 index 0000000000..1990d1e5f2 --- /dev/null +++ b/internal/core/bound_ports.go @@ -0,0 +1,52 @@ +package core + +import ( + "fmt" + + "github.com/docker/go-connections/nat" +) + +type BoundPorts map[nat.Port]nat.Port + +// BoundPortsFromBindings returns a map of container ports to host ports. +// They are resolved from the port bindings in the inspect response, +// using the host IP addresses of the Docker host. +// This will resolve the issue of the host port being bound to multiple IP addresses +// in the IPv4 and IPv6 case. +func BoundPortsFromBindings(portMap nat.PortMap) (BoundPorts, error) { + hostIPs := GetDockerHostIPs() + + boundPorts := make(BoundPorts) + + for containerPort, bindings := range portMap { + if len(bindings) == 0 { + continue + } + + hostPort, err := resolveHostPortBinding(hostIPs, bindings) + if err != nil { + return nil, fmt.Errorf("failed to resolve host port binding for port %s: %w", containerPort, err) + } + + boundPorts[containerPort] = hostPort + } + + return boundPorts, nil +} + +// resolveHostPortBinding resolves the host port binding for the host IPs. +// It will return the host port for the first matching IP family (IPv4 or IPv6). +func resolveHostPortBinding(hostIPs []HostIP, portBindings []nat.PortBinding) (nat.Port, error) { + for _, hp := range hostIPs { + family := hp.Family + + for _, portBinding := range portBindings { + hostIP := newHostIP(portBinding.HostIP) + if hostIP.Family == family { + return nat.Port(portBinding.HostPort), nil + } + } + } + + return "", fmt.Errorf("no host port found for host IPs %v", hostIPs) +} diff --git a/internal/core/bound_ports_test.go b/internal/core/bound_ports_test.go new file mode 100644 index 0000000000..b7fc041022 --- /dev/null +++ b/internal/core/bound_ports_test.go @@ -0,0 +1,91 @@ +package core + +import ( + "fmt" + "testing" + + "github.com/docker/go-connections/nat" +) + +func TestResolveHostPortBinding(t *testing.T) { + type testCase struct { + name string + expectedPort nat.Port + hostIPs []HostIP + bindings []nat.PortBinding + expectedErr error + } + + testCases := []testCase{ + { + name: "should return IPv6-mapped host port when preferred", + hostIPs: []HostIP{ + {Family: IPv6, Address: "::1"}, + {Family: IPv4, Address: "127.0.0.1"}, + }, + bindings: []nat.PortBinding{ + {HostIP: "0.0.0.0", HostPort: "50000"}, + {HostIP: "::", HostPort: "50001"}, + }, + expectedPort: nat.Port("50001"), + }, + { + name: "should return IPv4-mapped host port when preferred", + hostIPs: []HostIP{ + {Family: IPv4, Address: "127.0.0.1"}, + {Family: IPv6, Address: "::1"}, + }, + bindings: []nat.PortBinding{ + {HostIP: "0.0.0.0", HostPort: "50000"}, + {HostIP: "::", HostPort: "50001"}, + }, + expectedPort: nat.Port("50000"), + }, + { + name: "should return mapped host port when dual stack IP", + hostIPs: []HostIP{ + {Family: IPv4, Address: "127.0.0.1"}, + {Family: IPv6, Address: "::1"}, + }, + bindings: []nat.PortBinding{ + {HostIP: "", HostPort: "50000"}, + }, + expectedPort: nat.Port("50000"), + }, + { + name: "should throw when no host port available for host IP family", + hostIPs: []HostIP{ + {Family: IPv6, Address: "::1"}, + }, + bindings: []nat.PortBinding{ + {HostIP: "0.0.0.0", HostPort: "50000"}, + }, + expectedPort: nat.Port(""), // that's the zero value returned by ResolveHostPortBinding + expectedErr: fmt.Errorf("no host port found for host IPs [%s (IPv6)]", "::1"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resolvedPort, err := resolveHostPortBinding(tc.hostIPs, tc.bindings) + + switch { + case err == nil && tc.expectedErr == nil: + break + case err == nil && tc.expectedErr != nil: + t.Errorf("did not receive expected error: %s", tc.expectedErr.Error()) + return + case err != nil && tc.expectedErr == nil: + t.Errorf("unexpected error: %v", err) + return + case err.Error() != tc.expectedErr.Error(): + t.Errorf("errors mismatch: %s != %s", err.Error(), tc.expectedErr.Error()) + return + } + + if resolvedPort != tc.expectedPort { + t.Errorf("resolved port mismatch: got %s, expected %s", resolvedPort, tc.expectedPort) + } + }) + } +} diff --git a/internal/core/docker_host_ips.go b/internal/core/docker_host_ips.go new file mode 100644 index 0000000000..cfd299ce35 --- /dev/null +++ b/internal/core/docker_host_ips.go @@ -0,0 +1,79 @@ +package core + +import ( + "context" + "fmt" + "net" + "sync" +) + +type IPFamily string + +const ( + IPv4 IPFamily = "IPv4" + IPv6 IPFamily = "IPv6" +) + +var ( + hostIPs []HostIP + hostIPsOnce sync.Once +) + +type HostIP struct { + Address string + Family IPFamily +} + +func (h HostIP) String() string { + return fmt.Sprintf("%s (%s)", h.Address, h.Family) +} + +func newHostIP(host string) HostIP { + var hip HostIP + + ip := net.ParseIP(host) + if ip == nil { + host = "127.0.0.1" + ip = net.ParseIP(host) + } + + hip.Address = host + + if ip.To4() != nil { + hip.Family = IPv4 + } else if ip.To16() != nil { + hip.Family = IPv6 + } + + return hip +} + +// GetDockerHostIPs returns the IP addresses of the Docker host. +// The function is protected by a sync.Once to avoid unnecessary calculations. +func GetDockerHostIPs() []HostIP { + hostIPsOnce.Do(func() { + dockerHost := ExtractDockerHost(context.Background()) + hostIPs = getDockerHostIPs(dockerHost) + }) + + return hostIPs +} + +// getDockerHostIPs returns the IP addresses of the Docker host. +// The function is helpful for testing purposes, +// as it's not protected by the sync.Once. +func getDockerHostIPs(host string) []HostIP { + hip := newHostIP(host) + + ips, err := net.LookupIP(hip.Address) + if err != nil { + return []HostIP{hip} + } + + hips := []HostIP{} + for _, ip := range ips { + hips = append(hips, newHostIP(ip.String())) + } + + return hips +} diff --git a/internal/core/docker_host_ips_test.go b/internal/core/docker_host_ips_test.go new file mode 100644 index 0000000000..3142f913f8 --- /dev/null +++ b/internal/core/docker_host_ips_test.go @@ -0,0 +1,54 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetDockerHostIPs(t *testing.T) { + type args struct { + host string + } + tests := []struct { + name string + args args + hostIps []HostIP + }{ + { + name: "should return a list of resolved host IPs when host is not an IP", + args: args{ + host: "localhost", + }, + hostIps: []HostIP{{Address: "127.0.0.1", Family: IPv4}}, + }, + { + name: "should return host IP and v4 family when host is an IPv4 IP", + args: args{ + host: "127.0.0.1", + }, + hostIps: []HostIP{{Address: "127.0.0.1", Family: IPv4}}, + }, + { + name: "should return host IP and v4 family when host is an IPv4 IP with tcp schema", + args: args{ + host: "tcp://127.0.0.1:64692", + }, + hostIps: []HostIP{{Address: "127.0.0.1", Family: IPv4}}, + }, + { + name: "should return host IP and v6 family when host is an IPv6 IP", + args: args{ + host: "::1", + }, + hostIps: []HostIP{{Address: "::1", Family: IPv6}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hips := getDockerHostIPs(tt.args.host) + assert.Equal(t, tt.hostIps, hips) + }) + } +} diff --git a/modules/cockroachdb/cockroachdb_test.go b/modules/cockroachdb/cockroachdb_test.go index 1f5a6df0ad..ab2a9442f9 100644 --- a/modules/cockroachdb/cockroachdb_test.go +++ b/modules/cockroachdb/cockroachdb_test.go @@ -19,13 +19,13 @@ import ( func TestCockroach_Insecure(t *testing.T) { suite.Run(t, &AuthNSuite{ - url: "postgres://root@localhost:xxxxx/defaultdb?sslmode=disable", + url: "postgres://root@" + testcontainers.GetDockerHostIP() + ":xxxxx/defaultdb?sslmode=disable", }) } func TestCockroach_NotRoot(t *testing.T) { suite.Run(t, &AuthNSuite{ - url: "postgres://test@localhost:xxxxx/defaultdb?sslmode=disable", + url: "postgres://test@" + testcontainers.GetDockerHostIP() + ":xxxxx/defaultdb?sslmode=disable", opts: []testcontainers.ContainerCustomizer{ cockroachdb.WithUser("test"), }, @@ -34,7 +34,7 @@ func TestCockroach_NotRoot(t *testing.T) { func TestCockroach_Password(t *testing.T) { suite.Run(t, &AuthNSuite{ - url: "postgres://foo:bar@localhost:xxxxx/defaultdb?sslmode=disable", + url: "postgres://foo:bar@" + testcontainers.GetDockerHostIP() + ":xxxxx/defaultdb?sslmode=disable", opts: []testcontainers.ContainerCustomizer{ cockroachdb.WithUser("foo"), cockroachdb.WithPassword("bar"), @@ -47,7 +47,7 @@ func TestCockroach_TLS(t *testing.T) { require.NoError(t, err) suite.Run(t, &AuthNSuite{ - url: "postgres://root@localhost:xxxxx/defaultdb?sslmode=verify-full", + url: "postgres://root@" + testcontainers.GetDockerHostIP() + ":xxxxx/defaultdb?sslmode=verify-full", opts: []testcontainers.ContainerCustomizer{ cockroachdb.WithTLS(tlsCfg), }, diff --git a/modules/cockroachdb/examples_test.go b/modules/cockroachdb/examples_test.go index b427846d4b..5c0e2e7419 100644 --- a/modules/cockroachdb/examples_test.go +++ b/modules/cockroachdb/examples_test.go @@ -45,5 +45,5 @@ func ExampleRun() { // Output: // true - // postgres://root@localhost:xxx/defaultdb?sslmode=disable + // postgres://root@127.0.0.1:xxx/defaultdb?sslmode=disable } diff --git a/modules/postgres/postgres_test.go b/modules/postgres/postgres_test.go index adef0defe3..74600d990c 100644 --- a/modules/postgres/postgres_test.go +++ b/modules/postgres/postgres_test.go @@ -92,7 +92,7 @@ func TestPostgres(t *testing.T) { // Ensure connection string is using generic format id, err := container.MappedPort(ctx, "5432/tcp") require.NoError(t, err) - assert.Equal(t, fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable&application_name=test", user, password, "localhost", id.Port(), dbname), connStr) + assert.Equal(t, fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable&application_name=test", user, password, testcontainers.GetDockerHostIP(), id.Port(), dbname), connStr) // perform assertions db, err := sql.Open("postgres", connStr) diff --git a/modules/registry/registry.go b/modules/registry/registry.go index 22e1aa1532..18af9d78a6 100644 --- a/modules/registry/registry.go +++ b/modules/registry/registry.go @@ -109,7 +109,6 @@ func (c *RegistryContainer) ImageExists(ctx context.Context, imageRef string) er WithMethod(http.MethodHead). WithBasicAuth(imageAuth.Username, imageAuth.Password). WithHeaders(map[string]string{"Accept": "application/vnd.docker.distribution.manifest.v2+json"}). - WithForcedIPv4LocalHost(). WithStatusCodeMatcher(func(statusCode int) bool { return statusCode == http.StatusOK }). diff --git a/testcontainers.go b/testcontainers.go index 5b52e09a22..d4efaae02c 100644 --- a/testcontainers.go +++ b/testcontainers.go @@ -23,6 +23,12 @@ func ExtractDockerSocket() string { return core.ExtractDockerSocket(context.Background()) } +// GetDockerHostIPs returns the first IP address of the Docker host, resolving the issue +// of the host port being bound to multiple IP addresses in the IPv4 and IPv6 case. +func GetDockerHostIP() string { + return core.GetDockerHostIPs()[0].Address +} + // SessionID returns a unique session ID for the current test session. Because each Go package // will be run in a separate process, we need a way to identify the current test session. // By test session, we mean: diff --git a/wait/host_port.go b/wait/host_port.go index 9d47a0b39d..b0b406e683 100644 --- a/wait/host_port.go +++ b/wait/host_port.go @@ -11,6 +11,8 @@ import ( "time" "github.com/docker/go-connections/nat" + + "github.com/testcontainers/testcontainers-go/internal/core" ) // Implement interface @@ -94,9 +96,16 @@ func (hp *HostPortStrategy) WaitUntilReady(ctx context.Context, target StrategyT return err } - for port := range inspect.NetworkSettings.Ports { - if internalPort == "" || port.Int() < internalPort.Int() { - internalPort = port + boundPorts, err := core.BoundPortsFromBindings(inspect.NetworkSettings.Ports) + if err != nil { + return err + } + + // no need to check for empty boundPorts, BoundPortsFromBindings will return an empty map if there are no bindings + for containerPort := range boundPorts { + if internalPort == "" || containerPort.Int() < internalPort.Int() { + internalPort = containerPort + break } } } diff --git a/wait/http.go b/wait/http.go index 11452ecbf0..6dc4216a78 100644 --- a/wait/http.go +++ b/wait/http.go @@ -11,10 +11,11 @@ import ( "net/http" "net/url" "strconv" - "strings" "time" "github.com/docker/go-connections/nat" + + "github.com/testcontainers/testcontainers-go/internal/core" ) // Implement interface @@ -41,7 +42,7 @@ type HTTPStrategy struct { ResponseHeadersMatcher func(headers http.Header) bool PollInterval time.Duration UserInfo *url.Userinfo - ForceIPv4LocalHost bool + ForceIPv4LocalHost bool // Deprecated: it will be removed in a future release } // NewHTTPStrategy constructs a HTTP strategy waiting on port 80 and status code 200 @@ -137,6 +138,7 @@ func (ws *HTTPStrategy) WithPollInterval(pollInterval time.Duration) *HTTPStrate return ws } +// Deprecated: will be removed in a future release. // WithForcedIPv4LocalHost forces usage of localhost to be ipv4 127.0.0.1 // to avoid ipv6 docker bugs https://github.com/moby/moby/issues/42442 https://github.com/moby/moby/issues/42375 func (ws *HTTPStrategy) WithForcedIPv4LocalHost() *HTTPStrategy { @@ -168,10 +170,6 @@ func (ws *HTTPStrategy) WaitUntilReady(ctx context.Context, target StrategyTarge if err != nil { return err } - // to avoid ipv6 docker bugs https://github.com/moby/moby/issues/42442 https://github.com/moby/moby/issues/42375 - if ws.ForceIPv4LocalHost { - ipAddress = strings.Replace(ipAddress, "localhost", "127.0.0.1", 1) - } var mappedPort nat.Port if ws.Port == "" { @@ -193,22 +191,27 @@ func (ws *HTTPStrategy) WaitUntilReady(ctx context.Context, target StrategyTarge return err } + boundPorts, err := core.BoundPortsFromBindings(inspect.NetworkSettings.Ports) + if err != nil { + return err + } + // Find the lowest numbered exposed tcp port. var lowestPort nat.Port var hostPort string - for port, bindings := range inspect.NetworkSettings.Ports { + for port, bindings := range boundPorts { if len(bindings) == 0 || port.Proto() != "tcp" { continue } if lowestPort == "" || port.Int() < lowestPort.Int() { lowestPort = port - hostPort = bindings[0].HostPort + hostPort = inspect.NetworkSettings.Ports[port][0].HostPort } } if lowestPort == "" { - return errors.New("No exposed tcp ports or mapped ports - cannot wait for status") + return errors.New("no exposed tcp ports or mapped ports - cannot wait for status") } mappedPort, _ = nat.NewPort(lowestPort.Proto(), hostPort) @@ -229,7 +232,7 @@ func (ws *HTTPStrategy) WaitUntilReady(ctx context.Context, target StrategyTarge } if mappedPort.Proto() != "tcp" { - return errors.New("Cannot use HTTP client on non-TCP ports") + return errors.New("cannot use HTTP client on non-TCP ports") } } diff --git a/wait/http_test.go b/wait/http_test.go index 54610f4686..d055dfbdc6 100644 --- a/wait/http_test.go +++ b/wait/http_test.go @@ -155,7 +155,7 @@ func ExampleHTTPStrategy_WithForcedIPv4LocalHost() { req := testcontainers.ContainerRequest{ Image: "nginx:latest", ExposedPorts: []string{"8080/tcp", "80/tcp"}, - WaitingFor: wait.ForHTTP("/").WithForcedIPv4LocalHost(), + WaitingFor: wait.ForHTTP("/").WithPort("80/tcp"), } c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ @@ -818,7 +818,7 @@ func TestHttpStrategyFailsWhileGettingPortDueToNoExposedPorts(t *testing.T) { t.Fatal("no error") } - expected := "No exposed tcp ports or mapped ports - cannot wait for status" + expected := "no exposed tcp ports or mapped ports - cannot wait for status" if err.Error() != expected { t.Fatalf("expected %q, got %q", expected, err.Error()) } @@ -872,7 +872,7 @@ func TestHttpStrategyFailsWhileGettingPortDueToOnlyUDPPorts(t *testing.T) { t.Fatal("no error") } - expected := "No exposed tcp ports or mapped ports - cannot wait for status" + expected := "no exposed tcp ports or mapped ports - cannot wait for status" if err.Error() != expected { t.Fatalf("expected %q, got %q", expected, err.Error()) } @@ -921,7 +921,7 @@ func TestHttpStrategyFailsWhileGettingPortDueToExposedPortNoBindings(t *testing. t.Fatal("no error") } - expected := "No exposed tcp ports or mapped ports - cannot wait for status" + expected := "no exposed tcp ports or mapped ports - cannot wait for status" if err.Error() != expected { t.Fatalf("expected %q, got %q", expected, err.Error()) }