You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ds...@apache.org on 2026/03/30 03:18:02 UTC

(solr) 02/03: SOLR-18150: Add SolrBackend abstraction for test/benchmark deployments (#4214)

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

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit af9527b25566f532dae480fe521c90fc20de6ca9
Author: David Smiley <ds...@apache.org>
AuthorDate: Sun Mar 29 11:23:13 2026 -0400

    SOLR-18150: Add SolrBackend abstraction for test/benchmark deployments (#4214)
    
    SOLR-18150: Add SolrBackend abstraction for test/benchmark deployments
    
    Created SolrBackend interface to abstract over different Solr deployment
    types (embedded, remote, Jetty, MiniSolrCloudCluster) with a unified API
    for collection/configSet management, diagnostics, and node URL access.
    JettySolrRunner now manages a HttpJettySolrClient instance, get via getSolrClient.
    
    This PR only introduces the abstraction; follow-on PRs will add callers
    in benchmarks and tests.
    
    Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
---
 changelog/unreleased/SOLR-18150-SolrBackend.yml    |   7 +
 .../java/org/apache/solr/bench/SolrBenchState.java |   9 +-
 .../solr/cloud/RecoveryStrategyStressTest.java     |   3 +-
 .../test/org/apache/solr/cloud/RecoveryZkTest.java |   3 +-
 .../apache/solr/cloud/RecoveryZkTestWithAuth.java  |   3 +-
 .../solr/search/facet/RangeFacetCloudTest.java     |   2 +-
 .../search/facet/TestCloudJSONFacetJoinDomain.java |   2 +-
 .../solr/search/facet/TestCloudJSONFacetSKG.java   |   2 +-
 .../search/facet/TestCloudJSONFacetSKGEquiv.java   |   2 +-
 .../solr/search/join/TestCloudNestedDocsSort.java  |   2 +-
 .../solr/servlet/TestRequestRateLimiter.java       |   6 +-
 .../solr/update/DeleteByIdWithRouterFieldTest.java |   2 +-
 .../examples/IndexingNestedDocuments.java          |   6 +-
 .../src/java/org/apache/solr/SolrBackend.java      | 168 +++++++++++++++++++++
 .../apache/solr/cloud/MiniSolrCloudCluster.java    | 136 +++++++++--------
 .../org/apache/solr/cloud/RemoteSolrBackend.java   | 119 +++++++++++++++
 .../apache/solr/embedded/EmbeddedSolrBackend.java  | 100 ++++++++++++
 .../org/apache/solr/embedded/JettySolrRunner.java  |  96 +++++++++---
 18 files changed, 563 insertions(+), 105 deletions(-)

diff --git a/changelog/unreleased/SOLR-18150-SolrBackend.yml b/changelog/unreleased/SOLR-18150-SolrBackend.yml
new file mode 100644
index 00000000000..eccb8cedaa0
--- /dev/null
+++ b/changelog/unreleased/SOLR-18150-SolrBackend.yml
@@ -0,0 +1,7 @@
+title: 'solr-test-framework: New SolrBackend abstraction for use by solr/benchmark and SolrClientTestRule.  Useful for anyone using other test frameworks or higher JUnit versions.'
+type: other
+authors:
+  - name: David Smiley
+links:
+  - name: SOLR-18150
+    url: https://issues.apache.org/jira/browse/SOLR-18150
diff --git a/solr/benchmark/src/java/org/apache/solr/bench/SolrBenchState.java b/solr/benchmark/src/java/org/apache/solr/bench/SolrBenchState.java
index 0a7e9b1f2be..ff6a09cfe51 100755
--- a/solr/benchmark/src/java/org/apache/solr/bench/SolrBenchState.java
+++ b/solr/benchmark/src/java/org/apache/solr/bench/SolrBenchState.java
@@ -25,6 +25,7 @@ import java.io.OutputStream;
 import java.io.PrintStream;
 import java.lang.management.ManagementFactory;
 import java.net.URL;
+import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.FileSystems;
 import java.nio.file.Files;
@@ -127,7 +128,10 @@ public class SolrBenchState {
             benchmarkParams.getBenchmark() + ".txt");
     Files.createDirectories(metricsResults.getParent());
 
-    cluster.dumpMetrics(metricsResults.getParent(), metricsResults.getFileName().toString());
+    try (var out =
+        new PrintStream(Files.newOutputStream(metricsResults), false, Charset.defaultCharset())) {
+      cluster.dumpMetrics(out);
+    }
   }
 
   /**
@@ -346,7 +350,6 @@ public class SolrBenchState {
     this.useHttp1 = useHttp1;
   }
 
-  @SuppressForbidden(reason = "This module does not need to deal with logging context")
   public void index(String collection, Docs docs, int docCount) throws Exception {
     index(collection, docs, docCount, true);
   }
@@ -390,7 +393,7 @@ public class SolrBenchState {
     dumpCoreInfo();
   }
 
-  @SuppressForbidden(reason = "This module does not need to deal with logging context")
+  @SuppressForbidden(reason = "Benchmarks may use JDK ExecutorService impls")
   private void indexParallel(String collection, Docs docs, int docCount)
       throws InterruptedException {
     Meter meter = new Meter();
diff --git a/solr/core/src/test/org/apache/solr/cloud/RecoveryStrategyStressTest.java b/solr/core/src/test/org/apache/solr/cloud/RecoveryStrategyStressTest.java
index 9e66bee3867..2cd0ef45a34 100644
--- a/solr/core/src/test/org/apache/solr/cloud/RecoveryStrategyStressTest.java
+++ b/solr/core/src/test/org/apache/solr/cloud/RecoveryStrategyStressTest.java
@@ -54,8 +54,7 @@ public class RecoveryStrategyStressTest extends SolrCloudTestCase {
         "Expected a collection with one shard and two replicas", collection, clusterShape(1, 4));
     final var scheduledExecutorService =
         Executors.newScheduledThreadPool(1, new SolrNamedThreadFactory("stressTestRecovery"));
-    try (SolrClient solrClient =
-        cluster.basicSolrClientBuilder().withDefaultCollection(collection).build()) {
+    try (SolrClient solrClient = cluster.newSolrClient(collection)) {
       final StoppableIndexingThread indexThread =
           new StoppableIndexingThread(null, solrClient, "1", true, 10, 1, true);
 
diff --git a/solr/core/src/test/org/apache/solr/cloud/RecoveryZkTest.java b/solr/core/src/test/org/apache/solr/cloud/RecoveryZkTest.java
index 5ea4794ff4b..48d4855c1d2 100644
--- a/solr/core/src/test/org/apache/solr/cloud/RecoveryZkTest.java
+++ b/solr/core/src/test/org/apache/solr/cloud/RecoveryZkTest.java
@@ -81,8 +81,7 @@ public class RecoveryZkTest extends SolrCloudTestCase {
     }
     log.info("Indexing {} documents", maxDoc);
 
-    try (SolrClient solrClient =
-        cluster.basicSolrClientBuilder().withDefaultCollection(collection).build(); ) {
+    try (SolrClient solrClient = cluster.newSolrClient(collection); ) {
       final StoppableIndexingThread indexThread =
           new StoppableIndexingThread(null, solrClient, "1", true, maxDoc, 1, true);
       threads.add(indexThread);
diff --git a/solr/core/src/test/org/apache/solr/cloud/RecoveryZkTestWithAuth.java b/solr/core/src/test/org/apache/solr/cloud/RecoveryZkTestWithAuth.java
index 3a62ac08f88..479a5c44ba3 100644
--- a/solr/core/src/test/org/apache/solr/cloud/RecoveryZkTestWithAuth.java
+++ b/solr/core/src/test/org/apache/solr/cloud/RecoveryZkTestWithAuth.java
@@ -78,8 +78,7 @@ public class RecoveryZkTestWithAuth extends SolrCloudTestCase {
         .process(cluster.getSolrClient());
     waitForState(
         "Expected a collection with one shard and one replicas", collection, clusterShape(1, 1));
-    try (SolrClient solrClient =
-        cluster.basicSolrClientBuilder().withDefaultCollection(collection).build()) {
+    try (SolrClient solrClient = cluster.newSolrClient(collection)) {
       UpdateRequest commitReq = new UpdateRequest();
       withBasicAuth(commitReq);
       for (int i = 0; i < 500; i++) {
diff --git a/solr/core/src/test/org/apache/solr/search/facet/RangeFacetCloudTest.java b/solr/core/src/test/org/apache/solr/search/facet/RangeFacetCloudTest.java
index fbb9abb1b33..697a2f8b680 100644
--- a/solr/core/src/test/org/apache/solr/search/facet/RangeFacetCloudTest.java
+++ b/solr/core/src/test/org/apache/solr/search/facet/RangeFacetCloudTest.java
@@ -102,7 +102,7 @@ public class RangeFacetCloudTest extends SolrCloudTestCase {
                 .process(cluster.getSolrClient()))
             .getStatus());
 
-    CLOUD_CLIENT = cluster.basicSolrClientBuilder().withDefaultCollection(COLLECTION).build();
+    CLOUD_CLIENT = cluster.newSolrClient(COLLECTION);
 
     final int numDocs = atLeast(1000);
     final int maxTermId = atLeast(TERM_VALUES_RANDOMIZER);
diff --git a/solr/core/src/test/org/apache/solr/search/facet/TestCloudJSONFacetJoinDomain.java b/solr/core/src/test/org/apache/solr/search/facet/TestCloudJSONFacetJoinDomain.java
index 93e7f07bf4c..2853d1c7c3f 100644
--- a/solr/core/src/test/org/apache/solr/search/facet/TestCloudJSONFacetJoinDomain.java
+++ b/solr/core/src/test/org/apache/solr/search/facet/TestCloudJSONFacetJoinDomain.java
@@ -118,7 +118,7 @@ public class TestCloudJSONFacetJoinDomain extends SolrCloudTestCase {
         .setProperties(collectionProperties)
         .process(cluster.getSolrClient());
 
-    CLOUD_CLIENT = cluster.basicSolrClientBuilder().withDefaultCollection(COLLECTION_NAME).build();
+    CLOUD_CLIENT = cluster.newSolrClient(COLLECTION_NAME);
 
     waitForRecoveriesToFinish(CLOUD_CLIENT);
 
diff --git a/solr/core/src/test/org/apache/solr/search/facet/TestCloudJSONFacetSKG.java b/solr/core/src/test/org/apache/solr/search/facet/TestCloudJSONFacetSKG.java
index c6e9dfa0094..48ccb4a133f 100644
--- a/solr/core/src/test/org/apache/solr/search/facet/TestCloudJSONFacetSKG.java
+++ b/solr/core/src/test/org/apache/solr/search/facet/TestCloudJSONFacetSKG.java
@@ -145,7 +145,7 @@ public class TestCloudJSONFacetSKG extends SolrCloudTestCase {
         .setProperties(collectionProperties)
         .process(cluster.getSolrClient());
 
-    CLOUD_CLIENT = cluster.basicSolrClientBuilder().withDefaultCollection(COLLECTION_NAME).build();
+    CLOUD_CLIENT = cluster.newSolrClient(COLLECTION_NAME);
 
     waitForRecoveriesToFinish(CLOUD_CLIENT);
 
diff --git a/solr/core/src/test/org/apache/solr/search/facet/TestCloudJSONFacetSKGEquiv.java b/solr/core/src/test/org/apache/solr/search/facet/TestCloudJSONFacetSKGEquiv.java
index b295fd4c9a3..087f2a2ec5c 100644
--- a/solr/core/src/test/org/apache/solr/search/facet/TestCloudJSONFacetSKGEquiv.java
+++ b/solr/core/src/test/org/apache/solr/search/facet/TestCloudJSONFacetSKGEquiv.java
@@ -137,7 +137,7 @@ public class TestCloudJSONFacetSKGEquiv extends SolrCloudTestCase {
         .setProperties(collectionProperties)
         .process(cluster.getSolrClient());
 
-    CLOUD_CLIENT = cluster.basicSolrClientBuilder().withDefaultCollection(COLLECTION_NAME).build();
+    CLOUD_CLIENT = cluster.newSolrClient(COLLECTION_NAME);
 
     waitForRecoveriesToFinish(CLOUD_CLIENT);
 
diff --git a/solr/core/src/test/org/apache/solr/search/join/TestCloudNestedDocsSort.java b/solr/core/src/test/org/apache/solr/search/join/TestCloudNestedDocsSort.java
index 35fc3ffbfbd..9921394e136 100644
--- a/solr/core/src/test/org/apache/solr/search/join/TestCloudNestedDocsSort.java
+++ b/solr/core/src/test/org/apache/solr/search/join/TestCloudNestedDocsSort.java
@@ -73,7 +73,7 @@ public class TestCloudNestedDocsSort extends SolrCloudTestCase {
         .withProperty("schema", "schema.xml")
         .process(cluster.getSolrClient());
 
-    client = cluster.basicSolrClientBuilder().withDefaultCollection("collection1").build();
+    client = cluster.newSolrClient("collection1");
 
     ZkStateReader zkStateReader = ZkStateReader.from(client);
     AbstractFullDistribZkTestBase.waitForRecoveriesToFinish(
diff --git a/solr/core/src/test/org/apache/solr/servlet/TestRequestRateLimiter.java b/solr/core/src/test/org/apache/solr/servlet/TestRequestRateLimiter.java
index 1c7c49b1c41..d551862227c 100644
--- a/solr/core/src/test/org/apache/solr/servlet/TestRequestRateLimiter.java
+++ b/solr/core/src/test/org/apache/solr/servlet/TestRequestRateLimiter.java
@@ -67,8 +67,7 @@ public class TestRequestRateLimiter extends SolrCloudTestCase {
 
   @Test
   public void testConcurrentQueries() throws Exception {
-    try (CloudSolrClient client =
-        cluster.basicSolrClientBuilder().withDefaultCollection(FIRST_COLLECTION).build()) {
+    try (CloudSolrClient client = cluster.newSolrClient(FIRST_COLLECTION)) {
 
       CollectionAdminRequest.createCollection(FIRST_COLLECTION, 1, 1).process(client);
       cluster.waitForActiveCollection(FIRST_COLLECTION, 1, 1);
@@ -287,8 +286,7 @@ public class TestRequestRateLimiter extends SolrCloudTestCase {
 
   @Nightly
   public void testSlotBorrowing() throws Exception {
-    try (CloudSolrClient client =
-        cluster.basicSolrClientBuilder().withDefaultCollection(SECOND_COLLECTION).build()) {
+    try (CloudSolrClient client = cluster.newSolrClient(SECOND_COLLECTION)) {
 
       CollectionAdminRequest.createCollection(SECOND_COLLECTION, 1, 1).process(client);
       cluster.waitForActiveCollection(SECOND_COLLECTION, 1, 1);
diff --git a/solr/core/src/test/org/apache/solr/update/DeleteByIdWithRouterFieldTest.java b/solr/core/src/test/org/apache/solr/update/DeleteByIdWithRouterFieldTest.java
index a36b7b02cd6..cf5ead4b685 100644
--- a/solr/core/src/test/org/apache/solr/update/DeleteByIdWithRouterFieldTest.java
+++ b/solr/core/src/test/org/apache/solr/update/DeleteByIdWithRouterFieldTest.java
@@ -78,7 +78,7 @@ public class DeleteByIdWithRouterFieldTest extends SolrCloudTestCase {
             .process(cluster.getSolrClient())
             .isSuccess());
 
-    solrClient = cluster.basicSolrClientBuilder().withDefaultCollection(COLL).build();
+    solrClient = cluster.newSolrClient(COLL);
 
     ClusterState clusterState = cluster.getSolrClient().getClusterState();
     for (Replica replica : clusterState.getCollection(COLL).getReplicas()) {
diff --git a/solr/solr-ref-guide/modules/indexing-guide/examples/IndexingNestedDocuments.java b/solr/solr-ref-guide/modules/indexing-guide/examples/IndexingNestedDocuments.java
index 1310d65185d..668abf3d592 100644
--- a/solr/solr-ref-guide/modules/indexing-guide/examples/IndexingNestedDocuments.java
+++ b/solr/solr-ref-guide/modules/indexing-guide/examples/IndexingNestedDocuments.java
@@ -73,8 +73,7 @@ public class IndexingNestedDocuments extends SolrCloudTestCase {
         .process(cluster.getSolrClient());
 
     // configure the client with the default collection name, to simplify our example below.
-    IndexingNestedDocuments.clientUsedInSolrJExample =
-        cluster.basicSolrClientBuilder().withDefaultCollection(collection).build();
+    IndexingNestedDocuments.clientUsedInSolrJExample = cluster.newSolrClient(collection);
 
     //
     // DO NOT MODIFY THESE EXAMPLE DOCS WITHOUT MAKING THE SAME CHANGES TO THE JSON AND XML
@@ -165,8 +164,7 @@ public class IndexingNestedDocuments extends SolrCloudTestCase {
     CollectionAdminRequest.createCollection(collection, 1, 1).process(cluster.getSolrClient());
 
     // configure the client with the default collection name, to simplify our example below.
-    IndexingNestedDocuments.clientUsedInSolrJExample =
-        cluster.basicSolrClientBuilder().withDefaultCollection(collection).build();
+    IndexingNestedDocuments.clientUsedInSolrJExample = cluster.newSolrClient(collection);
 
     //
     // DO NOT MODIFY THESE EXAMPLE DOCS WITHOUT MAKING THE SAME CHANGES TO THE JSON AND XML
diff --git a/solr/test-framework/src/java/org/apache/solr/SolrBackend.java b/solr/test-framework/src/java/org/apache/solr/SolrBackend.java
new file mode 100644
index 00000000000..db6c3f5ad09
--- /dev/null
+++ b/solr/test-framework/src/java/org/apache/solr/SolrBackend.java
@@ -0,0 +1,168 @@
+/*
+ * 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.solr;
+
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.file.Path;
+import java.util.Random;
+import org.apache.solr.client.solrj.SolrClient;
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.impl.CloudSolrClient;
+import org.apache.solr.client.solrj.impl.HttpSolrClientBase;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.GenericSolrRequest;
+import org.apache.solr.client.solrj.request.MetricsRequest;
+import org.apache.solr.client.solrj.response.InputStreamResponseParser;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.URLUtil;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.embedded.EmbeddedSolrBackend;
+import org.apache.solr.embedded.JettySolrRunner;
+
+/**
+ * Abstraction over a running Solr deployment for use in tests and benchmarks. The abstraction
+ * normalizes how to shut down and perform other common operations as the methods indicate.
+ */
+public interface SolrBackend extends AutoCloseable {
+
+  /**
+   * Creates a new {@link SolrClient} defaulted to the given collection. The <em>caller</em> owns
+   * this client and is responsible for closing it. Callers that want a long-lived client should
+   * cache it themselves.
+   */
+  SolrClient newSolrClient(String collection);
+
+  /**
+   * Returns the node / admin (collection-less) {@link SolrClient} owned by this backend. The caller
+   * must NOT close it; it is released when this backend is {@link #close()}d. A {@link
+   * CloudSolrClient} should be returned if appropriate. While it *can* be used to target specific
+   * collections, please use {@link #newSolrClient(String)} for that instead.
+   */
+  SolrClient getSolrClient();
+
+  /**
+   * Upload a configSet, possibly overwriting (creating files, updating files, NOT deleting files).
+   *
+   * @param configDir a path to the config set to upload
+   * @param name the name to give the configSet
+   */
+  default void uploadConfigSet(Path configDir, String name)
+      throws SolrServerException, IOException {
+    getCoreContainer().getConfigSetService().uploadConfig(name, configDir);
+  }
+
+  /**
+   * Checks if a configSet with the given name exists.
+   *
+   * @param name configSet name to check
+   * @return true if the configSet exists, false otherwise
+   */
+  default boolean hasConfigSet(String name) throws SolrServerException, IOException {
+    return getCoreContainer().getConfigSetService().checkConfigExists(name);
+  }
+
+  /**
+   * Creates a collection (or core for single-node backends). Cloud backends honour {@code
+   * numShards} and {@code replicationFactor}; single-node backends ({@link JettySolrRunner}, {@link
+   * EmbeddedSolrBackend}) ignore those fields. Callers should use {@link #hasCollection(String)}
+   * first if they want to avoid errors when the collection already exists. Tests/benchmarks that
+   * want to test how this works should not use this to do so.
+   */
+  void createCollection(CollectionAdminRequest.Create create)
+      throws SolrServerException, IOException;
+
+  /**
+   * Checks if a collection or core with the given name exists.
+   *
+   * @param name collection or core name to check
+   * @return true if the collection/core exists, false otherwise
+   */
+  boolean hasCollection(String name) throws SolrServerException, IOException;
+
+  /** Reloads a collection or core by this name. The purpose is typically to clear caches. */
+  default void reloadCollection(String name) throws SolrServerException, IOException {
+    getSolrClient().request(CollectionAdminRequest.reloadCollection(name));
+  }
+
+  /**
+   * Provides access to an embedded/in-process {@link org.apache.solr.core.CoreContainer} -- if
+   * available (else null). If there is more than one node, then one is returned.
+   *
+   * @return can be null.
+   */
+  CoreContainer getCoreContainer();
+
+  /**
+   * Returns the base URL of a Solr node. For cloud backends with multiple nodes, a live node is
+   * chosen at random. The URL does not include a trailing slash.
+   *
+   * @return base URL (e.g., "http://localhost:8983/solr"). Null for EmbeddedSolrServer.
+   */
+  default String getBaseUrl(Random r) {
+    // Get live nodes and pick one randomly
+    SolrClient adminClient = getSolrClient();
+    if (adminClient instanceof HttpSolrClientBase httpSolrClient) {
+      return httpSolrClient.getBaseURL();
+    } else if (adminClient instanceof CloudSolrClient cloudClient) {
+      var liveNodes = cloudClient.getClusterStateProvider().getLiveNodes();
+      if (liveNodes.isEmpty()) {
+        return null;
+      }
+      String randomNode =
+          liveNodes.stream().skip(r.nextInt(liveNodes.size())).findFirst().orElseThrow();
+      String urlScheme = cloudClient.getClusterStateProvider().getUrlScheme();
+      return URLUtil.getBaseUrlForNodeName(randomNode, urlScheme);
+    } else {
+      return null;
+    }
+  }
+
+  /** Dumps Prometheus-format metrics to {@code out}. No-op is an acceptable implementation. */
+  default void dumpMetrics(PrintStream out) {
+    try {
+      var request = new MetricsRequest();
+      request.setResponseParser(new InputStreamResponseParser("prometheus"));
+      var response = request.process(getSolrClient());
+      out.println(InputStreamResponseParser.consumeResponseToString(response.getResponse()));
+    } catch (SolrServerException | IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Dumps JSON-format information for all cores to {@code out}. No-op is an acceptable
+   * implementation. Default uses {@code GET /admin/cores?indexInfo=true}.
+   */
+  default void dumpCoreInfo(PrintStream out) {
+    try {
+      var request =
+          new GenericSolrRequest(
+              SolrRequest.METHOD.GET, "/admin/cores", SolrParams.of("indexInfo", "true"));
+      request.setResponseParser(new InputStreamResponseParser("json"));
+      var response = request.process(getSolrClient());
+      out.println(InputStreamResponseParser.consumeResponseToString(response.getResponse()));
+    } catch (SolrServerException | IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /** Shuts down this backend, releases the admin client, and all other resources. */
+  @Override
+  void close();
+}
diff --git a/solr/test-framework/src/java/org/apache/solr/cloud/MiniSolrCloudCluster.java b/solr/test-framework/src/java/org/apache/solr/cloud/MiniSolrCloudCluster.java
index ee8ced5a367..f2bbfb87f9b 100644
--- a/solr/test-framework/src/java/org/apache/solr/cloud/MiniSolrCloudCluster.java
+++ b/solr/test-framework/src/java/org/apache/solr/cloud/MiniSolrCloudCluster.java
@@ -16,8 +16,6 @@
  */
 package org.apache.solr.cloud;
 
-import static org.apache.solr.core.ConfigSetProperties.DEFAULT_FILENAME;
-
 import com.codahale.metrics.MetricRegistry;
 import io.dropwizard.metrics.jetty12.ee10.InstrumentedEE10Handler;
 import jakarta.servlet.Filter;
@@ -55,12 +53,13 @@ import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Consumer;
 import org.apache.lucene.tests.util.LuceneTestCase;
+import org.apache.solr.SolrBackend;
 import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.client.solrj.SolrServerException;
 import org.apache.solr.client.solrj.apache.CloudLegacySolrClient;
 import org.apache.solr.client.solrj.impl.CloudSolrClient;
 import org.apache.solr.client.solrj.jetty.SSLConfig;
 import org.apache.solr.client.solrj.request.CollectionAdminRequest;
-import org.apache.solr.client.solrj.request.ConfigSetAdminRequest;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.SolrException.ErrorCode;
 import org.apache.solr.common.cloud.Aliases;
@@ -90,7 +89,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /** "Mini" SolrCloud cluster to be used for testing */
-public class MiniSolrCloudCluster {
+public class MiniSolrCloudCluster implements SolrBackend {
 
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
   private static final URL PRE_GENERATED_PRIVATE_KEY_URL =
@@ -334,7 +333,7 @@ public class MiniSolrCloudCluster {
       throw startupError;
     }
 
-    solrClient = buildSolrClient();
+    solrClient = newSolrClient(null);
 
     if (numServers > 0) {
       waitForAllNodes(numServers, 60);
@@ -564,23 +563,6 @@ public class MiniSolrCloudCluster {
     return jetty;
   }
 
-  /**
-   * Upload a config set
-   *
-   * @param configDir a path to the config set to upload
-   * @param configName the name to give the configset
-   */
-  public void uploadConfigSet(Path configDir, String configName) throws IOException {
-    try (SolrZkClient zkClient =
-        new SolrZkClient.Builder()
-            .withUrl(zkServer.getZkAddress())
-            .withTimeout(AbstractZkTestCase.TIMEOUT, TimeUnit.MILLISECONDS)
-            .withConnTimeOut(AbstractZkTestCase.TIMEOUT, TimeUnit.MILLISECONDS)
-            .build()) {
-      new ZkConfigSetService(zkClient).uploadConfig(configName, configDir);
-    }
-  }
-
   /** Delete all collections (and aliases) */
   public void deleteAllCollections() throws Exception {
     try (ZkStateReader reader = new ZkStateReader(getZkClient())) {
@@ -632,22 +614,32 @@ public class MiniSolrCloudCluster {
   }
 
   public void deleteAllConfigSets() throws Exception {
-
-    List<String> configSetNames =
-        new ConfigSetAdminRequest.List().process(solrClient).getConfigSets();
-
-    for (String configSet : configSetNames) {
+    ZkConfigSetService service = new ZkConfigSetService(getZkClient());
+    for (String configSet : service.listConfigs()) {
       if (configSet.equals("_default")) {
         continue;
       }
-      try {
-        // cleanup any property before removing the configset
-        getZkClient()
-            .delete(
-                ZkConfigSetService.CONFIGS_ZKNODE + "/" + configSet + "/" + DEFAULT_FILENAME, -1);
-      } catch (KeeperException.NoNodeException nne) {
-      }
-      new ConfigSetAdminRequest.Delete().setConfigSetName(configSet).process(solrClient);
+      service.deleteConfig(configSet);
+    }
+  }
+
+  @Override
+  public void uploadConfigSet(Path configDir, String configName) throws IOException {
+    new ZkConfigSetService(getZkClient()).uploadConfig(configName, configDir);
+  }
+
+  @Override
+  public boolean hasConfigSet(String name) throws IOException {
+    return new ZkConfigSetService(getZkClient()).checkConfigExists(name);
+  }
+
+  /** Shut down the cluster, including all Solr nodes and ZooKeeper. Doesn't throw. */
+  @Override
+  public void close() {
+    try {
+      shutdown();
+    } catch (Exception e) { // don't propagate; more noisy than helpful
+      log.error(e.toString(), e); // nowarn
     }
   }
 
@@ -690,6 +682,7 @@ public class MiniSolrCloudCluster {
     return baseDir;
   }
 
+  @Override // SolrBackend
   public CloudSolrClient getSolrClient() {
     return solrClient;
   }
@@ -725,6 +718,16 @@ public class MiniSolrCloudCluster {
         });
   }
 
+  @Override // SolrBackend
+  public CloudSolrClient newSolrClient(String collection) {
+    return new CloudLegacySolrClient.Builder(
+            Collections.singletonList(getZkServer().getZkAddress()), Optional.empty())
+        .withSocketTimeout(90000, TimeUnit.MILLISECONDS)
+        .withConnectionTimeout(15000, TimeUnit.MILLISECONDS)
+        .withDefaultCollection(collection)
+        .build(); // we choose 90 because we run in some harsh envs
+  }
+
   public SolrZkClient getZkClient() {
     return getZkStateReader().getZkClient();
   }
@@ -741,27 +744,6 @@ public class MiniSolrCloudCluster {
     }
   }
 
-  protected CloudSolrClient buildSolrClient() {
-    return new CloudLegacySolrClient.Builder(
-            Collections.singletonList(getZkServer().getZkAddress()), Optional.empty())
-        .withSocketTimeout(90000, TimeUnit.MILLISECONDS)
-        .withConnectionTimeout(15000, TimeUnit.MILLISECONDS)
-        .build(); // we choose 90 because we run in some harsh envs
-  }
-
-  /**
-   * creates a basic CloudSolrClient Builder that then can be customized by callers, for example by
-   * specifying what collection they want to use.
-   *
-   * @return CloudLegacySolrClient.Builder
-   */
-  public CloudLegacySolrClient.Builder basicSolrClientBuilder() {
-    return new CloudLegacySolrClient.Builder(
-            Collections.singletonList(getZkServer().getZkAddress()), Optional.empty())
-        .withSocketTimeout(90000) // we choose 90 because we run in some harsh envs
-        .withConnectionTimeout(15000);
-  }
-
   private Exception checkForExceptions(String message, Collection<Future<JettySolrRunner>> futures)
       throws InterruptedException {
     Exception parsed = new Exception(message);
@@ -820,6 +802,26 @@ public class MiniSolrCloudCluster {
     throw new SolrException(ErrorCode.NOT_FOUND, "No open Overseer found");
   }
 
+  @Override // SolrBackend
+  public CoreContainer getCoreContainer() {
+    return jettys.size() != 1 ? null : jettys.getFirst().getCoreContainer();
+  }
+
+  @Override // SolrBackend
+  public void createCollection(CollectionAdminRequest.Create create)
+      throws SolrServerException, IOException {
+    String collectionName = create.getCollectionName();
+    create.process(this.getSolrClient());
+    int shards = create.getNumShards() != null ? create.getNumShards() : 1;
+    int replicas = create.getReplicationFactor() != null ? create.getReplicationFactor() : 1;
+    waitForActiveCollection(collectionName, 15, TimeUnit.SECONDS, shards, shards * replicas);
+  }
+
+  @Override // SolrBackend
+  public boolean hasCollection(String name) {
+    return getZkStateReader().getClusterState().hasCollection(name);
+  }
+
   public void waitForActiveCollection(
       String collection, long wait, TimeUnit unit, int shards, int totalReplicas) {
     log.info("waitForActiveCollection: {}", collection);
@@ -938,15 +940,27 @@ public class MiniSolrCloudCluster {
     }
   }
 
-  public void dumpMetrics(Path outputDirectory, String fileName) throws IOException {
-    for (JettySolrRunner jetty : jettys) {
-      jetty.outputMetrics(outputDirectory, fileName);
+  @Override
+  public void dumpMetrics(PrintStream out) {
+    try {
+      for (JettySolrRunner jetty : jettys) {
+        out.println();
+        out.println("# Jetty " + jetty.getNodeName() + " metrics:");
+        out.println();
+        jetty.outputMetrics(out);
+      }
+    } catch (IOException e) {
+      throw new RuntimeException(e);
     }
   }
 
-  public void dumpCoreInfo(PrintStream pw) throws IOException {
+  @Override
+  public void dumpCoreInfo(PrintStream out) {
     for (JettySolrRunner jetty : jettys) {
-      jetty.dumpCoresInfo(pw);
+      out.println();
+      out.println("  Jetty " + jetty.getNodeName() + " cores:");
+      out.println();
+      jetty.dumpCoresInfo(out);
     }
   }
 
diff --git a/solr/test-framework/src/java/org/apache/solr/cloud/RemoteSolrBackend.java b/solr/test-framework/src/java/org/apache/solr/cloud/RemoteSolrBackend.java
new file mode 100644
index 00000000000..78cb06ecee0
--- /dev/null
+++ b/solr/test-framework/src/java/org/apache/solr/cloud/RemoteSolrBackend.java
@@ -0,0 +1,119 @@
+/*
+ * 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.solr.cloud;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import org.apache.solr.SolrBackend;
+import org.apache.solr.client.solrj.SolrClient;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.impl.CloudSolrClient;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.ConfigSetAdminRequest;
+import org.apache.solr.common.util.IOUtils;
+import org.apache.solr.common.util.URLUtil;
+import org.apache.solr.core.CoreContainer;
+
+/**
+ * {@link SolrBackend} that connects to a pre-existing remote SolrCloud cluster. The caller supplies
+ * the HTTP connection string at construction time (e.g. {@code http://localhost:8983/solr}).
+ */
+public class RemoteSolrBackend implements SolrBackend {
+
+  private final CloudSolrClient adminClient;
+
+  public RemoteSolrBackend(String baseUrl) {
+    this.adminClient = new CloudSolrClient.Builder(List.of(baseUrl)).build();
+  }
+
+  @Override
+  public CoreContainer getCoreContainer() {
+    return null;
+  }
+
+  @Override
+  public SolrClient newSolrClient(String collection) {
+    String urlScheme = adminClient.getClusterStateProvider().getUrlScheme();
+    var urls =
+        adminClient.getClusterStateProvider().getLiveNodes().stream()
+            .map(liveNode -> URLUtil.getBaseUrlForNodeName(liveNode, urlScheme))
+            .toList();
+    return new CloudSolrClient.Builder(urls).withDefaultCollection(collection).build();
+  }
+
+  @Override
+  public SolrClient getSolrClient() {
+    return adminClient;
+  }
+
+  @Override
+  public void uploadConfigSet(Path configDir, String name) throws SolrServerException, IOException {
+    Path tempZip = Files.createTempFile("configset-", ".zip");
+    try {
+      zipDirectory(configDir, tempZip);
+      new ConfigSetAdminRequest.Upload()
+          .setConfigSetName(name)
+          .setUploadFile(tempZip, "application/zip")
+          .process(adminClient);
+    } finally {
+      Files.deleteIfExists(tempZip);
+    }
+  }
+
+  @Override
+  public boolean hasConfigSet(String name) throws SolrServerException, IOException {
+    return new ConfigSetAdminRequest.List().process(adminClient).getConfigSets().contains(name);
+  }
+
+  /** Packages {@code sourceDir} contents into a zip file. */
+  private static void zipDirectory(Path sourceDir, Path targetZip) throws IOException {
+    try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(targetZip))) {
+      Files.walk(sourceDir)
+          .filter(Files::isRegularFile)
+          .forEach(
+              file -> {
+                try {
+                  zos.putNextEntry(new ZipEntry(sourceDir.relativize(file).toString()));
+                  Files.copy(file, zos);
+                  zos.closeEntry();
+                } catch (IOException e) {
+                  throw new RuntimeException(e);
+                }
+              });
+    }
+  }
+
+  @Override
+  public void createCollection(CollectionAdminRequest.Create create)
+      throws SolrServerException, IOException {
+    create.process(adminClient);
+  }
+
+  @Override
+  public boolean hasCollection(String name) throws SolrServerException, IOException {
+    return CollectionAdminRequest.listCollections(adminClient).contains(name);
+  }
+
+  @Override
+  public void close() {
+    IOUtils.closeQuietly(adminClient);
+  }
+}
diff --git a/solr/test-framework/src/java/org/apache/solr/embedded/EmbeddedSolrBackend.java b/solr/test-framework/src/java/org/apache/solr/embedded/EmbeddedSolrBackend.java
new file mode 100644
index 00000000000..6b70539afcf
--- /dev/null
+++ b/solr/test-framework/src/java/org/apache/solr/embedded/EmbeddedSolrBackend.java
@@ -0,0 +1,100 @@
+/*
+ * 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.solr.embedded;
+
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+import org.apache.solr.SolrBackend;
+import org.apache.solr.client.solrj.SolrClient;
+import org.apache.solr.client.solrj.embedded.EmbeddedSolrServer;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.IOUtils;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.core.CoreDescriptor;
+
+/**
+ * {@link SolrBackend} backed by an in-process {@link CoreContainer}/{@link EmbeddedSolrServer}. No
+ * network or ZooKeeper overhead, although there's some request/response serialization.
+ *
+ * <p>Data is persisted in the given {@code solrHome} directory.
+ */
+public class EmbeddedSolrBackend implements SolrBackend {
+
+  private final CoreContainer coreContainer;
+  private final EmbeddedSolrServer adminClient;
+
+  public EmbeddedSolrBackend(Path solrHome) {
+    coreContainer = new CoreContainer(solrHome, new Properties());
+    coreContainer.load();
+    adminClient = new EmbeddedSolrServer(coreContainer, null);
+  }
+
+  /**
+   * @lucene.internal
+   */
+  public EmbeddedSolrBackend(EmbeddedSolrServer solrServer) {
+    this.coreContainer = solrServer.getCoreContainer();
+    this.adminClient = solrServer;
+  }
+
+  @Override
+  public CoreContainer getCoreContainer() {
+    return coreContainer;
+  }
+
+  @Override
+  public SolrClient newSolrClient(String collection) {
+    return new EmbeddedSolrServer(coreContainer, collection);
+  }
+
+  @Override
+  public EmbeddedSolrServer getSolrClient() {
+    return adminClient;
+  }
+
+  @Override
+  public void createCollection(CollectionAdminRequest.Create create) {
+    // convert configSet & properties to a map of "create params":
+    Map<String, String> coreParams = new HashMap<>();
+    if (create.getConfigName() != null) { // actually the configSet name; "config" is ambiguous
+      coreParams.put(CoreDescriptor.CORE_CONFIGSET, create.getConfigName());
+    }
+    if (create.getProperties() != null) {
+      create.getProperties().forEach((k, v) -> coreParams.put(k.toString(), v.toString()));
+    }
+    coreContainer.create(create.getCollectionName(), coreParams);
+  }
+
+  @Override
+  public boolean hasCollection(String name) {
+    return coreContainer.getCoreDescriptor(name) != null;
+  }
+
+  @Override
+  public void reloadCollection(String name) throws SolrException {
+    coreContainer.reload(name);
+  }
+
+  @Override
+  public void close() {
+    IOUtils.closeQuietly(adminClient);
+    coreContainer.shutdown();
+  }
+}
diff --git a/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java b/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java
index 43261fa7fd9..5ecabf8abf9 100644
--- a/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java
+++ b/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java
@@ -30,7 +30,6 @@ import jakarta.servlet.http.HttpServlet;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import java.io.IOException;
-import java.io.OutputStream;
 import java.io.PrintStream;
 import java.lang.invoke.MethodHandles;
 import java.net.BindException;
@@ -38,8 +37,6 @@ import java.net.MalformedURLException;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.List;
@@ -52,11 +49,16 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicLong;
+import org.apache.solr.SolrBackend;
 import org.apache.solr.client.solrj.SolrClient;
 import org.apache.solr.client.solrj.SolrServerException;
 import org.apache.solr.client.solrj.apache.HttpSolrClient;
+import org.apache.solr.client.solrj.embedded.EmbeddedSolrServer;
+import org.apache.solr.client.solrj.jetty.HttpJettySolrClient;
 import org.apache.solr.client.solrj.jetty.SSLConfig;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
 import org.apache.solr.client.solrj.request.CoresApi;
+import org.apache.solr.common.util.IOUtils;
 import org.apache.solr.common.util.TimeSource;
 import org.apache.solr.common.util.Utils;
 import org.apache.solr.core.CoreContainer;
@@ -105,7 +107,7 @@ import org.slf4j.MDC;
  *
  * @since solr 1.3
  */
-public class JettySolrRunner {
+public class JettySolrRunner implements SolrBackend {
 
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
@@ -146,6 +148,8 @@ public class JettySolrRunner {
 
   private String host;
 
+  private volatile HttpJettySolrClient jettySolrClient;
+
   private volatile boolean started = false;
 
   public static class DebugFilter implements Filter {
@@ -499,9 +503,7 @@ public class JettySolrRunner {
     return (RateLimitFilter) rateLimitFilter.getFilter();
   }
 
-  /**
-   * @return the {@link CoreContainer} for this node
-   */
+  @Override
   public CoreContainer getCoreContainer() {
     SolrServlet servlet;
     try {
@@ -590,6 +592,9 @@ public class JettySolrRunner {
 
       setProtocolAndHost();
 
+      IOUtils.closeQuietly(jettySolrClient);
+      jettySolrClient = null;
+
       if (enableProxy) {
         if (started) {
           proxy.reopen();
@@ -678,6 +683,9 @@ public class JettySolrRunner {
     Map<String, String> prevContext = MDC.getCopyOfContextMap();
     MDC.clear();
     try {
+      IOUtils.closeQuietly(jettySolrClient);
+      jettySolrClient = null;
+
       QueuedThreadPool qtp = (QueuedThreadPool) server.getThreadPool();
       ReservedThreadExecutor rte = qtp.getBean(ReservedThreadExecutor.class);
 
@@ -731,29 +739,20 @@ public class JettySolrRunner {
     }
   }
 
-  public void outputMetrics(Path outputDirectory, String fileName) throws IOException {
+  public void outputMetrics(PrintStream out) throws IOException {
     if (getCoreContainer() != null) {
-
-      if (outputDirectory != null) {
-        Path outDir = outputDirectory;
-        Files.createDirectories(outDir);
-      }
-
       SolrMetricManager metricsManager = getCoreContainer().getMetricManager();
 
       Set<String> registryNames = metricsManager.registryNames();
       for (String registryName : registryNames) {
         var prometheusReader = metricsManager.getPrometheusMetricReader(registryName);
         if (prometheusReader != null) {
-          try (OutputStream os =
-              outputDirectory == null
-                  ? OutputStream.nullOutputStream()
-                  : Files.newOutputStream(outputDirectory.resolve(registryName + "_" + fileName))) {
-            new PrometheusTextFormatWriter(false).write(os, prometheusReader.collect());
-          }
+          out.println();
+          out.println("# Registry: " + registryName);
+          out.println();
+          new PrometheusTextFormatWriter(false).write(out, prometheusReader.collect());
         }
       }
-
     } else {
       throw new IllegalStateException("No CoreContainer found");
     }
@@ -919,4 +918,59 @@ public class JettySolrRunner {
   public SocketProxy getProxy() {
     return proxy;
   }
+
+  // ---- SolrBackend implementation ----
+
+  @Override
+  public SolrClient newSolrClient(String collection) {
+    return new HttpJettySolrClient.Builder(getBaseUrl().toString())
+        .withDefaultCollection(collection)
+        .build();
+  }
+
+  @Override
+  public synchronized HttpJettySolrClient getSolrClient() {
+    if (jettySolrClient == null) {
+      jettySolrClient = new HttpJettySolrClient.Builder(getBaseUrl().toString()).build();
+    }
+    return jettySolrClient;
+  }
+
+  private EmbeddedSolrBackend getEmbeddedSolrBackend() {
+    var container = getCoreContainer();
+    if (container.isZooKeeperAware()) {
+      throw new IllegalStateException(
+          "Don't call SolrBackend methods in SolrCloud on JettySolrRunner");
+    }
+    return new EmbeddedSolrBackend(new EmbeddedSolrServer(container, null)); // cheap
+  }
+
+  @Override
+  public void createCollection(CollectionAdminRequest.Create create) {
+    getEmbeddedSolrBackend().createCollection(create);
+  }
+
+  @Override
+  public boolean hasCollection(String name) {
+    return getEmbeddedSolrBackend().hasCollection(name);
+  }
+
+  @Override
+  public void reloadCollection(String name) throws SolrServerException, IOException {
+    getEmbeddedSolrBackend().reloadCollection(name);
+  }
+
+  @Override
+  public String getBaseUrl(Random r) {
+    return getBaseUrl().toString();
+  }
+
+  @Override
+  public void close() {
+    try {
+      stop();
+    } catch (Exception e) {
+      log.error(e.toString(), e); // nowarn
+    }
+  }
 }