diff --git a/core/src/main/java/org/testcontainers/containers/ContainerState.java b/core/src/main/java/org/testcontainers/containers/ContainerState.java index e19f7a85310..a0327ebb221 100644 --- a/core/src/main/java/org/testcontainers/containers/ContainerState.java +++ b/core/src/main/java/org/testcontainers/containers/ContainerState.java @@ -148,13 +148,12 @@ default Integer getFirstMappedPort() { * Get the actual mapped port for a given port exposed by the container. * It should be used in conjunction with {@link #getHost()}. *

- * Note: The returned port number might be outdated (for instance, after disconnecting from a network and reconnecting - * again). If you always need up-to-date value, override the {@link #getContainerInfo()} to return the - * {@link #getCurrentContainerInfo()}. + * The host port is resolved from a live container inspect so it stays correct after Docker + * reassigns published ports (for example after disconnecting from a network and reconnecting, + * or after attaching the container to an additional network). * * @param originalPort the original TCP port that is exposed * @return the port that the exposed port is mapped to, or null if it is not exposed - * @see #getContainerInfo() * @see #getCurrentContainerInfo() */ default Integer getMappedPort(int originalPort) { @@ -164,7 +163,9 @@ default Integer getMappedPort(int originalPort) { ); Ports.Binding[] binding = new Ports.Binding[0]; - final InspectContainerResponse containerInfo = this.getContainerInfo(); + // Live inspect: cached {@link #getContainerInfo()} can hold stale host ports after + // network attach/detach (see https://github.com/testcontainers/testcontainers-java/issues/11779). + final InspectContainerResponse containerInfo = this.getCurrentContainerInfo(); if (containerInfo != null) { binding = containerInfo.getNetworkSettings().getPorts().getBindings().get(new ExposedPort(originalPort)); } diff --git a/core/src/test/java/org/testcontainers/containers/ContainerStateTest.java b/core/src/test/java/org/testcontainers/containers/ContainerStateTest.java index 7b37bc1926f..2b33e4d5699 100644 --- a/core/src/test/java/org/testcontainers/containers/ContainerStateTest.java +++ b/core/src/test/java/org/testcontainers/containers/ContainerStateTest.java @@ -1,5 +1,10 @@ package org.testcontainers.containers; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.NetworkSettings; +import com.github.dockerjava.api.model.Ports; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -35,4 +40,48 @@ void test(String name, String testSet, List expectedResult) { List result = containerState.getBoundPortNumbers(); assertThat(result).hasSameElementsAs(expectedResult); } + + @Test + void getMappedPortUsesCurrentContainerInfo() { + ContainerState containerState = mock(ContainerState.class); + doCallRealMethod().when(containerState).getMappedPort(8080); + InspectContainerResponse cachedInfo = inspectResponse(8080, 18080); + InspectContainerResponse currentInfo = inspectResponse(8080, 28080); + + when(containerState.getContainerId()).thenReturn("container-id"); + when(containerState.getContainerInfo()).thenReturn(cachedInfo); + when(containerState.getCurrentContainerInfo()).thenReturn(currentInfo); + + assertThat(containerState.getMappedPort(8080)).isEqualTo(28080); + } + + @Test + void getFirstMappedPortUsesCurrentContainerInfo() { + ContainerState containerState = mock(ContainerState.class); + doCallRealMethod().when(containerState).getMappedPort(8080); + doCallRealMethod().when(containerState).getFirstMappedPort(); + InspectContainerResponse cachedInfo = inspectResponse(8080, 18080); + InspectContainerResponse currentInfo = inspectResponse(8080, 28080); + + when(containerState.getContainerId()).thenReturn("container-id"); + when(containerState.getExposedPorts()).thenReturn(Collections.singletonList(8080)); + when(containerState.getContainerInfo()).thenReturn(cachedInfo); + when(containerState.getCurrentContainerInfo()).thenReturn(currentInfo); + + assertThat(containerState.getFirstMappedPort()).isEqualTo(28080); + } + + private static InspectContainerResponse inspectResponse(int containerPort, int hostPort) { + InspectContainerResponse response = mock(InspectContainerResponse.class); + NetworkSettings networkSettings = mock(NetworkSettings.class); + Ports ports = new Ports(); + ExposedPort exposedPort = new ExposedPort(containerPort); + Ports.Binding binding = Ports.Binding.bindPort(hostPort); + ports.bind(exposedPort, binding); + + when(response.getNetworkSettings()).thenReturn(networkSettings); + when(networkSettings.getPorts()).thenReturn(ports); + + return response; + } } diff --git a/core/src/test/java/org/testcontainers/containers/NetworkTest.java b/core/src/test/java/org/testcontainers/containers/NetworkTest.java index 675658b353a..538498db8c1 100644 --- a/core/src/test/java/org/testcontainers/containers/NetworkTest.java +++ b/core/src/test/java/org/testcontainers/containers/NetworkTest.java @@ -1,5 +1,9 @@ package org.testcontainers.containers; +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.Ports; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.testcontainers.DockerClientFactory; @@ -109,5 +113,115 @@ void testReusability() { .isNotEqualTo(firstId); } } + + @Test + void getFirstMappedPortRemainsCorrectAfterSecondaryNetworkAttach() { + // Regression for https://github.com/testcontainers/testcontainers-java/issues/11779 + try ( + Network primary = Network.newNetwork(); + Network secondary = Network.newNetwork(); + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withNetwork(primary) + .withNetworkAliases("test-host") + .withExposedPorts(7077) + .withCommand( + "/bin/sh", + "-c", + "while true ; do printf 'HTTP/1.1 200 OK\\n\\nok' | nc -l -p 7077; done" + ) + ) { + container.start(); + + Integer mappedPortBefore = container.getFirstMappedPort(); + assertThat(mappedPortBefore).isEqualTo(publishedHostPort(container, 7077)); + + DockerClient dockerClient = DockerClientFactory.instance().client(); + dockerClient + .connectToNetworkCmd() + .withContainerId(container.getContainerId()) + .withNetworkId(secondary.getId()) + .exec(); + + assertThat(container.getFirstMappedPort()) + .as("mapped port after secondary network attach matches Docker") + .isEqualTo(publishedHostPort(container, 7077)); + assertThat(container.getMappedPort(7077)).isEqualTo(publishedHostPort(container, 7077)); + } + } + + @Test + void getMappedPortReflectsDockerRemapAfterNetworkDisconnectReconnect() { + // Docker reassigns published host ports after network disconnect/reconnect. + // Cached inspect data must not be used for mapped-port lookups. + try ( + Network primary = Network.newNetwork(); + Network secondary = Network.newNetwork(); + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withNetwork(primary) + .withExposedPorts(7077) + .withCommand( + "/bin/sh", + "-c", + "while true ; do printf 'HTTP/1.1 200 OK\\n\\nok' | nc -l -p 7077; done" + ) + ) { + container.start(); + + Integer mappedPortBefore = container.getMappedPort(7077); + Integer cachedPortBefore = hostPortFromInspect(container.getContainerInfo(), 7077); + assertThat(mappedPortBefore).isEqualTo(cachedPortBefore); + + DockerClient dockerClient = DockerClientFactory.instance().client(); + dockerClient + .connectToNetworkCmd() + .withContainerId(container.getContainerId()) + .withNetworkId(secondary.getId()) + .exec(); + dockerClient + .disconnectFromNetworkCmd() + .withContainerId(container.getContainerId()) + .withNetworkId(primary.getId()) + .exec(); + dockerClient + .connectToNetworkCmd() + .withContainerId(container.getContainerId()) + .withNetworkId(primary.getId()) + .exec(); + + Integer publishedAfter = publishedHostPort(container, 7077); + Integer stillCachedFromStart = hostPortFromInspect(container.getContainerInfo(), 7077); + + // When Docker remaps the host port, the start-time cache is stale — getMappedPort + // must still return Docker's current binding (not the cached one). + if (!publishedAfter.equals(stillCachedFromStart)) { + assertThat(container.getMappedPort(7077)) + .as("must not return stale cached host port after network remap") + .isNotEqualTo(stillCachedFromStart) + .isEqualTo(publishedAfter); + } else { + assertThat(container.getMappedPort(7077)).isEqualTo(publishedAfter); + } + assertThat(container.getFirstMappedPort()).isEqualTo(publishedAfter); + } + } + + private static Integer publishedHostPort(GenericContainer container, int containerPort) { + InspectContainerResponse inspect = DockerClientFactory + .instance() + .client() + .inspectContainerCmd(container.getContainerId()) + .exec(); + return hostPortFromInspect(inspect, containerPort); + } + + private static Integer hostPortFromInspect(InspectContainerResponse inspect, int containerPort) { + Ports.Binding[] bindings = inspect + .getNetworkSettings() + .getPorts() + .getBindings() + .get(new ExposedPort(containerPort)); + assertThat(bindings).isNotNull().isNotEmpty(); + return Integer.valueOf(bindings[0].getHostPortSpec()); + } } }