From 1bd177f01edcee893cd1a85dba2d88663c2885a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edd=C3=BA=20Mel=C3=A9ndez=20Gonzales?= Date: Thu, 30 Mar 2023 10:26:37 -0600 Subject: [PATCH] Add support for LocalStack v2 (#6808) `HOSTNAME_EXTERNAL` env var is deprecated and will be replaced by `LOCALSTACK_HOST` in the upcoming v2. Fixes #6792 --- .../localstack/LocalStackContainer.java | 52 +++++++--- .../localstack/LocalstackContainerTest.java | 99 +++++++++++++++++-- 2 files changed, 126 insertions(+), 25 deletions(-) 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 6da5857adaa..a4b2224e1d2 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 @@ -21,17 +21,18 @@ import java.util.stream.Collectors; /** - *

Container for LocalStack, 'A fully functional local AWS cloud stack'.

- *

{@link LocalStackContainer#withServices(Service...)} should be used to select which services - * are to be launched. See {@link Service} for available choices. + * Testcontainers implementation for LocalStack. */ @Slf4j public class LocalStackContainer extends GenericContainer { static final int PORT = 4566; + @Deprecated private static final String HOSTNAME_EXTERNAL_ENV_VAR = "HOSTNAME_EXTERNAL"; + private static final String LOCALSTACK_HOST_ENV_VAR = "LOCALSTACK_HOST"; + private final List services = new ArrayList<>(); private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("localstack/localstack"); @@ -66,6 +67,8 @@ public class LocalStackContainer extends GenericContainer { */ private final boolean servicesEnvVarRequired; + private final boolean isVersion2; + /** * @deprecated use {@link LocalStackContainer(DockerImageName)} instead */ @@ -92,18 +95,31 @@ public LocalStackContainer(final DockerImageName dockerImageName) { /** * @param dockerImageName image name to use for Localstack * @param useLegacyMode if true, each AWS service is exposed on a different port + * @deprecated use {@link LocalStackContainer(DockerImageName)} instead */ + @Deprecated public LocalStackContainer(final DockerImageName dockerImageName, boolean useLegacyMode) { super(dockerImageName); dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); this.legacyMode = useLegacyMode; - this.servicesEnvVarRequired = isServicesEnvVarRequired(dockerImageName.getVersionPart()); + String version = dockerImageName.getVersionPart(); + this.servicesEnvVarRequired = isServicesEnvVarRequired(version); + this.isVersion2 = isVersion2(version); withFileSystemBind(DockerClientFactory.instance().getRemoteDockerUnixSocketPath(), "/var/run/docker.sock"); waitingFor(Wait.forLogMessage(".*Ready\\.\n", 1)); } + private static boolean isVersion2(String version) { + if (version.equals("latest")) { + return true; + } + + ComparableVersion comparableVersion = new ComparableVersion(version); + return comparableVersion.isGreaterThanOrEqualTo("2.0.0"); + } + private static boolean isServicesEnvVarRequired(String version) { if (version.equals("latest")) { return false; @@ -141,7 +157,7 @@ private static boolean shouldRunInLegacyMode(String version) { protected void configure() { super.configure(); - if (servicesEnvVarRequired) { + if (this.servicesEnvVarRequired) { Preconditions.check("services list must not be empty", !services.isEmpty()); } @@ -152,26 +168,30 @@ protected void configure() { } } + if (this.isVersion2) { + resolveHostname(LOCALSTACK_HOST_ENV_VAR); + } else { + resolveHostname(HOSTNAME_EXTERNAL_ENV_VAR); + } + + exposePorts(); + } + + private void resolveHostname(String envVar) { String hostnameExternalReason; - if (getEnvMap().containsKey(HOSTNAME_EXTERNAL_ENV_VAR)) { + if (getEnvMap().containsKey(envVar)) { // do nothing hostnameExternalReason = "explicitly as environment variable"; } else if (getNetwork() != null && getNetworkAliases() != null && getNetworkAliases().size() >= 1) { - withEnv(HOSTNAME_EXTERNAL_ENV_VAR, getNetworkAliases().get(getNetworkAliases().size() - 1)); // use the last network alias set + withEnv(envVar, getNetworkAliases().get(getNetworkAliases().size() - 1)); // use the last network alias set hostnameExternalReason = "to match last network alias on container with non-default network"; } else { - withEnv(HOSTNAME_EXTERNAL_ENV_VAR, getHost()); + withEnv(envVar, getHost()); hostnameExternalReason = "to match host-routable address for container"; } - logger() - .info( - "{} environment variable set to {} ({})", - HOSTNAME_EXTERNAL_ENV_VAR, - getEnvMap().get(HOSTNAME_EXTERNAL_ENV_VAR), - hostnameExternalReason - ); - exposePorts(); + logger() + .info("{} environment variable set to {} ({})", envVar, getEnvMap().get(envVar), hostnameExternalReason); } private void exposePorts() { 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 b99c455ae19..30682388af5 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 @@ -30,6 +30,7 @@ import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.Network; import org.testcontainers.containers.localstack.LocalStackContainer.Service; +import org.testcontainers.utility.DockerImageName; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; @@ -265,6 +266,11 @@ public static class WithNetwork { .withEnv("AWS_SECRET_ACCESS_KEY", "secretkey") .withEnv("AWS_REGION", "eu-west-1"); + @Test + public void localstackHostEnVarIsSet() { + assertThat(localstackInDockerNetwork.getEnvMap().get("HOSTNAME_EXTERNAL")).isEqualTo("localstack"); + } + @Test public void s3TestOverDockerNetwork() throws Exception { runAwsCliAgainstDockerNetworkContainer( @@ -357,17 +363,92 @@ public static class WithoutServices { @Test public void s3ServiceStartLazily() { - S3Client s3 = S3Client - .builder() - .endpointOverride(localstack.getEndpointOverride(Service.S3)) - .credentialsProvider( - StaticCredentialsProvider.create( - AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()) + try ( + S3Client s3 = S3Client + .builder() + .endpointOverride(localstack.getEndpointOverride(Service.S3)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()) + ) ) + .region(Region.of(localstack.getRegion())) + .build() + ) { + assertThat(s3.listBuckets().buckets()).as("S3 Service is started lazily").isEmpty(); + } + } + } + + public static class WithVersion2 { + + private static Network network = Network.newNetwork(); + + @ClassRule + public static LocalStackContainer localstack = new LocalStackContainer( + DockerImageName.parse("localstack/localstack:2.0") + ) + .withNetwork(network) + .withNetworkAliases("localstack"); + + @ClassRule + public static GenericContainer awsCliInDockerNetwork = new GenericContainer<>( + LocalstackTestImages.AWS_CLI_IMAGE + ) + .withNetwork(network) + .withCreateContainerCmdModifier(cmd -> cmd.withEntrypoint("tail")) + .withCommand(" -f /dev/null") + .withEnv("AWS_ACCESS_KEY_ID", "accesskey") + .withEnv("AWS_SECRET_ACCESS_KEY", "secretkey") + .withEnv("AWS_REGION", "eu-west-1"); + + @Test + public void localstackHostEnVarIsSet() { + assertThat(localstack.getEnvMap().get("LOCALSTACK_HOST")).isEqualTo("localstack"); + } + + @Test + public void sqsTestOverDockerNetwork() throws Exception { + final String queueCreationResponse = runAwsCliAgainstDockerNetworkContainer( + "sqs create-queue --queue-name baz" + ); + + assertThat(queueCreationResponse) + .as("Created queue has external hostname URL") + .contains("http://localstack:" + LocalStackContainer.PORT); + + runAwsCliAgainstDockerNetworkContainer( + String.format( + "sqs send-message --endpoint http://localstack:%d --queue-url http://localstack:%d/queue/baz --message-body test", + LocalStackContainer.PORT, + LocalStackContainer.PORT ) - .region(Region.of(localstack.getRegion())) - .build(); - assertThat(s3.listBuckets().buckets()).as("S3 Service is started lazily").isEmpty(); + ); + final String message = runAwsCliAgainstDockerNetworkContainer( + String.format( + "sqs receive-message --endpoint http://localstack:%d --queue-url http://localstack:%d/queue/baz", + LocalStackContainer.PORT, + LocalStackContainer.PORT + ) + ); + + assertThat(message).as("the sent message can be received").contains("\"Body\": \"test\""); + } + + private String runAwsCliAgainstDockerNetworkContainer(String command) throws Exception { + final String[] commandParts = String + .format( + "/usr/local/bin/aws --region eu-west-1 %s --endpoint-url http://localstack:%d --no-verify-ssl", + command, + LocalStackContainer.PORT + ) + .split(" "); + final Container.ExecResult execResult = awsCliInDockerNetwork.execInContainer(commandParts); + assertThat(execResult.getExitCode()).isEqualTo(0); + + final String logs = execResult.getStdout() + execResult.getStderr(); + log.info(logs); + return logs; } } }