From ad3f69803db981288d4501e8de9fd258cb4690a4 Mon Sep 17 00:00:00 2001 From: Justin Novak Date: Sat, 5 Oct 2024 10:10:01 -0700 Subject: [PATCH 1/5] setup ExposeHostPorts forwards on container start Fixes testcontainers/testcontainers-go#2811 Previously ExposedHostPorts would start an SSHD container prior to starting the testcontainer and inject a PostReadies lifecycle hook into the testcontainer in order to set up remote port forwarding from the host to the SSHD container so the testcontainer can talk to the host via the SSHD container This would be an issue if the testcontainer depends on accessing the host port on startup ( e.g., a proxy server ) as the forwarding for the host access isn't set up until all the WiatFor strategies on the testcontainer have completed. The fix is to move the forwarding setup to the PreCreates hook on the testcontainer. Since remote forwarding doesn't establish a connection to the host port until a connection is made to the remote port, this should not be an issue even if the host isn't listening yet and ensures the remote port is available to the testcontainer immediately. --- port_forwarding.go | 4 +- port_forwarding_test.go | 124 +++++++++++++++++----------------------- 2 files changed, 53 insertions(+), 75 deletions(-) diff --git a/port_forwarding.go b/port_forwarding.go index 88f14f2d72..ad17fb105a 100644 --- a/port_forwarding.go +++ b/port_forwarding.go @@ -150,8 +150,8 @@ func exposeHostPorts(ctx context.Context, req *ContainerRequest, ports ...int) ( // after the container is ready, create the SSH tunnel // for each exposed port from the host. sshdConnectHook = ContainerLifecycleHooks{ - PostReadies: []ContainerHook{ - func(ctx context.Context, c Container) error { + PreCreates: []ContainerRequestHook{ + func(ctx context.Context, req ContainerRequest) error { return sshdContainer.exposeHostPort(ctx, req.HostAccessPorts...) }, }, diff --git a/port_forwarding_test.go b/port_forwarding_test.go index 7a82158147..72b3a40a2d 100644 --- a/port_forwarding_test.go +++ b/port_forwarding_test.go @@ -8,13 +8,12 @@ import ( "net/http" "net/http/httptest" "testing" - "time" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" - tcexec "github.com/testcontainers/testcontainers-go/exec" "github.com/testcontainers/testcontainers-go/network" + "github.com/testcontainers/testcontainers-go/wait" ) const ( @@ -23,42 +22,59 @@ const ( func TestExposeHostPorts(t *testing.T) { tests := []struct { - name string - numberOfPorts int - hasNetwork bool - hasHostAccess bool + name string + numberOfPorts int + hasNetwork bool + bindOnPostStarts bool }{ { name: "single port", numberOfPorts: 1, - hasHostAccess: true, }, { name: "single port using a network", numberOfPorts: 1, hasNetwork: true, - hasHostAccess: true, }, { name: "multiple ports", numberOfPorts: 3, - hasHostAccess: true, }, { - name: "single port with cancellation", - numberOfPorts: 1, - hasHostAccess: false, + name: "multiple ports bound on PostStarts", + numberOfPorts: 3, + bindOnPostStarts: true, }, } for _, tc := range tests { t.Run(tc.name, func(tt *testing.T) { + servers := make([]*httptest.Server, tc.numberOfPorts) freePorts := make([]int, tc.numberOfPorts) + waitStrategies := make([]wait.Strategy, tc.numberOfPorts) for i := range freePorts { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, expectedResponse) })) - freePorts[i] = server.Listener.Addr().(*net.TCPAddr).Port + + if !tc.bindOnPostStarts { + server.Start() + } + + servers[i] = server + freePort := server.Listener.Addr().(*net.TCPAddr).Port + freePorts[i] = freePort + waitStrategies[i] = wait. + ForExec([]string{"wget", "-q", "-O", "-", fmt.Sprintf("http://%s:%d", testcontainers.HostInternal, freePort)}). + WithExitCodeMatcher(func(code int) bool { + return code == 0 + }). + WithResponseMatcher(func(body io.Reader) bool { + bs, err := io.ReadAll(body) + require.NoError(tt, err) + return string(bs) == expectedResponse + }) + tt.Cleanup(func() { server.Close() }) @@ -69,7 +85,26 @@ func TestExposeHostPorts(t *testing.T) { ContainerRequest: testcontainers.ContainerRequest{ Image: "alpine:3.17", HostAccessPorts: freePorts, - Cmd: []string{"top"}, + WaitingFor: wait.ForAll(waitStrategies...), + LifecycleHooks: []testcontainers.ContainerLifecycleHooks{ + { + PostStarts: []testcontainers.ContainerHook{ + func(ctx context.Context, c testcontainers.Container) error { + if tc.bindOnPostStarts { + for _, server := range servers { + server.Start() + } + } + + return nil + }, + func(ctx context.Context, c testcontainers.Container) error { + return waitStrategies[0].WaitUntilReady(ctx, c) + }, + }, + }, + }, + Cmd: []string{"top"}, }, // } Started: true, @@ -87,66 +122,9 @@ func TestExposeHostPorts(t *testing.T) { } ctx := context.Background() - if !tc.hasHostAccess { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, 10*time.Second) - defer cancel() - } - c, err := testcontainers.GenericContainer(ctx, req) - testcontainers.CleanupContainer(t, c) require.NoError(tt, err) - - if tc.hasHostAccess { - // create a container that has host access, which will - // automatically forward the port to the container - assertContainerHasHostAccess(tt, c, freePorts...) - } else { - // force cancellation because of timeout - time.Sleep(11 * time.Second) - - assertContainerHasNoHostAccess(tt, c, freePorts...) - } + _ = c.Terminate(ctx) }) } } - -func httpRequest(t *testing.T, c testcontainers.Container, port int) (int, string) { - // wgetHostInternal { - code, reader, err := c.Exec( - context.Background(), - []string{"wget", "-q", "-O", "-", fmt.Sprintf("http://%s:%d", testcontainers.HostInternal, port)}, - tcexec.Multiplexed(), - ) - // } - require.NoError(t, err) - - // read the response - bs, err := io.ReadAll(reader) - require.NoError(t, err) - - return code, string(bs) -} - -func assertContainerHasHostAccess(t *testing.T, c testcontainers.Container, ports ...int) { - for _, port := range ports { - code, response := httpRequest(t, c, port) - if code != 0 { - t.Fatalf("expected status code [%d] but got [%d]", 0, code) - } - - if response != expectedResponse { - t.Fatalf("expected [%s] but got [%s]", expectedResponse, response) - } - } -} - -func assertContainerHasNoHostAccess(t *testing.T, c testcontainers.Container, ports ...int) { - for _, port := range ports { - _, response := httpRequest(t, c, port) - - if response == expectedResponse { - t.Fatalf("expected not to get [%s] but got [%s]", expectedResponse, response) - } - } -} From 0d2c28de7bfe69caece3d3f422d6a74371adff23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20de=20la=20Pe=C3=B1a?= Date: Fri, 5 Sep 2025 17:07:34 +0200 Subject: [PATCH 2/5] chore(port-forwarding): create sshd container before the container is created --- port_forwarding.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/port_forwarding.go b/port_forwarding.go index 107bd42d1b..bdfa7a9176 100644 --- a/port_forwarding.go +++ b/port_forwarding.go @@ -159,11 +159,11 @@ func exposeHostPorts(ctx context.Context, req *ContainerRequest, ports ...int) ( }, } - // after the container is ready, create the SSH tunnel + // before the container is created, create the SSH tunnel // for each exposed port from the host. sshdConnectHook = ContainerLifecycleHooks{ - PostReadies: []ContainerHook{ - func(ctx context.Context, _ Container) error { + PreCreates: []ContainerRequestHook{ + func(ctx context.Context, req ContainerRequest) error { return sshdContainer.exposeHostPort(ctx, req.HostAccessPorts...) }, }, From 5f56647fb73d85d4a66b7278fd99596ec780f5fe Mon Sep 17 00:00:00 2001 From: mdelapenya Date: Wed, 19 Nov 2025 07:00:18 +0000 Subject: [PATCH 3/5] chore: simplify --- port_forwarding_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/port_forwarding_test.go b/port_forwarding_test.go index a73a19a2f4..d0ec2e823d 100644 --- a/port_forwarding_test.go +++ b/port_forwarding_test.go @@ -33,14 +33,12 @@ func TestExposeHostPorts(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, expectedResponse) })) - hostPorts[i] = server.Listener.Addr().(*net.TCPAddr).Port servers[i] = server - freePort := server.Listener.Addr().(*net.TCPAddr).Port - hostPorts[i] = freePort + hostPorts[i] = server.Listener.Addr().(*net.TCPAddr).Port waitStrategies[i] = wait. - ForExec([]string{"wget", "-q", "-O", "-", fmt.Sprintf("http://%s:%d", testcontainers.HostInternal, freePort)}). + ForExec([]string{"wget", "-q", "-O", "-", fmt.Sprintf("http://%s:%d", testcontainers.HostInternal, hostPorts[i])}). WithExitCodeMatcher(func(code int) bool { return code == 0 }). From 327ed8e6fe4330529608124b533c3becc8a8cf02 Mon Sep 17 00:00:00 2001 From: mdelapenya Date: Thu, 20 Nov 2025 09:32:50 +0000 Subject: [PATCH 4/5] fix(solace): increase wait timeout --- modules/solace/solace.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/solace/solace.go b/modules/solace/solace.go index 8d40c411a7..f756f4dc23 100644 --- a/modules/solace/solace.go +++ b/modules/solace/solace.go @@ -43,13 +43,13 @@ func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustom // Primary wait strategy for Solace to be ready waitStrategies[0] = wait.ForExec([]string{"grep", "-q", "Primary Virtual Router is now active", "/usr/sw/jail/logs/system.log"}). - WithStartupTimeout(1 * time.Minute). + WithStartupTimeout(2 * time.Minute). WithPollInterval(1 * time.Second) // Add port-based wait strategies for each service for i, service := range settings.services { port := fmt.Sprintf("%d/tcp", service.Port) - waitStrategies[i+1] = wait.ForListeningPort(nat.Port(port)) + waitStrategies[i+1] = wait.ForListeningPort(nat.Port(port)).WithStartupTimeout(30 * time.Second) exposedPorts[i] = fmt.Sprintf("%d/tcp", service.Port) } From 092e17551689665b987bd8317aeedd1f26d95fca Mon Sep 17 00:00:00 2001 From: mdelapenya Date: Thu, 20 Nov 2025 09:48:06 +0000 Subject: [PATCH 5/5] chore(ci): bump setup docker action --- .github/workflows/ci-test-go.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-test-go.yml b/.github/workflows/ci-test-go.yml index bb3e6b43cf..fea34e62ee 100644 --- a/.github/workflows/ci-test-go.yml +++ b/.github/workflows/ci-test-go.yml @@ -55,7 +55,7 @@ jobs: steps: - name: Setup rootless Docker if: ${{ inputs.rootless-docker }} - uses: docker/setup-docker-action@b60f85385d03ac8acfca6d9996982511d8620a19 # v4 + uses: docker/setup-docker-action@efe9e3891a4f7307e689f2100b33a155b900a608 # v4.5.0 with: rootless: true