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");
+ }
+}