You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@geode.apache.org by je...@apache.org on 2021/08/17 21:14:00 UTC

[geode] 02/02: GEODE-9156: Replace docker-compose-rule with testcontainers in geode-assembly (#6385)

This is an automated email from the ASF dual-hosted git repository.

jensdeppe pushed a commit to branch feature/redis-performance-testing
in repository https://gitbox.apache.org/repos/asf/geode.git

commit 88d2f30ad3d2ef05b5080322ef5eae5a7e0cc9e5
Author: Jens Deppe <jd...@pivotal.io>
AuthorDate: Fri Apr 30 14:10:20 2021 -0700

    GEODE-9156: Replace docker-compose-rule with testcontainers in geode-assembly (#6385)
    
    Something to note when doing SSL testing: testcontainers does not let
    you set the container name (using `container_name` in your compose.yml).
    This ultimately means that reverse IP lookups produce hostnames that
    look something like `project_service_index`. The problem is that these
    names are not RFC compliant as they contain underscores. This can break
    some aspects of SSL validation. I've had to work around this by renaming
    containers in various test classes.
    
    (cherry picked from commit 473af500ce43a4d35e08d4d750f3b5ed9186cc99)
---
 .../src/test/resources/expected-pom.xml            |  17 +-
 .../gradle/plugins/DependencyConstraints.groovy    |   5 -
 geode-assembly/build.gradle                        |   7 +-
 ...iversWithSamePortAndHostnameForSendersTest.java |  88 +++----
 .../client/sni/ClientSNICQAcceptanceTest.java      |  58 ++---
 .../sni/ClientSNIDropProxyAcceptanceTest.java      |  82 ++-----
 .../client/sni/DualServerSNIAcceptanceTest.java    |  58 +++--
 .../geode/client/sni/NotOnWindowsDockerRule.java   |  57 -----
 .../client/sni/SingleServerSNIAcceptanceTest.java  |  27 +--
 .../org/apache/geode/rules/DockerComposeRule.java  | 268 +++++++++++++++++++++
 .../org/apache/geode/cache/wan/docker-compose.yml  |  16 --
 .../org/apache/geode/client/sni/docker-compose.yml |   8 -
 .../client/sni/dual-server-docker-compose.yml      |   0
 13 files changed, 392 insertions(+), 299 deletions(-)

diff --git a/boms/geode-all-bom/src/test/resources/expected-pom.xml b/boms/geode-all-bom/src/test/resources/expected-pom.xml
index 2a3e268..ae5f59f 100644
--- a/boms/geode-all-bom/src/test/resources/expected-pom.xml
+++ b/boms/geode-all-bom/src/test/resources/expected-pom.xml
@@ -16,6 +16,11 @@
   See the License for the specific language governing permissions and
   limitations under the License.
   -->
+  <!-- This module was also published with a richer model, Gradle metadata,  -->
+  <!-- which should be used instead. Do not delete the following line which  -->
+  <!-- is to indicate to Gradle or any Gradle module metadata file consumer  -->
+  <!-- that they should prefer consuming it instead. -->
+  <!-- do_not_remove: published-with-gradle-metadata -->
   <modelVersion>4.0.0</modelVersion>
   <groupId>org.apache.geode</groupId>
   <artifactId>geode-all-bom</artifactId>
@@ -590,18 +595,6 @@
         <scope>compile</scope>
       </dependency>
       <dependency>
-        <groupId>com.palantir.docker.compose</groupId>
-        <artifactId>docker-compose-rule-core</artifactId>
-        <version>0.31.1</version>
-        <scope>compile</scope>
-      </dependency>
-      <dependency>
-        <groupId>com.palantir.docker.compose</groupId>
-        <artifactId>docker-compose-rule-junit4</artifactId>
-        <version>0.31.1</version>
-        <scope>compile</scope>
-      </dependency>
-      <dependency>
         <groupId>com.pholser</groupId>
         <artifactId>junit-quickcheck-core</artifactId>
         <version>1.0</version>
diff --git a/buildSrc/src/main/groovy/org/apache/geode/gradle/plugins/DependencyConstraints.groovy b/buildSrc/src/main/groovy/org/apache/geode/gradle/plugins/DependencyConstraints.groovy
index 40726d6..6c45313 100644
--- a/buildSrc/src/main/groovy/org/apache/geode/gradle/plugins/DependencyConstraints.groovy
+++ b/buildSrc/src/main/groovy/org/apache/geode/gradle/plugins/DependencyConstraints.groovy
@@ -193,11 +193,6 @@ class DependencyConstraints implements Plugin<Project> {
       entry('json-path')
     }
 
-    dependencySet(group: 'com.palantir.docker.compose', version: '0.31.1') {
-      entry('docker-compose-rule-core')
-      entry('docker-compose-rule-junit4')
-    }
-
     dependencySet(group: 'com.pholser', version: '1.0') {
       entry('junit-quickcheck-core')
       entry('junit-quickcheck-generators')
diff --git a/geode-assembly/build.gradle b/geode-assembly/build.gradle
index 51af352..e2fc831 100755
--- a/geode-assembly/build.gradle
+++ b/geode-assembly/build.gradle
@@ -140,10 +140,6 @@ repositories {
   maven {
     url 'https://repo.gradle.org/gradle/libs-releases'
   }
-  // docker-compose-rule is published on bintray
-  maven {
-    url 'https://dl.bintray.com/palantir/releases'
-  }
 }
 
 def webServersDir = "$buildDir/generated-resources/webservers"
@@ -269,8 +265,7 @@ dependencies {
   // don't have it be the same version as the outer gradle version.
   acceptanceTestImplementation('org.gradle:gradle-tooling-api:5.1.1')
 
-  acceptanceTestImplementation('com.palantir.docker.compose:docker-compose-rule-core')
-  acceptanceTestImplementation('com.palantir.docker.compose:docker-compose-rule-junit4')
+  acceptanceTestImplementation('org.testcontainers:testcontainers')
 
   uiTestImplementation(project(':geode-core'))
   uiTestImplementation(project(':geode-dunit')) {
diff --git a/geode-assembly/src/acceptanceTest/java/org/apache/geode/cache/wan/SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest.java b/geode-assembly/src/acceptanceTest/java/org/apache/geode/cache/wan/SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest.java
index 7247362..86a6cc6 100644
--- a/geode-assembly/src/acceptanceTest/java/org/apache/geode/cache/wan/SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest.java
+++ b/geode-assembly/src/acceptanceTest/java/org/apache/geode/cache/wan/SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest.java
@@ -14,8 +14,6 @@
  */
 package org.apache.geode.cache.wan;
 
-import static com.palantir.docker.compose.execution.DockerComposeExecArgument.arguments;
-import static com.palantir.docker.compose.execution.DockerComposeExecOption.options;
 import static org.apache.geode.cache.Region.SEPARATOR;
 import static org.apache.geode.distributed.ConfigurationProperties.DISTRIBUTED_SYSTEM_ID;
 import static org.apache.geode.distributed.ConfigurationProperties.LOCATORS;
@@ -26,7 +24,6 @@ import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
 import java.io.File;
-import java.io.IOException;
 import java.net.URL;
 import java.util.HashMap;
 import java.util.Map;
@@ -34,7 +31,6 @@ import java.util.Properties;
 import java.util.StringTokenizer;
 import java.util.Vector;
 
-import com.palantir.docker.compose.DockerComposeRule;
 import org.junit.BeforeClass;
 import org.junit.ClassRule;
 import org.junit.Rule;
@@ -48,12 +44,12 @@ import org.apache.geode.cache.Region;
 import org.apache.geode.cache.RegionFactory;
 import org.apache.geode.cache.RegionShortcut;
 import org.apache.geode.cache.persistence.PartitionOfflineException;
-import org.apache.geode.client.sni.NotOnWindowsDockerRule;
 import org.apache.geode.distributed.Locator;
 import org.apache.geode.internal.cache.ForceReattemptException;
 import org.apache.geode.internal.cache.PoolStats;
 import org.apache.geode.internal.cache.wan.AbstractGatewaySender;
 import org.apache.geode.internal.cache.wan.InternalGatewaySenderFactory;
+import org.apache.geode.rules.DockerComposeRule;
 import org.apache.geode.test.dunit.IgnoredException;
 import org.apache.geode.test.dunit.VM;
 import org.apache.geode.test.dunit.rules.DistributedRule;
@@ -63,20 +59,17 @@ import org.apache.geode.test.junit.categories.WanTest;
 /**
  * These tests use two Geode sites:
  *
- * - One site (the remote one) consisting of a 2-server, 1-locator Geode cluster.
- * The servers host a partition region (region-wan) and have gateway senders to receive events
- * from the other site with the same value for hostname-for-senders and listening on the
- * same port (2324).
- * The servers and locator run each inside a Docker container and are not route-able
- * from the host (where this JUnit test is running).
- * Another Docker container is running the HAProxy image and it's set up as a TCP load balancer.
- * The other site connects to the locator and to the gateway receivers via the
- * TCP load balancer that forwards traffic directed to the 20334 port to the locator and
- * traffic directed to the 2324 port to the receivers in a round robin fashion.
+ * - One site (the remote one) consisting of a 2-server, 1-locator Geode cluster. The servers host a
+ * partition region (region-wan) and have gateway senders to receive events from the other site with
+ * the same value for hostname-for-senders and listening on the same port (2324). The servers and
+ * locator run each inside a Docker container and are not route-able from the host (where this JUnit
+ * test is running). Another Docker container is running the HAProxy image and it's set up as a TCP
+ * load balancer. The other site connects to the locator and to the gateway receivers via the TCP
+ * load balancer that forwards traffic directed to the 20334 port to the locator and traffic
+ * directed to the 2324 port to the receivers in a round robin fashion.
  *
- * - Another site consisting of a 1-server, 1-locator Geode cluster.
- * The server hosts a partition region (region-wan) and has a gateway receiver
- * to send events to the remote site.
+ * - Another site consisting of a 1-server, 1-locator Geode cluster. The server hosts a partition
+ * region (region-wan) and has a gateway receiver to send events to the remote site.
  */
 @Category({WanTest.class})
 public class SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest {
@@ -89,12 +82,12 @@ public class SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest {
       SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest.class
           .getResource("docker-compose.yml");
 
-  // Docker compose does not work on windows in CI. Ignore this test on windows
-  // Using a RuleChain to make sure we ignore the test before the rule comes into play
   @ClassRule
-  public static NotOnWindowsDockerRule docker =
-      new NotOnWindowsDockerRule(() -> DockerComposeRule.builder()
-          .file(DOCKER_COMPOSE_PATH.getPath()).build());
+  public static DockerComposeRule docker = new DockerComposeRule.Builder()
+      .file(DOCKER_COMPOSE_PATH.getPath())
+      .service("haproxy", 20334)
+      .service("haproxy", 2324)
+      .build();
 
   @Rule
   public DistributedRule distributedRule =
@@ -103,17 +96,17 @@ public class SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest {
   @BeforeClass
   public static void beforeClass() throws Exception {
     // Start locator
-    docker.get().exec(options("-T"), "locator",
-        arguments("gfsh", "run", "--file=/geode/scripts/geode-starter-locator.gfsh"));
+    docker.execForService("locator", "gfsh", "run",
+        "--file=/geode/scripts/geode-starter-locator.gfsh");
     // Start server1
-    docker.get().exec(options("-T"), "server1",
-        arguments("gfsh", "run", "--file=/geode/scripts/geode-starter-server1.gfsh"));
+    docker.execForService("server1", "gfsh", "run",
+        "--file=/geode/scripts/geode-starter-server1.gfsh");
     // Start server2
-    docker.get().exec(options("-T"), "server2",
-        arguments("gfsh", "run", "--file=/geode/scripts/geode-starter-server2.gfsh"));
+    docker.execForService("server2", "gfsh", "run",
+        "--file=/geode/scripts/geode-starter-server2.gfsh");
     // Create partition region and gateway receiver
-    docker.get().exec(options("-T"), "locator",
-        arguments("gfsh", "run", "--file=/geode/scripts/geode-starter-create.gfsh"));
+    docker.execForService("locator", "gfsh", "run",
+        "--file=/geode/scripts/geode-starter-create.gfsh");
   }
 
   public SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest() {
@@ -121,18 +114,17 @@ public class SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest {
   }
 
   /**
-   * The aim of this test is verify that when several gateway receivers in a remote site
-   * share the same port and hostname-for-senders, the pings sent from the gateway senders
-   * reach the right gateway receiver and not just any of the receivers. Failure to do this
-   * may result in the closing of connections by a gateway receiver for not having
-   * received the ping in time.
+   * The aim of this test is verify that when several gateway receivers in a remote site share the
+   * same port and hostname-for-senders, the pings sent from the gateway senders reach the right
+   * gateway receiver and not just any of the receivers. Failure to do this may result in the
+   * closing of connections by a gateway receiver for not having received the ping in time.
    */
   @Test
   public void testPingsToReceiversWithSamePortAndHostnameForSendersReachTheRightReceivers()
       throws InterruptedException {
     String senderId = "ln";
     String regionName = "region-wan";
-    final int remoteLocPort = 20334;
+    final int remoteLocPort = docker.getExternalPortForService("haproxy", 20334);
 
     int locPort = createLocator(VM.getVM(0), 1, remoteLocPort);
 
@@ -168,7 +160,7 @@ public class SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest {
   public void testSerialGatewaySenderThreadsConnectToSameReceiver() {
     String senderId = "ln";
     String regionName = "region-wan";
-    final int remoteLocPort = 20334;
+    final int remoteLocPort = docker.getExternalPortForService("haproxy", 20334);
 
     int locPort = createLocator(VM.getVM(0), 1, remoteLocPort);
 
@@ -188,7 +180,7 @@ public class SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest {
   @Test
   public void testTwoSendersWithSameIdShouldUseSameValueForEnforceThreadsConnectToSameServer() {
     String senderId = "ln";
-    final int remoteLocPort = 20334;
+    final int remoteLocPort = docker.getExternalPortForService("haproxy", 20334);
 
     int locPort = createLocator(VM.getVM(0), 1, remoteLocPort);
 
@@ -225,20 +217,9 @@ public class SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest {
     return true;
   }
 
-
   private String runListGatewayReceiversCommandInServer(int serverN) {
-    String result = "";
-    try {
-      result = docker.get().exec(options("-T"), "locator",
-          arguments("gfsh", "run",
-              "--file=/geode/scripts/geode-list-gateway-receivers-server" + serverN + ".gfsh"));
-    } catch (IOException e) {
-      e.printStackTrace();
-    } catch (InterruptedException e) {
-      e.printStackTrace();
-    } finally {
-      return result;
-    }
+    return docker.execForService("locator", "gfsh", "run",
+        "--file=/geode/scripts/geode-list-gateway-receivers-server" + serverN + ".gfsh");
   }
 
   private Vector<String> parseSendersConnectedFromGfshOutput(String gfshOutput) {
@@ -293,7 +274,8 @@ public class SeveralGatewayReceiversWithSamePortAndHostnameForSendersTest {
   public static void createGatewaySender(VM vm, String dsName, int remoteDsId,
       boolean isParallel, Integer batchSize,
       int numDispatchers,
-      GatewaySender.OrderPolicy orderPolicy, boolean enforceThreadsConnectToSameReceiver) {
+      GatewaySender.OrderPolicy orderPolicy,
+      boolean enforceThreadsConnectToSameReceiver) {
     vm.invoke(() -> {
       final IgnoredException exln = IgnoredException.addIgnoredException("Could not connect");
       try {
diff --git a/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/ClientSNICQAcceptanceTest.java b/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/ClientSNICQAcceptanceTest.java
index 149667f..b6635c9 100644
--- a/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/ClientSNICQAcceptanceTest.java
+++ b/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/ClientSNICQAcceptanceTest.java
@@ -14,8 +14,6 @@
  */
 package org.apache.geode.client.sni;
 
-import static com.palantir.docker.compose.execution.DockerComposeExecArgument.arguments;
-import static com.palantir.docker.compose.execution.DockerComposeExecOption.options;
 import static org.apache.geode.cache.Region.SEPARATOR;
 import static org.apache.geode.distributed.ConfigurationProperties.SSL_ENABLED_COMPONENTS;
 import static org.apache.geode.distributed.ConfigurationProperties.SSL_ENDPOINT_IDENTIFICATION_ENABLED;
@@ -27,19 +25,16 @@ import static org.apache.geode.test.awaitility.GeodeAwaitility.await;
 import static org.apache.geode.test.util.ResourceUtils.createTempFileFromResource;
 import static org.assertj.core.api.Assertions.assertThat;
 
-import java.io.IOException;
 import java.net.URL;
 import java.util.Properties;
 import java.util.concurrent.atomic.AtomicInteger;
 
-import com.palantir.docker.compose.DockerComposeRule;
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.ClassRule;
 import org.junit.Test;
-import org.junit.rules.TestRule;
 
 import org.apache.geode.cache.Operation;
 import org.apache.geode.cache.Region;
@@ -57,22 +52,18 @@ import org.apache.geode.cache.query.CqListener;
 import org.apache.geode.cache.query.CqQuery;
 import org.apache.geode.cache.query.QueryService;
 import org.apache.geode.cache.query.RegionNotFoundException;
-import org.apache.geode.test.junit.rules.IgnoreOnWindowsRule;
+import org.apache.geode.rules.DockerComposeRule;
 
 public class ClientSNICQAcceptanceTest {
 
   private static final URL DOCKER_COMPOSE_PATH =
       ClientSNICQAcceptanceTest.class.getResource("docker-compose.yml");
 
-  // Docker compose does not work on windows in CI. Ignore this test on windows
-  // Using a RuleChain to make sure we ignore the test before the rule comes into play
   @ClassRule
-  public static TestRule ignoreOnWindowsRule = new IgnoreOnWindowsRule();
-
-  @ClassRule
-  public static NotOnWindowsDockerRule docker =
-      new NotOnWindowsDockerRule(() -> DockerComposeRule.builder()
-          .file(DOCKER_COMPOSE_PATH.getPath()).build());
+  public static DockerComposeRule docker = new DockerComposeRule.Builder()
+      .file(DOCKER_COMPOSE_PATH.getPath())
+      .service("haproxy", 15443)
+      .build();
 
   private CqQuery cqTracker;
 
@@ -80,15 +71,13 @@ public class ClientSNICQAcceptanceTest {
   AtomicInteger eventUpdateCounter = new AtomicInteger(0);
   private ClientCache cache;
   private Region<String, Integer> region;
+  private static String trustStorePath;
 
   class SNICQListener implements CqListener {
 
-
     @Override
     public void onEvent(CqEvent cqEvent) {
       Operation queryOperation = cqEvent.getQueryOperation();
-
-
       if (queryOperation.isUpdate()) {
         eventUpdateCounter.incrementAndGet();
       } else if (queryOperation.isCreate()) {
@@ -102,25 +91,25 @@ public class ClientSNICQAcceptanceTest {
     }
   }
 
-  private static String trustStorePath;
-
   @BeforeClass
-  public static void beforeClass() throws IOException, InterruptedException {
+  public static void beforeClass() {
     trustStorePath =
         createTempFileFromResource(ClientSNICQAcceptanceTest.class,
             "geode-config/truststore.jks")
                 .getAbsolutePath();
-    docker.get().exec(options("-T"), "geode",
-        arguments("gfsh", "run", "--file=/geode/scripts/geode-starter.gfsh"));
-
+    docker.loggingExecForService("geode",
+        "gfsh", "run", "--file=/geode/scripts/geode-starter.gfsh");
   }
 
   @AfterClass
-  public static void afterClass() throws Exception {
-    String output =
-        docker.get().exec(options("-T"), "geode",
-            arguments("cat", "server-dolores/server-dolores.log"));
-    System.out.println("Server log file--------------------------------\n" + output);
+  public static void afterClass() {
+    printlog("locator-maeve");
+    printlog("server-dolores");
+  }
+
+  private static void printlog(String name) {
+    String output = docker.execForService("geode", "cat", name + "/" + name + ".log");
+    System.out.println(name + " log file--------------------------------\n" + output);
   }
 
   @Before
@@ -134,10 +123,7 @@ public class ClientSNICQAcceptanceTest {
     gemFireProps.setProperty(SSL_TRUSTSTORE_PASSWORD, "geode");
     gemFireProps.setProperty(SSL_ENDPOINT_IDENTIFICATION_ENABLED, "true");
 
-    int proxyPort = docker.get().containers()
-        .container("haproxy")
-        .port(15443)
-        .getExternalPort();
+    int proxyPort = docker.getExternalPortForService("haproxy", 15443);
     cache = new ClientCacheFactory(gemFireProps)
         .addPoolLocator("locator-maeve", 10334)
         .setPoolSocketFactory(ProxySocketFactories.sni("localhost",
@@ -180,10 +166,10 @@ public class ClientSNICQAcceptanceTest {
     // the CQ has been closed. StatArchiveReader has a main() that we can use to get a printout
     // of stat values
     await().untilAsserted(() -> {
-      String stats = docker.get().exec(options("-T"), "geode",
-          arguments("java", "-cp", "/geode/lib/geode-dependencies.jar",
-              "org.apache.geode.internal.statistics.StatArchiveReader",
-              "stat", "server-dolores/statArchive.gfs", "CqServiceStats.numCqsClosed"));
+      String stats = docker.execForService("geode",
+          "java", "-cp", "/geode/lib/geode-dependencies.jar",
+          "org.apache.geode.internal.statistics.StatArchiveReader",
+          "stat", "server-dolores/statArchive.gfs", "CqServiceStats.numCqsClosed");
       // the stat should transition from zero to one at some point
       assertThat(stats).contains("0.0 1.0");
     });
diff --git a/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/ClientSNIDropProxyAcceptanceTest.java b/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/ClientSNIDropProxyAcceptanceTest.java
index 309f381..095697a 100644
--- a/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/ClientSNIDropProxyAcceptanceTest.java
+++ b/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/ClientSNIDropProxyAcceptanceTest.java
@@ -14,8 +14,6 @@
  */
 package org.apache.geode.client.sni;
 
-import static com.palantir.docker.compose.execution.DockerComposeExecArgument.arguments;
-import static com.palantir.docker.compose.execution.DockerComposeExecOption.options;
 import static org.apache.geode.distributed.ConfigurationProperties.SSL_ENABLED_COMPONENTS;
 import static org.apache.geode.distributed.ConfigurationProperties.SSL_ENDPOINT_IDENTIFICATION_ENABLED;
 import static org.apache.geode.distributed.ConfigurationProperties.SSL_KEYSTORE_TYPE;
@@ -31,9 +29,6 @@ import java.io.IOException;
 import java.net.URL;
 import java.util.Properties;
 
-import com.palantir.docker.compose.DockerComposeRule;
-import com.palantir.docker.compose.execution.DockerComposeRunArgument;
-import com.palantir.docker.compose.execution.DockerComposeRunOption;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.ClassRule;
@@ -45,18 +40,18 @@ import org.apache.geode.cache.client.ClientCacheFactory;
 import org.apache.geode.cache.client.ClientRegionShortcut;
 import org.apache.geode.cache.client.NoAvailableLocatorsException;
 import org.apache.geode.cache.client.proxy.ProxySocketFactories;
+import org.apache.geode.rules.DockerComposeRule;
 
 public class ClientSNIDropProxyAcceptanceTest {
 
   private static final URL DOCKER_COMPOSE_PATH =
       ClientSNIDropProxyAcceptanceTest.class.getResource("docker-compose.yml");
 
-  // Docker compose does not work on windows in CI. Ignore this test on windows
-  // Using a RuleChain to make sure we ignore the test before the rule comes into play
   @ClassRule
-  public static NotOnWindowsDockerRule docker =
-      new NotOnWindowsDockerRule(() -> DockerComposeRule.builder()
-          .file(DOCKER_COMPOSE_PATH.getPath()).build());
+  public static DockerComposeRule docker = new DockerComposeRule.Builder()
+      .file(DOCKER_COMPOSE_PATH.getPath())
+      .service("haproxy", 15443)
+      .build();
 
   private ClientCache cache;
 
@@ -69,8 +64,7 @@ public class ClientSNIDropProxyAcceptanceTest {
         createTempFileFromResource(ClientSNIDropProxyAcceptanceTest.class,
             "geode-config/truststore.jks")
                 .getAbsolutePath();
-    docker.get().exec(options("-T"), "geode",
-        arguments("gfsh", "run", "--file=/geode/scripts/geode-starter.gfsh"));
+    docker.execForService("geode", "gfsh", "run", "--file=/geode/scripts/geode-starter.gfsh");
   }
 
   @After
@@ -79,22 +73,20 @@ public class ClientSNIDropProxyAcceptanceTest {
   }
 
   @Test
-  public void performSimpleOperationsDropSNIProxy()
-      throws IOException,
-      InterruptedException {
+  public void performSimpleOperationsDropSNIProxy() {
     final Region<String, Integer> region = getRegion();
 
     region.put("Roy Hobbs", 9);
     assertThat(region.get("Roy Hobbs")).isEqualTo(9);
 
-    stopProxy();
+    pauseProxy();
 
     assertThatThrownBy(() -> region.get("Roy Hobbs"))
         .isInstanceOf(NoAvailableLocatorsException.class)
         .hasMessageContaining("Unable to connect to any locators in the list");
 
 
-    restartProxy();
+    unpauseProxy();
 
     await().untilAsserted(() -> assertThat(region.get("Roy Hobbs")).isEqualTo(9));
 
@@ -109,53 +101,12 @@ public class ClientSNIDropProxyAcceptanceTest {
 
   }
 
-  private void stopProxy() throws IOException, InterruptedException {
-    docker.get().containers()
-        .container("haproxy")
-        .stop();
+  private void pauseProxy() {
+    docker.pauseService("haproxy");
   }
 
-  private void restartProxy() throws IOException, InterruptedException {
-    restartProxyOnPreviousPort();
-    // Leave this commented here in case you need it for troubleshooting
-    // restartProxyOnDockerComposePort();
-  }
-
-  /**
-   * Use this variant to (re)start the container on whatever port(s) is specified in
-   * docker-compose.yml. Usually that would look something like:
-   *
-   * ports:
-   * - "15443:15443"
-   *
-   * Leave this unused method here for troubleshooting.
-   */
-  private void restartProxyOnDockerComposePort() throws IOException, InterruptedException {
-    docker.get().containers()
-        .container("haproxy")
-        .start();
-  }
-
-  /**
-   * Use this variant to (re)start the container whatever host port it was bound to before
-   * it was stopped. Usually you'll want the ports spec in docker-compose.yml to look like
-   * this when using this method (allowing Docker to initially choose a random host port
-   * to bind to):
-   *
-   * ports:
-   * - "15443"
-   */
-  private void restartProxyOnPreviousPort() throws IOException, InterruptedException {
-    /*
-     * docker-compose run needs -d to daemonize the container (fork the process and return control
-     * to this process). The first time we ran the HAproxy container, we let it pick the host port
-     * to bind on. This time, we want it to bind to that same host port (proxyPort). The syntax
-     * for the --publish argument is <host-port>:<internal-port> in this case.
-     */
-    docker.get().run(
-        DockerComposeRunOption.options("-d", "--publish", String.format("%d:15443", proxyPort)),
-        "haproxy",
-        DockerComposeRunArgument.arguments("haproxy", "-f", "/usr/local/etc/haproxy/haproxy.cfg"));
+  private void unpauseProxy() {
+    docker.unpauseService("haproxy");
   }
 
   public Region<String, Integer> getRegion() {
@@ -168,10 +119,7 @@ public class ClientSNIDropProxyAcceptanceTest {
     gemFireProps.setProperty(SSL_TRUSTSTORE_PASSWORD, "geode");
     gemFireProps.setProperty(SSL_ENDPOINT_IDENTIFICATION_ENABLED, "true");
 
-    proxyPort = docker.get().containers()
-        .container("haproxy")
-        .port(15443)
-        .getExternalPort();
+    proxyPort = docker.getExternalPortForService("haproxy", 15443);
 
     ensureCacheClosed();
 
@@ -181,7 +129,7 @@ public class ClientSNIDropProxyAcceptanceTest {
             proxyPort))
         .setPoolSubscriptionEnabled(true)
         .create();
-    return (Region<String, Integer>) cache.<String, Integer>createClientRegionFactory(
+    return cache.<String, Integer>createClientRegionFactory(
         ClientRegionShortcut.PROXY)
         .create("jellyfish");
   }
diff --git a/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/DualServerSNIAcceptanceTest.java b/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/DualServerSNIAcceptanceTest.java
index 7b08be2..606f954 100644
--- a/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/DualServerSNIAcceptanceTest.java
+++ b/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/DualServerSNIAcceptanceTest.java
@@ -14,8 +14,6 @@
  */
 package org.apache.geode.client.sni;
 
-import static com.palantir.docker.compose.execution.DockerComposeExecArgument.arguments;
-import static com.palantir.docker.compose.execution.DockerComposeExecOption.options;
 import static org.apache.geode.distributed.ConfigurationProperties.SSL_ENABLED_COMPONENTS;
 import static org.apache.geode.distributed.ConfigurationProperties.SSL_ENDPOINT_IDENTIFICATION_ENABLED;
 import static org.apache.geode.distributed.ConfigurationProperties.SSL_KEYSTORE_TYPE;
@@ -29,8 +27,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import java.net.URL;
 import java.util.Properties;
 
-import com.palantir.docker.compose.DockerComposeRule;
 import org.junit.After;
+import org.junit.AfterClass;
 import org.junit.BeforeClass;
 import org.junit.ClassRule;
 import org.junit.Test;
@@ -41,10 +39,11 @@ import org.apache.geode.cache.client.ClientCache;
 import org.apache.geode.cache.client.ClientCacheFactory;
 import org.apache.geode.cache.client.ClientRegionShortcut;
 import org.apache.geode.cache.client.proxy.ProxySocketFactories;
+import org.apache.geode.rules.DockerComposeRule;
 
 /**
- * These tests run against a 2-server, 1-locator Geode cluster. The servers and locator run inside
- * a (single) Docker container and are not route-able from the host (where this JUnit test is
+ * These tests run against a 2-server, 1-locator Geode cluster. The servers and locator run inside a
+ * (single) Docker container and are not route-able from the host (where this JUnit test is
  * running). Another Docker container is running the HAProxy image and it's set up as an SNI
  * gateway. The test connects to the gateway via SNI and the gateway (in one Docker container)
  * forwards traffic to Geode members (running in the other Docker container).
@@ -53,29 +52,41 @@ import org.apache.geode.cache.client.proxy.ProxySocketFactories;
  * groups: group-dolores, and group-clementine, respectively. Also each server has a separate
  * REPLICATE region on it: region-dolores, and region-clementine, respectively.
  *
- * This test creates a connection pool to each group in turn. For that group, the test verifies
- * it can update data to the region of interest. There's also a pair of negative tests that verify
- * the correct exception is thrown when an attempt is made to operate on an unreachable region.
+ * This test creates a connection pool to each group in turn. For that group, the test verifies it
+ * can update data to the region of interest. There's also a pair of negative tests that verify the
+ * correct exception is thrown when an attempt is made to operate on an unreachable region.
  */
 public class DualServerSNIAcceptanceTest {
 
   private static final URL DOCKER_COMPOSE_PATH =
-      SingleServerSNIAcceptanceTest.class.getResource("docker-compose.yml");
+      DualServerSNIAcceptanceTest.class.getResource("dual-server-docker-compose.yml");
 
-  // Docker compose does not work on windows in CI. Ignore this test on windows
-  // Using a RuleChain to make sure we ignore the test before the rule comes into play
   @ClassRule
-  public static NotOnWindowsDockerRule docker =
-      new NotOnWindowsDockerRule(() -> DockerComposeRule.builder()
-          .file(DOCKER_COMPOSE_PATH.getPath()).build());
+  public static DockerComposeRule docker = new DockerComposeRule.Builder()
+      .file(DOCKER_COMPOSE_PATH.getPath())
+      .service("haproxy", 15443)
+      .build();
 
   private static Properties clientCacheProperties;
   private ClientCache cache;
 
   @BeforeClass
-  public static void beforeClass() throws Exception {
-    docker.get().exec(options("-T"), "geode",
-        arguments("gfsh", "run", "--file=/geode/scripts/geode-starter-2.gfsh"));
+  public static void beforeClass() {
+    docker.setContainerName("locator-maeve", "locator-maeve");
+    docker.setContainerName("server-dolores", "server-dolores");
+    docker.setContainerName("server-clementine", "server-clementine");
+
+    docker.loggingExecForService("locator-maeve",
+        "gfsh", "run", "--file=/geode/scripts/locator-maeve.gfsh");
+
+    docker.loggingExecForService("server-dolores",
+        "gfsh", "run", "--file=/geode/scripts/server-dolores.gfsh");
+
+    docker.loggingExecForService("server-clementine",
+        "gfsh", "run", "--file=/geode/scripts/server-clementine.gfsh");
+
+    docker.loggingExecForService("locator-maeve",
+        "gfsh", "run", "--file=/geode/scripts/create-regions.gfsh");
 
     final String trustStorePath =
         createTempFileFromResource(SingleServerSNIAcceptanceTest.class,
@@ -97,6 +108,14 @@ public class DualServerSNIAcceptanceTest {
     ensureCacheClosed();
   }
 
+  @AfterClass
+  public static void afterClass() {
+    // if you need to capture logs for one of the processes use this pattern:
+    // String output =
+    // docker.execForService("locator-maeve", "cat", "locator-maeve/locator-maeve.log");
+    // System.out.println("Locator log file--------------------------------\n" + output);
+  }
+
   @Test
   public void successfulRoutingTest() {
     verifyPutAndGet("group-dolores", "region-dolores");
@@ -135,10 +154,7 @@ public class DualServerSNIAcceptanceTest {
    * modifies cache field as a side-effect
    */
   private Region<String, String> getRegion(final String groupName, final String regionName) {
-    final int proxyPort = docker.get().containers()
-        .container("haproxy")
-        .port(15443)
-        .getExternalPort();
+    final int proxyPort = docker.getExternalPortForService("haproxy", 15443);
     ensureCacheClosed();
     cache = new ClientCacheFactory(clientCacheProperties)
         .addPoolLocator("locator-maeve", 10334)
diff --git a/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/NotOnWindowsDockerRule.java b/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/NotOnWindowsDockerRule.java
deleted file mode 100644
index 0e90fc1..0000000
--- a/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/NotOnWindowsDockerRule.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
- * agreements. See the NOTICE file distributed with this work for additional information regarding
- * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance with the License. You may obtain a
- * copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the License
- * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
- * or implied. See the License for the specific language governing permissions and limitations under
- * the License.
- */
-package org.apache.geode.client.sni;
-
-import java.util.function.Supplier;
-
-import com.palantir.docker.compose.DockerComposeRule;
-import org.apache.commons.lang3.SystemUtils;
-import org.junit.Assume;
-import org.junit.rules.ExternalResource;
-
-/**
- * A rule that wraps {@link DockerComposeRule} in such a way that docker
- * tests will be ignored on the windows platform.
- *
- * Provide the code to build a DockerComposeRule in the constructor to this rule,
- * and access the rule later in your test with the {@link #get()} method.
- */
-public class NotOnWindowsDockerRule extends ExternalResource {
-
-  private final Supplier<DockerComposeRule> dockerRuleSupplier;
-  private DockerComposeRule docker;
-
-  public NotOnWindowsDockerRule(Supplier<DockerComposeRule> dockerRuleSupplier) {
-    this.dockerRuleSupplier = dockerRuleSupplier;
-  }
-
-  @Override
-  protected void before() throws Throwable {
-    Assume.assumeFalse(SystemUtils.IS_OS_WINDOWS);
-    this.docker = dockerRuleSupplier.get();
-    docker.before();
-  }
-
-  @Override
-  protected void after() {
-    if (docker != null) {
-      docker.after();
-    }
-  }
-
-  public DockerComposeRule get() {
-    return docker;
-  }
-}
diff --git a/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/SingleServerSNIAcceptanceTest.java b/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/SingleServerSNIAcceptanceTest.java
index a08ce3c..5c78ea2 100644
--- a/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/SingleServerSNIAcceptanceTest.java
+++ b/geode-assembly/src/acceptanceTest/java/org/apache/geode/client/sni/SingleServerSNIAcceptanceTest.java
@@ -14,8 +14,6 @@
  */
 package org.apache.geode.client.sni;
 
-import static com.palantir.docker.compose.execution.DockerComposeExecArgument.arguments;
-import static com.palantir.docker.compose.execution.DockerComposeExecOption.options;
 import static org.apache.geode.cache.Region.SEPARATOR;
 import static org.apache.geode.distributed.ConfigurationProperties.SSL_ENABLED_COMPONENTS;
 import static org.apache.geode.distributed.ConfigurationProperties.SSL_ENDPOINT_IDENTIFICATION_ENABLED;
@@ -26,14 +24,12 @@ import static org.apache.geode.distributed.ConfigurationProperties.SSL_TRUSTSTOR
 import static org.apache.geode.test.util.ResourceUtils.createTempFileFromResource;
 import static org.assertj.core.api.Assertions.assertThat;
 
-import java.io.IOException;
 import java.net.URL;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Properties;
 import java.util.Set;
 
-import com.palantir.docker.compose.DockerComposeRule;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
 import org.junit.ClassRule;
@@ -46,6 +42,7 @@ import org.apache.geode.cache.client.ClientRegionShortcut;
 import org.apache.geode.cache.client.proxy.ProxySocketFactories;
 import org.apache.geode.cache.query.SelectResults;
 import org.apache.geode.internal.cache.tier.sockets.BaseCommand;
+import org.apache.geode.rules.DockerComposeRule;
 
 /**
  * This test runs against a 1-server, 1-locator Geode cluster. The server and locator run inside
@@ -62,22 +59,20 @@ public class SingleServerSNIAcceptanceTest {
   private static final URL DOCKER_COMPOSE_PATH =
       SingleServerSNIAcceptanceTest.class.getResource("docker-compose.yml");
 
-  // Docker compose does not work on windows in CI. Ignore this test on windows
-  // Using a RuleChain to make sure we ignore the test before the rule comes into play
   @ClassRule
-  public static NotOnWindowsDockerRule docker =
-      new NotOnWindowsDockerRule(() -> DockerComposeRule.builder()
-          .file(DOCKER_COMPOSE_PATH.getPath()).build());
+  public static DockerComposeRule docker = new DockerComposeRule.Builder()
+      .file(DOCKER_COMPOSE_PATH.getPath())
+      .service("haproxy", 15443)
+      .build();
 
   private static ClientCache cache;
   private static Region<String, String> region;
   private static Map<String, String> bulkData;
 
   @BeforeClass
-  public static void beforeClass() throws IOException, InterruptedException {
+  public static void beforeClass() {
     // start up server/locator processes and initialize the server cache
-    docker.get().exec(options("-T"), "geode",
-        arguments("gfsh", "run", "--file=/geode/scripts/geode-starter.gfsh"));
+    docker.execForService("geode", "gfsh", "run", "--file=/geode/scripts/geode-starter.gfsh");
 
     final String trustStorePath =
         createTempFileFromResource(SingleServerSNIAcceptanceTest.class,
@@ -104,8 +99,7 @@ public class SingleServerSNIAcceptanceTest {
 
   @AfterClass
   public static void afterClass() throws Exception {
-    String logs = docker.get().exec(options("-T"), "geode",
-        arguments("cat", "server-dolores/server-dolores.log"));
+    String logs = docker.execForService("geode", "cat", "server-dolores/server-dolores.log");
     System.out.println("server logs------------------------------------------");
     System.out.println(logs);
 
@@ -191,10 +185,7 @@ public class SingleServerSNIAcceptanceTest {
   }
 
   protected static ClientCache getClientCache(Properties properties) {
-    int proxyPort = docker.get().containers()
-        .container("haproxy")
-        .port(15443)
-        .getExternalPort();
+    int proxyPort = docker.getExternalPortForService("haproxy", 15443);
     return new ClientCacheFactory(properties)
         .addPoolLocator("locator-maeve", 10334)
         .setPoolSocketFactory(ProxySocketFactories.sni("localhost",
diff --git a/geode-assembly/src/acceptanceTest/java/org/apache/geode/rules/DockerComposeRule.java b/geode-assembly/src/acceptanceTest/java/org/apache/geode/rules/DockerComposeRule.java
new file mode 100644
index 0000000..25d1765
--- /dev/null
+++ b/geode-assembly/src/acceptanceTest/java/org/apache/geode/rules/DockerComposeRule.java
@@ -0,0 +1,268 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License. You may obtain a
+ * copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package org.apache.geode.rules;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.github.dockerjava.api.DockerClient;
+import com.github.dockerjava.api.command.ExecCreateCmdResponse;
+import org.apache.logging.log4j.Logger;
+import org.junit.rules.ExternalResource;
+import org.junit.rules.RuleChain;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+import org.testcontainers.DockerClientFactory;
+import org.testcontainers.containers.Container;
+import org.testcontainers.containers.ContainerState;
+import org.testcontainers.containers.DockerComposeContainer;
+import org.testcontainers.containers.output.BaseConsumer;
+import org.testcontainers.containers.output.FrameConsumerResultCallback;
+import org.testcontainers.containers.output.OutputFrame;
+import org.testcontainers.containers.output.ToStringConsumer;
+
+import org.apache.geode.logging.internal.log4j.api.LogService;
+import org.apache.geode.test.junit.rules.IgnoreOnWindowsRule;
+
+/**
+ * This class assists in managing the lifecycle of a cluster, launched via a docker-compose
+ * configuration file, for testing. For example:
+ *
+ * <pre>
+ *
+ * &#64;ClassRule
+ * public static DockerComposeRule cluster = new DockerComposeRule().Builder()
+ *     .file("/home/bob/test/docker-compose.yml")
+ *     .service("haproxy", 15223)
+ *     .build();
+ *
+ * // Get the exposed port for haproxy
+ * cluster.getExternalPortForService("haproxy", 15223);
+ * </pre>
+ *
+ * Some limitations are as follows:
+ * <ul>
+ * <li>{@code testcontainers} does not support using {@code container_name:} attributes. If you
+ * need your container to be named explicitly, use {@link DockerComposeRule#setContainerName}</li>
+ * <li>Do not use the {@code expose} directives in your yaml file. Instead use
+ * {@link DockerComposeRule.Builder#service}
+ * to expose the relevant service and port.</li>
+ * <li>For now, this rule only handles a single instance of a service.</li>
+ * </ul>
+ *
+ * @see <a href=
+ *      "https://www.testcontainers.org/modules/docker_compose/">https://www.testcontainers.org/modules/docker_compose/</a>
+ */
+public class DockerComposeRule extends ExternalResource {
+
+  private static final Logger logger = LogService.getLogger();
+
+  private final RuleChain delegate;
+  private final String composeFile;
+  private final Map<String, List<Integer>> exposedServices;
+  private DockerComposeContainer<?> composeContainer;
+
+  public DockerComposeRule(String composeFile, Map<String, List<Integer>> exposedServices) {
+    this.composeFile = composeFile;
+    this.exposedServices = exposedServices;
+
+    // Docker compose does not work on windows in CI. Ignore this test on windows using a
+    // RuleChain to make sure we ignore the test before the rule comes into play.
+    delegate = RuleChain.outerRule(new IgnoreOnWindowsRule());
+  }
+
+  @Override
+  public Statement apply(Statement base, Description description) {
+    Statement containStatement = new Statement() {
+      @Override
+      public void evaluate() throws Throwable {
+
+        composeContainer = new DockerComposeContainer<>("compose", new File(composeFile));
+        exposedServices.forEach((service, ports) -> ports
+            .forEach(p -> composeContainer.withExposedService(service, p)));
+
+        composeContainer.start();
+
+        try {
+          base.evaluate();
+        } finally {
+          composeContainer.stop();
+        }
+      }
+    };
+
+    return delegate.apply(containStatement, description);
+  }
+
+  /**
+   * When used with compose, testcontainers does not allow one to have a 'container_name'
+   * attribute in the compose file. This means that container names end up looking something like:
+   * {@code project_service_index}. When a container performs a reverse IP lookup it will get a
+   * hostname that looks something like {@code projectjkh_db_1.my-network}. This can be a problem
+   * since this hostname is not RFC compliant as it contains underscores. This may cause problems
+   * in particular with SSL.
+   *
+   * @param serviceName the service who's container name to change
+   * @param newName the new container name
+   *
+   * @throws IllegalArgumentException if the service cannot be found
+   */
+  public void setContainerName(String serviceName, String newName) {
+    ContainerState container = composeContainer.getContainerByServiceName(serviceName + "_1")
+        .orElseThrow(() -> new IllegalArgumentException("Unknown service name: " + serviceName));
+
+    String containerId = container.getContainerId();
+
+    DockerClient dockerClient = DockerClientFactory.instance().client();
+    dockerClient.renameContainerCmd(containerId).withName(newName).exec();
+  }
+
+  /**
+   * Execute a command in a service container
+   *
+   * @return the stdout of the container if the command was successful, else the stderr
+   */
+  public String execForService(String serviceName, String... command) {
+    ContainerState container = composeContainer.getContainerByServiceName(serviceName + "_1")
+        .orElseThrow(() -> new IllegalArgumentException("Unknown service name: " + serviceName));
+    Container.ExecResult result;
+    try {
+      result = container.execInContainer(command);
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+
+    return result.getExitCode() == 0 ? result.getStdout() : result.getStderr();
+  }
+
+  /**
+   * Execute a command in a service container, logging the output
+   *
+   * @return the exit code of the command
+   */
+  public Long loggingExecForService(String serviceName, String... command) {
+    ContainerState container = composeContainer.getContainerByServiceName(serviceName + "_1")
+        .orElseThrow(() -> new IllegalArgumentException("Unknown service name: " + serviceName));
+
+    String containerId = container.getContainerId();
+    String containerName = container.getContainerInfo().getName();
+
+    logger.info("{}: Running 'exec' command: {}", containerName, command);
+
+    DockerClient dockerClient = DockerClientFactory.instance().client();
+
+    final ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
+        .withAttachStdout(true).withAttachStderr(true).withCmd(command).exec();
+
+    final ToLogConsumer stdoutConsumer = new ToLogConsumer(serviceName, logger);
+    final ToLogConsumer stderrConsumer = new ToLogConsumer(serviceName, logger);
+
+    FrameConsumerResultCallback callback = new FrameConsumerResultCallback();
+    callback.addConsumer(OutputFrame.OutputType.STDOUT, stdoutConsumer);
+    callback.addConsumer(OutputFrame.OutputType.STDERR, stderrConsumer);
+
+    try {
+      dockerClient.execStartCmd(execCreateCmdResponse.getId()).exec(callback).awaitCompletion();
+    } catch (InterruptedException e) {
+      throw new RuntimeException(e);
+    }
+
+    return dockerClient.inspectExecCmd(execCreateCmdResponse.getId()).exec().getExitCodeLong();
+  }
+
+  /**
+   * Get the (ephemeral) exposed port for a service. This is the port that a test would use to
+   * connect.
+   *
+   * @param serviceName the service
+   * @param port the port (internal) which was exposed when building the rule
+   * @return the exposed port
+   */
+  public Integer getExternalPortForService(String serviceName, int port) {
+    return composeContainer.getServicePort(serviceName, port);
+  }
+
+  /**
+   * Pause a service. This is helpful to test failure conditions.
+   *
+   * @see <a href=
+   *      "https://docs.docker.com/engine/reference/commandline/pause/">https://docs.docker.com/engine/reference/commandline/pause/</a>
+   * @param serviceName the service to pause
+   */
+  public void pauseService(String serviceName) {
+    ContainerState container = composeContainer.getContainerByServiceName(serviceName + "_1")
+        .orElseThrow(() -> new IllegalArgumentException("Unknown service name: " + serviceName));
+    DockerClientFactory.instance().client().pauseContainerCmd(container.getContainerId()).exec();
+  }
+
+  /**
+   * Unpause the service. This does not restart anything.
+   *
+   * @param serviceName the service to unpause
+   */
+  public void unpauseService(String serviceName) {
+    ContainerState container = composeContainer.getContainerByServiceName(serviceName + "_1")
+        .orElseThrow(() -> new IllegalArgumentException("Unknown service name: " + serviceName));
+    DockerClientFactory.instance().client().unpauseContainerCmd(container.getContainerId()).exec();
+  }
+
+  public static class Builder {
+    private String filePath;
+    private final Map<String, List<Integer>> exposedServices = new HashMap<>();
+
+    public Builder() {}
+
+    public DockerComposeRule build() {
+      return new DockerComposeRule(filePath, exposedServices);
+    }
+
+    public Builder file(String filePath) {
+      this.filePath = filePath;
+      return this;
+    }
+
+    public Builder service(String serviceName, Integer port) {
+      exposedServices.computeIfAbsent(serviceName, k -> new ArrayList<>()).add(port);
+      return this;
+    }
+  }
+
+  private static class ToLogConsumer extends BaseConsumer<ToStringConsumer> {
+    private boolean firstLine = true;
+    private final Logger logger;
+    private final String prefix;
+
+    public ToLogConsumer(String prefix, Logger logger) {
+      this.prefix = prefix;
+      this.logger = logger;
+    }
+
+    @Override
+    public void accept(OutputFrame outputFrame) {
+      if (outputFrame.getBytes() != null) {
+        if (!firstLine) {
+          logger.info("[{}]:", prefix);
+        }
+        logger.info("[{}]: {}", prefix, outputFrame.getUtf8String());
+        firstLine = false;
+      }
+    }
+  }
+
+}
diff --git a/geode-assembly/src/acceptanceTest/resources/org/apache/geode/cache/wan/docker-compose.yml b/geode-assembly/src/acceptanceTest/resources/org/apache/geode/cache/wan/docker-compose.yml
index 817e770..886b2ed 100644
--- a/geode-assembly/src/acceptanceTest/resources/org/apache/geode/cache/wan/docker-compose.yml
+++ b/geode-assembly/src/acceptanceTest/resources/org/apache/geode/cache/wan/docker-compose.yml
@@ -17,12 +17,8 @@
 version: '3'
 services:
   locator:
-    container_name: 'locator'
     image: 'geode:develop'
     hostname: locator
-    expose:
-      - '20334'
-      - '1099'
     entrypoint: 'sh'
     command: '-c /geode/scripts/forever'
     networks:
@@ -31,12 +27,8 @@ services:
       - ./geode-config:/geode/config:ro
       - ./scripts:/geode/scripts
   server1:
-    container_name: 'server1'
     image: 'geode:develop'
     hostname: server1
-    expose:
-      - '40404'
-      - '2324'
     entrypoint: 'sh'
     command: '-c /geode/scripts/forever'
     networks:
@@ -45,12 +37,8 @@ services:
       - ./geode-config:/geode/config:ro
       - ./scripts:/geode/scripts
   server2:
-    container_name: 'server2'
     image: 'geode:develop'
     hostname: server2
-    expose:
-      - '40404'
-      - '2324'
     entrypoint: 'sh'
     command: '-c /geode/scripts/forever'
     networks:
@@ -59,11 +47,7 @@ services:
       - ./geode-config:/geode/config:ro
       - ./scripts:/geode/scripts
   haproxy:
-    container_name: 'haproxy'
     image: 'haproxy:2.1'
-    ports:
-      - "20334:20334"
-      - "2324:2324"
     networks:
       geode-wan-test:
     volumes:
diff --git a/geode-assembly/src/acceptanceTest/resources/org/apache/geode/client/sni/docker-compose.yml b/geode-assembly/src/acceptanceTest/resources/org/apache/geode/client/sni/docker-compose.yml
index 730d74d..a037d07 100644
--- a/geode-assembly/src/acceptanceTest/resources/org/apache/geode/client/sni/docker-compose.yml
+++ b/geode-assembly/src/acceptanceTest/resources/org/apache/geode/client/sni/docker-compose.yml
@@ -17,13 +17,8 @@
 version: '3'
 services:
   geode:
-    container_name: 'geode'
     image: 'geode:develop'
     hostname: geode
-    expose:
-      - '10334'
-      - '8501'
-      - '8502'
     entrypoint: 'sh'
     command: '-c /geode/scripts/forever'
     networks:
@@ -32,10 +27,7 @@ services:
       - ./geode-config:/geode/config:ro
       - ./scripts:/geode/scripts
   haproxy:
-    container_name: 'haproxy'
     image: 'haproxy:2.1'
-    ports:
-      - "15443"
     networks:
       geode-sni-test:
     volumes:
diff --git a/geode-assembly/src/acceptanceTest/resources/org/apache/geode/client/sni/dual-server-docker-compose.yml b/geode-assembly/src/acceptanceTest/resources/org/apache/geode/client/sni/dual-server-docker-compose.yml
new file mode 100644
index 0000000..e69de29