diff --git a/core/src/main/java/org/testcontainers/utility/ComparableVersion.java b/core/src/main/java/org/testcontainers/utility/ComparableVersion.java index 1ffa1642568..d3e3d9eb958 100644 --- a/core/src/main/java/org/testcontainers/utility/ComparableVersion.java +++ b/core/src/main/java/org/testcontainers/utility/ComparableVersion.java @@ -32,6 +32,10 @@ public int compareTo(@NotNull ComparableVersion other) { return 0; } + public boolean isSemanticVersion() { + return parts.length > 0; + } + public boolean isLessThan(String other) { return this.compareTo(new ComparableVersion(other)) < 0; } diff --git a/modules/localstack/src/main/java/org/testcontainers/containers/localstack/LocalStackContainer.java b/modules/localstack/src/main/java/org/testcontainers/containers/localstack/LocalStackContainer.java index 0136d94e3b2..3a9fab26ae4 100644 --- a/modules/localstack/src/main/java/org/testcontainers/containers/localstack/LocalStackContainer.java +++ b/modules/localstack/src/main/java/org/testcontainers/containers/localstack/LocalStackContainer.java @@ -4,15 +4,6 @@ import com.amazonaws.auth.AWSStaticCredentialsProvider; import com.amazonaws.auth.BasicAWSCredentials; import com.amazonaws.client.builder.AwsClientBuilder; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.experimental.FieldDefaults; -import org.rnorth.ducttape.Preconditions; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.Wait; -import org.testcontainers.utility.DockerImageName; -import org.testcontainers.utility.TestcontainersConfiguration; - import java.net.InetAddress; import java.net.URI; import java.net.URISyntaxException; @@ -21,6 +12,16 @@ import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.rnorth.ducttape.Preconditions; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.TestcontainersConfiguration; /** *

Container for Atlassian Labs Localstack, 'A fully functional local AWS cloud stack'.

@@ -30,13 +31,28 @@ * {@link LocalStackContainer#getDefaultCredentialsProvider()} * be used to obtain compatible endpoint configuration and credentials, respectively.

*/ +@Slf4j public class LocalStackContainer extends GenericContainer { - public static final String VERSION = "0.10.8"; + public static final String VERSION = "0.11.2"; + static final int PORT = 4566; private static final String HOSTNAME_EXTERNAL_ENV_VAR = "HOSTNAME_EXTERNAL"; - private final List services = new ArrayList<>(); + /** + * Whether or to assume that all APIs run on different ports (when true) or are + * exposed on a single port (false). From the Localstack README: + * + *
Note: Starting with version 0.11.0, all APIs are exposed via a single edge + * service [...] The API-specific endpoints below are still left for backward-compatibility but + * may get removed in a future release - please reconfigure your client SDKs to start using the + * single edge endpoint URL!
+ *

+ * Testcontainers will use the tag of the docker image to infer whether or not the used version + * of Localstack supports this feature. + */ + private final boolean legacyMode; + /** * @deprecated use {@link LocalStackContainer(DockerImageName)} instead */ @@ -53,13 +69,41 @@ public LocalStackContainer(String version) { this(DockerImageName.parse(TestcontainersConfiguration.getInstance().getLocalStackImage() + ":" + version)); } + /** + * @param dockerImageName image name to use for Localstack + */ public LocalStackContainer(final DockerImageName dockerImageName) { + this(dockerImageName, shouldRunInLegacyMode(dockerImageName.getVersionPart())); + } + + /** + * @param dockerImageName image name to use for Localstack + * @param useLegacyMode if true, each AWS service is exposed on a different port + */ + public LocalStackContainer(final DockerImageName dockerImageName, boolean useLegacyMode) { super(dockerImageName); + this.legacyMode = useLegacyMode; withFileSystemBind("//var/run/docker.sock", "/var/run/docker.sock"); waitingFor(Wait.forLogMessage(".*Ready\\.\n", 1)); } + private static boolean shouldRunInLegacyMode(String version) { + if (version.equals("latest")) { + return false; + } + + ComparableVersion comparableVersion = new ComparableVersion(version); + if (comparableVersion.isSemanticVersion()) { + boolean versionRequiresLegacyMode = comparableVersion.isLessThan("0.11"); + return versionRequiresLegacyMode; + } + + log.warn("Version {} is not a semantic version, LocalStack will run in legacy mode.", version); + log.warn("Consider using \"LocalStackContainer(DockerImageName dockerImageName, boolean legacyMode)\" constructor if you want to disable legacy mode."); + return true; + } + @Override protected void configure() { super.configure(); @@ -81,9 +125,14 @@ protected void configure() { } logger().info("{} environment variable set to {} ({})", HOSTNAME_EXTERNAL_ENV_VAR, getEnvMap().get(HOSTNAME_EXTERNAL_ENV_VAR), hostnameExternalReason); - for (Service service : services) { - addExposedPort(service.getPort()); - } + exposePorts(); + } + + private void exposePorts() { + services.stream() + .map(this::getServicePort) + .distinct() + .forEach(this::addExposedPort); } /** @@ -154,12 +203,16 @@ public URI getEndpointOverride(Service service) { return new URI("http://" + ipAddress + ":" + - getMappedPort(service.getPort())); + getMappedPort(getServicePort(service))); } catch (UnknownHostException | URISyntaxException e) { throw new IllegalStateException("Cannot obtain endpoint URL", e); } } + private int getServicePort(Service service) { + return legacyMode ? service.port : PORT; + } + /** * Provides a {@link AWSCredentialsProvider} that is preconfigured to communicate with a given simulated service. * The credentials provider should be set in the AWS Java SDK when building a client, e.g.: @@ -271,5 +324,13 @@ public enum Service { String localStackName; int port; + + @Deprecated + /* + Since version 0.11, LocalStack exposes all services on a single (4566) port. + */ + public int getPort() { + return port; + } } } diff --git a/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LegacyModeTest.java b/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LegacyModeTest.java new file mode 100644 index 00000000000..f50846b091f --- /dev/null +++ b/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LegacyModeTest.java @@ -0,0 +1,128 @@ +package org.testcontainers.containers.localstack; + +import lombok.AllArgsConstructor; +import lombok.SneakyThrows; +import org.junit.After; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Arrays; +import java.util.function.Consumer; + +import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; +import static org.rnorth.visibleassertions.VisibleAssertions.assertNotEquals; +import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3; +import static org.testcontainers.containers.localstack.LocalStackContainer.Service.SQS; +import static org.testcontainers.containers.localstack.LocalstackTestImages.LOCALSTACK_IMAGE; + +@RunWith(Enclosed.class) +public class LegacyModeTest { + + @RunWith(Parameterized.class) + @AllArgsConstructor + public static class Off { + private final String description; + private final LocalStackContainer localstack; + + @Parameterized.Parameters(name = "{0}") + public static Iterable constructors() { + return Arrays.asList(new Object[][]{ + {"default constructor", new LocalStackContainer(LOCALSTACK_IMAGE)}, + {"latest", new LocalStackContainer(LOCALSTACK_IMAGE.withTag("latest"))}, + {"0.11.1", new LocalStackContainer(LOCALSTACK_IMAGE.withTag("0.11.1"))}, + {"0.7.0 with legacy = off", new LocalStackContainer(LOCALSTACK_IMAGE.withTag("0.7.0"), false)} + }); + } + + @Test + public void samePortIsExposedForAllServices() { + localstack.withServices(S3, SQS); + localstack.start(); + + assertTrue("A single port is exposed", localstack.getExposedPorts().size() == 1); + assertEquals( + "Endpoint overrides are different", + localstack.getEndpointOverride(S3).toString(), + localstack.getEndpointOverride(SQS).toString()); + assertEquals( + "Endpoint configuration have different endpoints", + localstack.getEndpointConfiguration(S3).getServiceEndpoint(), + localstack.getEndpointConfiguration(SQS).getServiceEndpoint()); + } + + @After + public void cleanup() { + if (localstack != null) localstack.stop(); + } + } + + @RunWith(Parameterized.class) + @AllArgsConstructor + public static class On { + private final String description; + private final LocalStackContainer localstack; + + @BeforeClass + public static void createCustomTag() { + run("docker pull localstack/localstack:latest"); + run("docker tag localstack/localstack:latest localstack/localstack:custom"); + } + + @Parameterized.Parameters(name = "{0}") + public static Iterable constructors() { + return Arrays.asList(new Object[][]{ + {"0.10.7", new LocalStackContainer(LOCALSTACK_IMAGE.withTag("0.10.7"))}, + {"custom", new LocalStackContainer(LOCALSTACK_IMAGE.withTag("custom"))}, + {"0.11.1 with legacy = on", new LocalStackContainer(LOCALSTACK_IMAGE.withTag("0.11.1"), true)} + }); + } + + @Test + public void differentPortsAreExposed() { + localstack.withServices(S3, SQS); + localstack.start(); + + assertTrue("Multiple ports are exposed", localstack.getExposedPorts().size() > 1); + assertNotEquals( + "Endpoint overrides are different", + localstack.getEndpointOverride(S3).toString(), + localstack.getEndpointOverride(SQS).toString()); + assertNotEquals( + "Endpoint configuration have different endpoints", + localstack.getEndpointConfiguration(S3).getServiceEndpoint(), + localstack.getEndpointConfiguration(SQS).getServiceEndpoint()); + } + + @After + public void cleanup() { + if (localstack != null) localstack.stop(); + } + } + + @SneakyThrows + private static void run(String command) { + Process process = Runtime.getRuntime().exec(command); + join(process.getInputStream(), System.out::println); + join(process.getErrorStream(), System.err::println); + process.waitFor(); + if (process.exitValue() != 0) + throw new RuntimeException("Failed to execute " + command); + } + + private static void join(InputStream stream, Consumer logger) throws IOException { + BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(stream)); + String line; + while ((line = bufferedReader.readLine()) != null) { + logger.accept(line); + } + } + +} diff --git a/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackContainerTest.java b/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackContainerTest.java index 63be9d66df7..fad4060341c 100644 --- a/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackContainerTest.java +++ b/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackContainerTest.java @@ -1,6 +1,16 @@ package org.testcontainers.containers.localstack; +import static org.hamcrest.CoreMatchers.containsString; +import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; +import static org.rnorth.visibleassertions.VisibleAssertions.assertThat; +import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.testcontainers.containers.localstack.LocalStackContainer.PORT; +import static org.testcontainers.containers.localstack.LocalStackContainer.Service.CLOUDWATCHLOGS; +import static org.testcontainers.containers.localstack.LocalStackContainer.Service.KMS; +import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3; +import static org.testcontainers.containers.localstack.LocalStackContainer.Service.SQS; + import com.amazonaws.services.kms.AWSKMS; import com.amazonaws.services.kms.AWSKMSClientBuilder; import com.amazonaws.services.kms.model.CreateKeyRequest; @@ -18,6 +28,10 @@ import com.amazonaws.services.sqs.AmazonSQS; import com.amazonaws.services.sqs.AmazonSQSClientBuilder; import com.amazonaws.services.sqs.model.CreateQueueResult; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Optional; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; import org.junit.Assert; @@ -35,20 +49,6 @@ import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; -import java.io.IOException; -import java.nio.charset.Charset; -import java.util.List; -import java.util.Optional; - -import static org.hamcrest.CoreMatchers.containsString; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThat; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.containers.localstack.LocalStackContainer.Service.CLOUDWATCHLOGS; -import static org.testcontainers.containers.localstack.LocalStackContainer.Service.KMS; -import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3; -import static org.testcontainers.containers.localstack.LocalStackContainer.Service.SQS; - /** * Tests for Localstack Container, used both in bridge network (exposed to host) and docker network modes. *

@@ -60,14 +60,13 @@ @RunWith(Enclosed.class) public class LocalstackContainerTest { - private static final DockerImageName LOCALSTACK_IMAGE = DockerImageName.parse("localstack/localstack:0.10.8"); - private static final DockerImageName AWS_CLI_IMAGE = DockerImageName.parse("atlassian/pipelines-awscli:1.16.302"); - public static class WithoutNetwork { // without_network { @ClassRule - public static LocalStackContainer localstack = new LocalStackContainer(LOCALSTACK_IMAGE) + public static LocalStackContainer localstack = new LocalStackContainer( + DockerImageName.parse("localstack/localstack:0.11.0") + ) .withServices(S3, SQS, CLOUDWATCHLOGS, KMS); // } @@ -124,7 +123,7 @@ public void sqsTestOverBridgeNetwork() { CreateQueueResult queueResult = sqs.createQueue("baz"); String fooQueueUrl = queueResult.getQueueUrl(); assertThat("Created queue has external hostname URL", fooQueueUrl, - containsString("http://" + DockerClientFactory.instance().dockerHostIpAddress() + ":" + localstack.getMappedPort(SQS.getPort()))); + containsString("http://" + DockerClientFactory.instance().dockerHostIpAddress() + ":" + localstack.getMappedPort(PORT))); sqs.sendMessage(fooQueueUrl, "test"); final long messageCount = sqs.receiveMessage(fooQueueUrl).getMessages().stream() @@ -136,8 +135,8 @@ public void sqsTestOverBridgeNetwork() { @Test public void cloudWatchLogsTestOverBridgeNetwork() { AWSLogs logs = AWSLogsClientBuilder.standard() - .withEndpointConfiguration(localstack.getEndpointConfiguration(CLOUDWATCHLOGS)) - .withCredentials(localstack.getDefaultCredentialsProvider()).build(); + .withEndpointConfiguration(localstack.getEndpointConfiguration(CLOUDWATCHLOGS)) + .withCredentials(localstack.getDefaultCredentialsProvider()).build(); logs.createLogGroup(new CreateLogGroupRequest("foo")); @@ -160,6 +159,19 @@ public void kmsKeyCreationTest() { assertEquals("AWS KMS Customer Managed Key should be created ", key.getKeyMetadata().getDescription(), desc); } + + @Test + public void samePortIsExposedForAllServices() { + assertTrue("A single port is exposed", localstack.getExposedPorts().size() == 1); + assertEquals( + "Endpoint overrides are different", + localstack.getEndpointOverride(S3).toString(), + localstack.getEndpointOverride(SQS).toString()); + assertEquals( + "Endpoint configuration have different endpoints", + localstack.getEndpointConfiguration(S3).getServiceEndpoint(), + localstack.getEndpointConfiguration(SQS).getServiceEndpoint()); + } } public static class WithNetwork { @@ -167,14 +179,16 @@ public static class WithNetwork { private static Network network = Network.newNetwork(); @ClassRule - public static LocalStackContainer localstackInDockerNetwork = new LocalStackContainer(LOCALSTACK_IMAGE) + public static LocalStackContainer localstackInDockerNetwork = new LocalStackContainer( + DockerImageName.parse("localstack/localstack:0.11.0") + ) .withNetwork(network) .withNetworkAliases("notthis", "localstack") // the last alias is used for HOSTNAME_EXTERNAL .withServices(S3, SQS, CLOUDWATCHLOGS); // } @ClassRule - public static GenericContainer awsCliInDockerNetwork = new GenericContainer<>(AWS_CLI_IMAGE) + public static GenericContainer awsCliInDockerNetwork = new GenericContainer<>(LocalstackTestImages.AWS_CLI_IMAGE) .withNetwork(network) .withCreateContainerCmdModifier(cmd -> cmd.withEntrypoint("top")) .withEnv("AWS_ACCESS_KEY_ID", "accesskey") @@ -184,33 +198,33 @@ public static class WithNetwork { @Test public void s3TestOverDockerNetwork() throws Exception { - runAwsCliAgainstDockerNetworkContainer("s3api create-bucket --bucket foo", S3.getPort()); - runAwsCliAgainstDockerNetworkContainer("s3api list-buckets", S3.getPort()); - runAwsCliAgainstDockerNetworkContainer("s3 ls s3://foo", S3.getPort()); + runAwsCliAgainstDockerNetworkContainer("s3api create-bucket --bucket foo"); + runAwsCliAgainstDockerNetworkContainer("s3api list-buckets"); + runAwsCliAgainstDockerNetworkContainer("s3 ls s3://foo"); } @Test public void sqsTestOverDockerNetwork() throws Exception { - final String queueCreationResponse = runAwsCliAgainstDockerNetworkContainer("sqs create-queue --queue-name baz", SQS.getPort()); + final String queueCreationResponse = runAwsCliAgainstDockerNetworkContainer("sqs create-queue --queue-name baz"); assertThat("Created queue has external hostname URL", queueCreationResponse, - containsString("http://localstack:" + SQS.getPort())); + containsString("http://localstack:" + PORT)); runAwsCliAgainstDockerNetworkContainer( - String.format("sqs send-message --endpoint http://localstack:%d --queue-url http://localstack:%d/queue/baz --message-body test", SQS.getPort(), SQS.getPort()), SQS.getPort()); + String.format("sqs send-message --endpoint http://localstack:%d --queue-url http://localstack:%d/queue/baz --message-body test", PORT, PORT)); final String message = runAwsCliAgainstDockerNetworkContainer( - String.format("sqs receive-message --endpoint http://localstack:%d --queue-url http://localstack:%d/queue/baz", SQS.getPort(), SQS.getPort()), SQS.getPort()); + String.format("sqs receive-message --endpoint http://localstack:%d --queue-url http://localstack:%d/queue/baz", PORT, PORT)); assertTrue("the sent message can be received", message.contains("\"Body\": \"test\"")); } @Test public void cloudWatchLogsTestOverDockerNetwork() throws Exception { - runAwsCliAgainstDockerNetworkContainer("logs create-log-group --log-group-name foo", CLOUDWATCHLOGS.getPort()); + runAwsCliAgainstDockerNetworkContainer("logs create-log-group --log-group-name foo"); } - private String runAwsCliAgainstDockerNetworkContainer(String command, final int port) throws Exception { - final String[] commandParts = String.format("/usr/bin/aws --region eu-west-1 %s --endpoint-url http://localstack:%d --no-verify-ssl", command, port).split(" "); + private String runAwsCliAgainstDockerNetworkContainer(String command) throws Exception { + final String[] commandParts = String.format("/usr/bin/aws --region eu-west-1 %s --endpoint-url http://localstack:%d --no-verify-ssl", command, PORT).split(" "); final Container.ExecResult execResult = awsCliInDockerNetwork.execInContainer(commandParts); Assert.assertEquals(0, execResult.getExitCode()); diff --git a/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackTestImages.java b/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackTestImages.java new file mode 100644 index 00000000000..60e5498daf3 --- /dev/null +++ b/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackTestImages.java @@ -0,0 +1,8 @@ +package org.testcontainers.containers.localstack; + +import org.testcontainers.utility.DockerImageName; + +public interface LocalstackTestImages { + DockerImageName LOCALSTACK_IMAGE = DockerImageName.parse("localstack/localstack:0.11.0"); + DockerImageName AWS_CLI_IMAGE = DockerImageName.parse("atlassian/pipelines-awscli:1.16.302"); +}