diff --git a/README.md b/README.md index 105b6eb..96693b2 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ containers to make testing nice for Ebean ORM, see https://ebean.io/docs/testing ## Supported Containers -Postgres, ClickHouse, CockroachDB, DB2, ElasticSearch, Hana, LocalDynamoDB, Localstack, MariaDB, MySql, NuoDB, Oracle, Postgres, Redis, SqlServer, Yugabyte. +Postgres, ClickHouse, CockroachDB, DB2, ElasticSearch, Floci, Hana, LocalDynamoDB, Localstack, MariaDB, MySql, NuoDB, Oracle, Postgres, Redis, SqlServer, Yugabyte. ## Dependency @@ -211,6 +211,25 @@ occurring automatically on JVM shutdown. ``` +#### Floci - `hectorvent/floci` + +```java + FlociContainer container = FlociContainer.builder("latest") + .services("dynamodb,kinesis,sns,sqs,s3,kms") + //.awsRegion("ap-southeast-2") + //.port(4566) + //.image("hectorvent/floci:latest") + .start(); + + AwsSDKv2 sdk = container.sdk2(); + DynamoDbClient dynamoDb = sdk.dynamoDBClient(); + SqsClient sqs = sdk.sqsClient(); + SnsClient sns = sdk.snsClient(); + + // setup - create dynamoDB tables, queues etc + +``` + #### LocalDynamoDB - `amazon/dynamodb-local` ```java @@ -319,4 +338,3 @@ occurring automatically on JVM shutdown. Refer to the ebean testing documentation (https://ebean.io/docs/testing/) ... where we use ebean-test to hook into the Ebean lifecycle and automatically start the docker containers as needed (prior to running tests etc). - diff --git a/src/main/java/io/ebean/test/containers/FlociContainer.java b/src/main/java/io/ebean/test/containers/FlociContainer.java new file mode 100644 index 0000000..53fcc8f --- /dev/null +++ b/src/main/java/io/ebean/test/containers/FlociContainer.java @@ -0,0 +1,207 @@ +package io.ebean.test.containers; + +import java.io.IOException; +import java.lang.System.Logger.Level; +import java.net.URI; +import java.util.List; +import java.util.Properties; + +/** + * Floci container that supports AWS SDK v2. + * + *
{@code
+ *
+ *     FlociContainer container = FlociContainer.builder("latest")
+ *       // .port(4566)
+ *       // .image("hectorvent/floci:latest")
+ *       .build();
+ *
+ *     container.start();
+ *
+ *     AwsSDKv2 sdk = container.sdk2();
+ *     var amazonDynamoDB = sdk.dynamoDBClient();
+ *     createTable(amazonDynamoDB);
+ *
+ * }
+ */ +public class FlociContainer extends BaseContainer { + + @Override + public FlociContainer start() { + startOrThrow(); + return this; + } + + /** + * Create a builder for FlociContainer given the Floci image version. + */ + public static Builder builder(String version) { + return new Builder(version); + } + + /** + * Builder for FlociContainer. + */ + public static class Builder extends BaseBuilder { + + private String services = "dynamodb"; + private String awsRegion = "ap-southeast-2"; + private String healthUri = "_floci/health"; + + /** + * Create with a version of hectorvent/floci (example, latest) + */ + private Builder(String version) { + super("floci", 4566, 4566, version); + this.image = "hectorvent/floci:" + version; + } + + @Override + protected void extraProperties(Properties properties) { + super.extraProperties(properties); + services = prop(properties, "services", services); + awsRegion = prop(properties, "awsRegion", awsRegion); + healthUri = prop(properties, "healthUri", healthUri); + } + + /** + * Set the services desired (comma delimited). Defaults to "dynamodb". + *

+ * Examples: "dynamodb", "dynamodb,sns,sqs,kinesis" + */ + public Builder services(String services) { + this.services = services; + return self(); + } + + /** + * Set the AWS region to use. For example, "ap-southeast-2". + */ + public Builder awsRegion(String awsRegion) { + this.awsRegion = awsRegion; + return self(); + } + + /** + * Set the healthUri option - defaults to _floci/health. + */ + public Builder healthUri(String healthUri) { + this.healthUri = healthUri; + return self(); + } + + /** + * Build and return the FlociContainer to then start(). + */ + public FlociContainer build() { + return new FlociContainer(this); + } + + @Override + public FlociContainer start() { + return build().start(); + } + } + + private final List serviceNames; + private final String awsRegion; + private final String healthUri; + + /** + * Create the container using the given config. + */ + public FlociContainer(Builder builder) { + super(builder); + this.awsRegion = builder.awsRegion; + this.healthUri = builder.healthUri; + this.serviceNames = TrimSplit.split(builder.services); + } + + private String healthUrl() { + return String.format("http://%s:%s/%s", config.getHost(), config.getPort(), healthUri); + } + + /** + * Return the AWS v2 SDK compatible helper that provides API for + * DynamoDB client, SnsClient, SqsClient etc. + */ + public AwsSDKv2 sdk() { + return sdk2(); + } + + /** + * Return the AWS v2 SDK compatible helper that provides API for + * DynamoDB client, SnsClient, SqsClient etc. + */ + public AwsSDKv2 sdk2() { + return new LocalstackSdkV2(awsRegion, endpoint()); + } + + /** + * Return the endpoint as URI. + */ + public URI endpoint() { + return URI.create(endpointUrl()); + } + + /** + * Return the endpoint as String. + */ + public String endpointUrl() { + return String.format("http://%s:%s/", config.getHost(), config.getPort()); + } + + /** + * Return the AWS region. + */ + public String awsRegion() { + return awsRegion; + } + + @Override + boolean checkConnectivity() { + try { + String content = readUrlContent(healthUrl()); + if (log.isLoggable(Level.TRACE)) { + log.log(Level.TRACE, "checkConnectivity content: {0}", content); + } + return checkStatus(content); + } catch (IOException e) { + return false; + } + } + + private boolean checkStatus(String content) { + String[] serviceEntries = content.split(","); + for (String serviceName : serviceNames) { + if (!isServiceReady(serviceName, serviceEntries)) { + return false; + } + } + return true; + } + + private boolean isServiceReady(String serviceName, String[] serviceEntries) { + String key = "\"" + serviceName + "\":"; + for (String serviceEntry : serviceEntries) { + if (serviceEntry.contains(key) && (serviceEntry.contains("\"running\"") || serviceEntry.contains("\"available\""))) { + return true; + } + } + return false; + } + + protected ProcessBuilder runProcess() { + List args = dockerRun(); + if (config.getAdminPort() > 0) { + args.add("-p"); + args.add(config.getAdminPort() + ":" + config.getAdminInternalPort()); + } + if (notEmpty(awsRegion)) { + args.add("-e"); + args.add("FLOCI_DEFAULT_REGION=" + awsRegion); + } + args.add(config.image()); + return createProcessBuilder(args); + } +} diff --git a/src/test/java/io/ebean/test/containers/FlociContainerIntegrationTest.java b/src/test/java/io/ebean/test/containers/FlociContainerIntegrationTest.java new file mode 100644 index 0000000..db0150c --- /dev/null +++ b/src/test/java/io/ebean/test/containers/FlociContainerIntegrationTest.java @@ -0,0 +1,242 @@ +package io.ebean.test.containers; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; +import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; +import software.amazon.awssdk.services.dynamodb.model.CreateTableResponse; +import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; +import software.amazon.awssdk.services.dynamodb.model.KeyType; +import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; +import software.amazon.awssdk.services.dynamodb.model.ResourceInUseException; +import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; +import software.amazon.awssdk.services.kinesis.KinesisClient; +import software.amazon.awssdk.services.kinesis.model.CreateStreamRequest; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.Bucket; +import software.amazon.awssdk.services.s3.model.CreateBucketResponse; +import software.amazon.awssdk.services.s3.model.ListBucketsResponse; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.CreateTopicRequest; +import software.amazon.awssdk.services.sns.model.PublishRequest; +import software.amazon.awssdk.services.sns.model.SubscribeRequest; +import software.amazon.awssdk.services.sqs.SqsClient; +import software.amazon.awssdk.services.sqs.model.CreateQueueRequest; +import software.amazon.awssdk.services.sqs.model.GetQueueAttributesRequest; +import software.amazon.awssdk.services.sqs.model.Message; +import software.amazon.awssdk.services.sqs.model.QueueAttributeName; +import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest; +import software.amazon.awssdk.services.sqs.model.ReceiveMessageResponse; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class FlociContainerIntegrationTest { + + @Test + void start_viaBuilder() { + FlociContainer container = FlociContainer.builder("latest") + .awsRegion("ap-southeast-2") + .services("dynamodb,kinesis,sns,sqs,s3") + .containerName("ut_floci_dkss2") + .image("hectorvent/floci:latest") + .port(4578) + .build(); + + container.startMaybe(); + + AwsSDKv2 sdk = container.sdk2(); + assertThat(sdk.endpoint()).isNotNull(); + + DynamoDbClient dynamoDB = sdk.dynamoDBClient(); + createTable(dynamoDB); + + KinesisClient kinesis = sdk.kinesisClient(); + SnsClient sns = sdk.snsClient(); + SqsClient sqs = sdk.sqsClient(); + S3Client s3 = sdk.s3Client(); + + useSnsSqs(sdk); + useKinesis(sdk); + useS3(s3); + + assertThat(container.endpointUrl()).isNotNull(); + assertThat(container.awsRegion()).isEqualTo("ap-southeast-2"); + assertThat(kinesis).isNotNull(); + assertThat(sns).isNotNull(); + assertThat(sqs).isNotNull(); + assertThat(s3).isNotNull(); + } + + @Test + void randomPort() { + FlociContainer container = FlociContainer.builder("latest") + .services("dynamodb") + .port(0) + .build(); + + container.startMaybe(); + assertThat(container.port()).isGreaterThan(0); + + AwsSDKv2 sdk = container.sdk2(); + createTable(sdk.dynamoDBClient()); + + container.stop(); + container.stop(); + } + + @Test + void start() { + FlociContainer container = FlociContainer.builder("latest") + .services("dynamodb") + .build(); + + container.startMaybe(); + + AwsSDKv2 sdk = container.sdk(); + createTable(sdk.dynamoDBClient()); + } + + private void useS3(S3Client s3Client) { + String bucketName = "s3test-" + System.currentTimeMillis(); + CreateBucketResponse response = s3Client.createBucket(b -> b.bucket(bucketName)); + assertThat(response).isNotNull(); + + ListBucketsResponse listBucketsResponse = s3Client.listBuckets(); + List buckets = listBucketsResponse.buckets(); + assertThat(buckets).isNotEmpty(); + } + + private void useKinesis(AwsSDKv2 sdk) { + KinesisClient kinesis = sdk.kinesisClient(); + String streamName = "hello-stream-" + System.currentTimeMillis(); + try { + kinesis.createStream(CreateStreamRequest.builder() + .streamName(streamName) + .shardCount(1) + .build()); + } catch (software.amazon.awssdk.services.kinesis.model.ResourceInUseException e) { + // ignored for reruns against a shared integration container + } catch (software.amazon.awssdk.services.kinesis.model.KinesisException e) { + if (e.statusCode() == 415) { + // Floci currently expects JSON for Kinesis while AWS SDK v2 sends CBOR by default. + // Fallback request validates the Kinesis service path for this integration test. + createStreamViaJsonProtocol(sdk.endpoint(), streamName); + } else { + throw e; + } + } + } + + private void createStreamViaJsonProtocol(URI endpoint, String streamName) { + String payload = "{\"StreamName\":\"" + streamName + "\",\"ShardCount\":1}"; + HttpRequest request = HttpRequest.newBuilder(endpoint) + .header("X-Amz-Target", "Kinesis_20131202.CreateStream") + .header("Content-Type", "application/x-amz-json-1.1") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + try { + HttpResponse response = HttpClient.newHttpClient() + .send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isIn(200, 400); + if (response.statusCode() == 400) { + assertThat(response.body()).contains("ResourceInUseException"); + } + } catch (IOException e) { + throw new IllegalStateException("Error creating Kinesis stream via JSON fallback", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted creating Kinesis stream via JSON fallback", e); + } + } + + private void useSnsSqs(AwsSDKv2 sdk) { + SqsClient sqs = sdk.sqsClient(); + SnsClient sns = sdk.snsClient(); + + String suffix = String.valueOf(System.currentTimeMillis()); + String sqsName = "SQS_NAME_" + suffix; + String snsTopicName = "SNS_TOPIC_" + suffix; + + String sqsUrl = sqs.createQueue(CreateQueueRequest.builder() + .queueName(sqsName) + .build()).queueUrl(); + + String snsTopicArn = sns.createTopic(CreateTopicRequest.builder() + .name(snsTopicName) + .build()).topicArn(); + + String sqsArn = sqs.getQueueAttributes(GetQueueAttributesRequest.builder() + .queueUrl(sqsUrl) + .attributeNames(QueueAttributeName.QUEUE_ARN) + .build()) + .attributes().get(QueueAttributeName.QUEUE_ARN); + + sns.subscribe(SubscribeRequest.builder() + .topicArn(snsTopicArn) + .protocol("sqs") + .endpoint(sqsArn) + .build()); + + sns.publish(PublishRequest.builder().topicArn(snsTopicArn).message("Hello 0").build()); + sns.publish(PublishRequest.builder().topicArn(snsTopicArn).message("Hello 1").build()); + sns.publish(PublishRequest.builder().topicArn(snsTopicArn).message("Hello 2").build()); + + ReceiveMessageResponse receiveResp = sqs.receiveMessage(ReceiveMessageRequest.builder() + .queueUrl(sqsUrl) + .maxNumberOfMessages(2) + .waitTimeSeconds(10) + .build()); + List messages = receiveResp.messages(); + assertThat(messages).isNotEmpty(); + + sns.deleteTopic(b -> b.topicArn(snsTopicArn)); + sqs.deleteQueue(b -> b.queueUrl(sqsUrl)); + } + + private void createTable(DynamoDbClient dynamoDB) { + try { + List keys = asList(KeySchemaElement.builder() + .attributeName("key") + .keyType(KeyType.HASH) + .build()); + List attrs = asList(AttributeDefinition.builder() + .attributeName("key") + .attributeType(ScalarAttributeType.S) + .build()); + + CreateTableResponse result = dynamoDB.createTable(CreateTableRequest.builder() + .attributeDefinitions(attrs) + .tableName("junk-floci-" + System.currentTimeMillis()) + .keySchema(keys) + .provisionedThroughput(throughput()) + .build()); + assertThat(result.tableDescription()).isNotNull(); + + } catch (ResourceInUseException e) { + // ignored for reruns against a shared integration container + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private ProvisionedThroughput throughput() { + return ProvisionedThroughput.builder() + .readCapacityUnits(1L) + .writeCapacityUnits(1L) + .build(); + } + + private List asList(E item) { + List list = new ArrayList<>(); + list.add(item); + return list; + } +} diff --git a/src/test/java/io/ebean/test/containers/FlociContainerTest.java b/src/test/java/io/ebean/test/containers/FlociContainerTest.java new file mode 100644 index 0000000..b365d83 --- /dev/null +++ b/src/test/java/io/ebean/test/containers/FlociContainerTest.java @@ -0,0 +1,86 @@ +package io.ebean.test.containers; + +import org.junit.jupiter.api.Test; + +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +class FlociContainerTest { + + @Test + void defaults() { + InternalConfig config = FlociContainer.builder("latest").internalConfig(); + config.setDefaultContainerName(); + + assertThat(config.platform()).isEqualTo("floci"); + assertThat(config.getPort()).isEqualTo(4566); + assertThat(config.getInternalPort()).isEqualTo(4566); + assertThat(config.getImage()).isEqualTo("hectorvent/floci:latest"); + assertThat(config.containerName()).isEqualTo("ut_floci"); + } + + @Test + void properties_with_noPrefix() { + Properties properties = new Properties(); + properties.setProperty("floci.image", "foo"); + properties.setProperty("floci.port", "7380"); + properties.setProperty("floci.containerName", "floci_junk8"); + properties.setProperty("floci.internalPort", "5379"); + properties.setProperty("floci.startMode", "baz"); + properties.setProperty("floci.shutdownMode", "bar"); + properties.setProperty("floci.awsRegion", "us-east-1"); + + FlociContainer.Builder builder = FlociContainer.builder("latest") + .properties(properties); + InternalConfig config = builder.internalConfig(); + assertProperties(config); + + FlociContainer container = builder.build(); + assertThat(container.awsRegion()).isEqualTo("us-east-1"); + } + + @Test + void properties_with_ebeanTestPrefix() { + Properties properties = new Properties(); + properties.setProperty("ebean.test.floci.image", "foo"); + properties.setProperty("ebean.test.floci.port", "7380"); + properties.setProperty("ebean.test.floci.containerName", "floci_junk8"); + properties.setProperty("ebean.test.floci.internalPort", "5379"); + properties.setProperty("ebean.test.floci.startMode", "baz"); + properties.setProperty("ebean.test.floci.shutdownMode", "bar"); + properties.setProperty("ebean.test.floci.awsRegion", "us-east-1"); + + FlociContainer.Builder builder = FlociContainer.builder("latest") + .properties(properties); + InternalConfig config = builder.internalConfig(); + config.setDefaultContainerName(); + assertProperties(config); + + FlociContainer container = builder.build(); + assertThat(container.awsRegion()).isEqualTo("us-east-1"); + } + + @Test + void sdk2() { + FlociContainer container = FlociContainer.builder("latest") + .awsRegion("us-east-1") + .build(); + + AwsSDKv2 sdk = container.sdk2(); + assertThat(container.sdk()).isNotNull(); + assertThat(sdk.endpoint()).isEqualTo(container.endpoint()); + assertThat(sdk.region().id()).isEqualTo("us-east-1"); + assertThat(sdk.basicCredentials().accessKeyId()).isEqualTo("localstack"); + assertThat(sdk.basicCredentials().secretAccessKey()).isEqualTo("localstack"); + } + + private void assertProperties(InternalConfig config) { + assertThat(config.getPort()).isEqualTo(7380); + assertThat(config.getInternalPort()).isEqualTo(5379); + assertThat(config.getImage()).isEqualTo("foo"); + assertThat(config.getStartMode()).isEqualTo(StartMode.Create); + assertThat(config.shutdownMode()).isEqualTo(StopMode.Stop); + assertThat(config.containerName()).isEqualTo("floci_junk8"); + } +}