Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions container.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ type ContainerRequest struct {
Entrypoint []string
Env map[string]string
ExposedPorts []string // allow specifying protocol info
HostIP string // bind the exposed ports to this IP address on the host. Defaults to all interfaces (0.0.0.0)
Cmd []string
Labels map[string]string
Mounts ContainerMounts
Expand Down
17 changes: 17 additions & 0 deletions docs/features/common_functional_options.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,23 @@ If you need an advanced configuration for the container, modifying the container

This option can be called multiple times; each modifier is chained and applied in order, with the last call applied last.

##### WithHostIP

- Since <a href="https://github.com/testcontainers/testcontainers-go"><span class="tc-version">:material-tag: main</span></a>

By default, the exposed ports of a container are bound to all interfaces of the host (`0.0.0.0`). If you need to restrict the port bindings to a specific IP address, for example to make the container only reachable from `localhost`, you can use the `testcontainers.WithHostIP` option:

```golang
ctr, err = mymodule.Run(ctx, "docker.io/myservice:1.2.3",
testcontainers.WithHostIP("127.0.0.1"),
)
```

It is also available as the `HostIP` field of the `ContainerRequest`.

!!!note
The host IP is applied to all the exposed ports of the container. It overrides the per-port `HostIP` set with `WithHostConfigModifier`.

##### WithEndpointSettingsModifier

- Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.20.0"><span class="tc-version">:material-tag: v0.20.0</span></a>
Expand Down
1 change: 1 addition & 0 deletions docs/features/common_functional_options_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ The following options are exposed by the `testcontainers` package.
- [`WithHostPortAccess`](/features/creating_container/#withhostportaccess) Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.31.0"><span class="tc-version">:material-tag: v0.31.0</span></a>
- [`WithConfigModifier`](/features/creating_container/#withconfigmodifier) Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.20.0"><span class="tc-version">:material-tag: v0.20.0</span></a> · Chainable since <a href="https://github.com/testcontainers/testcontainers-go"><span class="tc-version">:material-tag: main</span></a>
- [`WithHostConfigModifier`](/features/creating_container/#withhostconfigmodifier) Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.20.0"><span class="tc-version">:material-tag: v0.20.0</span></a> · Chainable since <a href="https://github.com/testcontainers/testcontainers-go"><span class="tc-version">:material-tag: main</span></a>
- [`WithHostIP`](/features/creating_container/#withhostip) Since <a href="https://github.com/testcontainers/testcontainers-go"><span class="tc-version">:material-tag: main</span></a>
- [`WithEndpointSettingsModifier`](/features/creating_container/#withendpointsettingsmodifier) Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.20.0"><span class="tc-version">:material-tag: v0.20.0</span></a> · Chainable since <a href="https://github.com/testcontainers/testcontainers-go"><span class="tc-version">:material-tag: main</span></a>
- [`CustomizeRequest`](/features/creating_container/#customizerequest) Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.20.0"><span class="tc-version">:material-tag: v0.20.0</span></a>
- [`WithName`](/features/creating_container/#withname) Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.38.0"><span class="tc-version">:material-tag: v0.38.0</span></a>
Expand Down
157 changes: 157 additions & 0 deletions host_ip_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package testcontainers

import (
"context"
"net/netip"
"testing"

"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestPreCreateContainerHookAppliesHostIP tests that the pre-create hook binds
// all exposed ports to the requested host IP, both for the default ephemeral
// bindings and for bindings set by a HostConfigModifier.
func TestPreCreateContainerHookAppliesHostIP(t *testing.T) {
ctx := context.Background()
p := &DockerProvider{}

t.Run("applies to default ephemeral bindings", func(t *testing.T) {
dockerInput := &container.Config{}
hostConfig := &container.HostConfig{}
networkingConfig := &network.NetworkingConfig{}

req := ContainerRequest{
Image: "nginx:alpine",
ExposedPorts: []string{"80/tcp"},
HostIP: "127.0.0.1",
}

err := p.preCreateContainerHook(ctx, req, dockerInput, hostConfig, networkingConfig)
require.NoError(t, err)

port := network.MustParsePort("80/tcp")
bindings := hostConfig.PortBindings[port]
require.Len(t, bindings, 1)
assert.Equal(t, "127.0.0.1", bindings[0].HostIP.String())
assert.Equal(t, "0", bindings[0].HostPort, "HostPort should remain ephemeral")
})

t.Run("overrides bindings set by HostConfigModifier", func(t *testing.T) {
dockerInput := &container.Config{}
hostConfig := &container.HostConfig{}
networkingConfig := &network.NetworkingConfig{}

req := ContainerRequest{
Image: "nginx:alpine",
ExposedPorts: []string{"80/tcp", "443/tcp"},
HostIP: "127.0.0.1",
HostConfigModifier: func(hc *container.HostConfig) {
hc.PortBindings = network.PortMap{
// Use a different address than req.HostIP so the test proves
// that req.HostIP overrides the modifier-provided HostIP.
network.MustParsePort("443/tcp"): {{HostIP: netip.MustParseAddr("0.0.0.0"), HostPort: "8443"}},
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
}

err := p.preCreateContainerHook(ctx, req, dockerInput, hostConfig, networkingConfig)
require.NoError(t, err)

for _, port := range []network.Port{network.MustParsePort("80/tcp"), network.MustParsePort("443/tcp")} {
bindings := hostConfig.PortBindings[port]
require.Len(t, bindings, 1, "expected a binding for %s", port)
assert.Equal(t, "127.0.0.1", bindings[0].HostIP.String(), "binding for %s should use the requested host IP", port)
}

bindings := hostConfig.PortBindings[network.MustParsePort("443/tcp")]
assert.Equal(t, "8443", bindings[0].HostPort, "custom HostPort should be preserved")
})

t.Run("invalid IP returns error", func(t *testing.T) {
dockerInput := &container.Config{}
hostConfig := &container.HostConfig{}
networkingConfig := &network.NetworkingConfig{}

req := ContainerRequest{
Image: "nginx:alpine",
ExposedPorts: []string{"80/tcp"},
HostIP: "not-an-ip",
}

err := p.preCreateContainerHook(ctx, req, dockerInput, hostConfig, networkingConfig)
require.Error(t, err)
})

t.Run("no-op when HostIP is empty", func(t *testing.T) {
dockerInput := &container.Config{}
hostConfig := &container.HostConfig{}
networkingConfig := &network.NetworkingConfig{}

req := ContainerRequest{
Image: "nginx:alpine",
ExposedPorts: []string{"80/tcp"},
}

err := p.preCreateContainerHook(ctx, req, dockerInput, hostConfig, networkingConfig)
require.NoError(t, err)

bindings := hostConfig.PortBindings[network.MustParsePort("80/tcp")]
require.Len(t, bindings, 1)
assert.Zero(t, bindings[0].HostIP, "HostIP should remain empty (bind all interfaces)")
})
}

// TestContainerWithHostIP is an integration test verifying that a container
// started with a host IP binds its exposed ports to that address only.
func TestContainerWithHostIP(t *testing.T) {
ctx := context.Background()

t.Run("ContainerRequest.HostIP", func(t *testing.T) {
req := ContainerRequest{
Image: nginxAlpineImage,
ExposedPorts: []string{nginxDefaultPort},
HostIP: "127.0.0.1",
}

container, err := GenericContainer(ctx, GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
require.NoError(t, err)
defer func() {
require.NoError(t, container.Terminate(ctx))
}()

inspect, err := container.Inspect(ctx)
require.NoError(t, err)

port := network.MustParsePort(nginxDefaultPort)
bindings, ok := inspect.NetworkSettings.Ports[port]
require.True(t, ok, "expected a binding for %s", nginxDefaultPort)
require.Len(t, bindings, 1)
require.Equal(t, "127.0.0.1", bindings[0].HostIP.String())
})

t.Run("WithHostIP option", func(t *testing.T) {
container, err := Run(ctx, nginxAlpineImage,
WithExposedPorts(nginxDefaultPort),
WithHostIP("127.0.0.1"),
)
require.NoError(t, err)
defer func() {
require.NoError(t, container.Terminate(ctx))
}()

inspect, err := container.Inspect(ctx)
require.NoError(t, err)

port := network.MustParsePort(nginxDefaultPort)
bindings, ok := inspect.NetworkSettings.Ports[port]
require.True(t, ok, "expected a binding for %s", nginxDefaultPort)
require.Len(t, bindings, 1)
require.Equal(t, "127.0.0.1", bindings[0].HostIP.String())
})
}
17 changes: 17 additions & 0 deletions lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"net/netip"
"reflect"
"strings"
"time"
Expand Down Expand Up @@ -544,6 +545,22 @@ func (p *DockerProvider) preCreateContainerHook(ctx context.Context, req Contain

dockerInput.ExposedPorts = exposedPortSet
hostConfig.PortBindings = mergePortBindings(hostConfig.PortBindings, exposedPortSet)

// Bind all exposed ports to the requested host IP address.
// The binding happens after the port bindings are merged, so it applies to both
// the default ephemeral bindings and the ones set by the HostConfigModifier.
if req.HostIP != "" {
ip, err := netip.ParseAddr(req.HostIP)
if err != nil {
return fmt.Errorf("invalid host IP %q: %w", req.HostIP, err)
}

for _, bindings := range hostConfig.PortBindings {
for i := range bindings {
bindings[i].HostIP = ip
}
}
}
return nil
}

Expand Down
14 changes: 14 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"maps"
"net/netip"
"path"
"time"

Expand Down Expand Up @@ -122,6 +123,19 @@ func WithHostConfigModifier(modifier func(hostConfig *container.HostConfig)) Cus
}
}

// WithHostIP allows to set the IP address to which the exposed ports will be bound on the host.
// By default, the exposed ports are bound to all interfaces (0.0.0.0).
func WithHostIP(ip string) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
addr, err := netip.ParseAddr(ip)
if err != nil {
return fmt.Errorf("invalid host IP %q: %w", ip, err)
}
req.HostIP = addr.String()
return nil
}
}

// WithHostPortAccess allows to expose the host ports to the container
func WithHostPortAccess(ports ...int) CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
Expand Down
26 changes: 26 additions & 0 deletions options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,32 @@ func TestWithProvider(t *testing.T) {
})
}

func TestWithHostIP(t *testing.T) {
t.Run("valid IP", func(t *testing.T) {
req := testcontainers.GenericContainerRequest{}

opt := testcontainers.WithHostIP("127.0.0.1")
require.NoError(t, opt.Customize(&req))
require.Equal(t, "127.0.0.1", req.HostIP)
})

t.Run("valid IPv6", func(t *testing.T) {
req := testcontainers.GenericContainerRequest{}

opt := testcontainers.WithHostIP("::1")
require.NoError(t, opt.Customize(&req))
require.Equal(t, "::1", req.HostIP)
})

t.Run("invalid IP", func(t *testing.T) {
req := testcontainers.GenericContainerRequest{}

opt := testcontainers.WithHostIP("not-an-ip")
require.Error(t, opt.Customize(&req))
require.Empty(t, req.HostIP)
})
}

func TestWithHostConfigModifier(t *testing.T) {
t.Run("simple", func(t *testing.T) {
c, err := testcontainers.Run(
Expand Down
Loading