Repository: kroxylicious/kroxylicious
Pull request #398 (2023-06-20 01:50:10)

Metrics port collision
### Type of change

- Bugfix

### Description

1. Throw an exception on port collision with admin http port
2. Change the default metrics port to `9190` so we can continue using `9192` as our default example cluster port
    
If the admin port is enabled and any virtual cluster has declared that it wants to bind to that port (either with a shared or exclusive port), then `PortConflictDetector` throws an `IllegalStateException`.
    
If either the admin endpoint or the virtual cluster is binding to the <any> interface (ie `0.0.0.0`), we check if their ports collide.

If both the admin endpoint and the virtual cluster are binding to the same specific interface (not `0.0.0.0`), we check if their ports collide.

### Additional Context

Since the addition of the port-per-broker cluster binding mechanism the default metrics port of `9193` has been colliding with the port range used for example config and documentation, with the bootstrap on `localhost:9192` and therefore broker-0 will bind on `9193` and conflict.

### Checklist

_Please go through this checklist and make sure all applicable tasks have been done_

- [x] Write tests
- [x] Make sure all tests pass
- [x] Make sure all Sonar-Lint warnings are addressed or are justifiably ignored.
- [x] Update documentation
- [x] Reference relevant issue(s) and close them after merging
- [x] For user facing changes, update CHANGELOG.md (remember to include changes affecting the API of the test artefacts too).


robobario: Now if I set:

```yaml
adminHttp:
  port: 9193
  endpoints:
    prometheus: {}
```

And run `java -jar kroxylicious/target/kroxylicious-0.3.0-SNAPSHOT.jar -c kroxylicious/example-proxy-config.yml`, the process exits immediately with:

```
java.lang.IllegalStateException: The exclusive bind of port(s) 9193 for virtual cluster 'demo' to <any> would conflict with another (non-cluster) port binding
	at io.kroxylicious.proxy.internal.PortConflictDetector.checkForConflictsWithOtherExclusivePort(PortConflictDetector.java:154)
	at io.kroxylicious.proxy.internal.PortConflictDetector.lambda$validate$13(PortConflictDetector.java:57)
```
robobario: Thanks for review @SamBarker, will leave it for others to review since it can be worked around by removing the metrics config, changing the metrics port or changing the cluster bootstrap port

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6104f55..5c7a4068 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,12 @@ Please enumerate **all user-facing** changes using format `<githib issue/pr numb
 
 ## 0.3.0
 
+* [#398](https://github.com/kroxylicious/kroxylicious/pull/398): Validate admin port does not collide with cluster ports
 * [#384](https://github.com/kroxylicious/kroxylicious/pull/384): Bump guava from 32.0.0-jre to 32.0.1-jre
 * [#372](https://github.com/kroxylicious/kroxylicious/issues/372): Eliminate the test config model from the code-base
 * [#364](https://github.com/kroxylicious/kroxylicious/pull/364): Add Dockerfile for kroxylicious
+
+### Changes, deprecations and removals
+
+The default metrics port has changed from 9193 to 9190 to prevent port collisions
+
diff --git a/docs/operating.adoc b/docs/operating.adoc
index 9fdd46ec..a7cda5bb 100644
--- a/docs/operating.adoc
+++ b/docs/operating.adoc
@@ -23,7 +23,7 @@ adminHttp:
   endpoints:
     prometheus: {}
 ----
-Defaults to binding to `0.0.0.0:9193`. If no endpoints are configured the admin http server
+Defaults to binding to `0.0.0.0:9190`. If no endpoints are configured the admin http server
 is not created.
 
 #### complete configuration example
diff --git a/kroxylicious/src/main/java/io/kroxylicious/proxy/KafkaProxy.java b/kroxylicious/src/main/java/io/kroxylicious/proxy/KafkaProxy.java
index b57e4ede..9941e952 100644
--- a/kroxylicious/src/main/java/io/kroxylicious/proxy/KafkaProxy.java
+++ b/kroxylicious/src/main/java/io/kroxylicious/proxy/KafkaProxy.java
@@ -8,6 +8,7 @@ package io.kroxylicious.proxy;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
@@ -47,6 +48,7 @@ import io.kroxylicious.proxy.internal.net.EndpointRegistry;
 import io.kroxylicious.proxy.internal.net.NetworkBindingOperationProcessor;
 import io.kroxylicious.proxy.internal.util.Metrics;
 import io.kroxylicious.proxy.model.VirtualCluster;
+import io.kroxylicious.proxy.service.HostPort;
 
 public final class KafkaProxy implements AutoCloseable {
 
@@ -60,7 +62,7 @@ public final class KafkaProxy implements AutoCloseable {
         public List<Future<?>> shutdownGracefully() {
             return List.of(bossGroup.shutdownGracefully(), workerGroup.shutdownGracefully());
         }
-    };
+    }
 
     private final Configuration config;
     private final AdminHttpConfiguration adminHttpConfig;
@@ -93,7 +95,8 @@ public final class KafkaProxy implements AutoCloseable {
         }
 
         var portConflictDefector = new PortConflictDetector();
-        portConflictDefector.validate(virtualClusterMap);
+        Optional<HostPort> adminHttpHostPort = Optional.ofNullable(shouldBindAdminEndpoint() ? new HostPort(adminHttpConfig.host(), adminHttpConfig.port()) : null);
+        portConflictDefector.validate(virtualClusterMap, adminHttpHostPort);
 
         var availableCores = Runtime.getRuntime().availableProcessors();
         var meterRegistries = new MeterRegistries(micrometerConfig);
@@ -165,8 +168,7 @@ public final class KafkaProxy implements AutoCloseable {
     private void maybeStartMetricsListener(EventGroupConfig eventGroupConfig,
                                            MeterRegistries meterRegistries)
             throws InterruptedException {
-        if (adminHttpConfig != null
-                && adminHttpConfig.endpoints().maybePrometheus().isPresent()) {
+        if (shouldBindAdminEndpoint()) {
             ServerBootstrap metricsBootstrap = new ServerBootstrap().group(eventGroupConfig.bossGroup(), eventGroupConfig.workerGroup())
                     .option(ChannelOption.SO_REUSEADDR, true)
                     .channel(eventGroupConfig.clazz())
@@ -175,6 +177,11 @@ public final class KafkaProxy implements AutoCloseable {
         }
     }
 
+    private boolean shouldBindAdminEndpoint() {
+        return adminHttpConfig != null
+                && adminHttpConfig.endpoints().maybePrometheus().isPresent();
+    }
+
     /**
      * Blocks while this proxy is running.
      * This should only be called after a successful call to {@link #startup()}.
diff --git a/kroxylicious/src/main/java/io/kroxylicious/proxy/Kroxylicious.java b/kroxylicious/src/main/java/io/kroxylicious/proxy/Kroxylicious.java
index 50e483e4..e5bdb213 100644
--- a/kroxylicious/src/main/java/io/kroxylicious/proxy/Kroxylicious.java
+++ b/kroxylicious/src/main/java/io/kroxylicious/proxy/Kroxylicious.java
@@ -43,10 +43,10 @@ class Kroxylicious implements Callable<Integer> {
 
         try (InputStream stream = Files.newInputStream(configFile.toPath())) {
             Configuration config = new ConfigParser().parseConfiguration(stream);
-
-            KafkaProxy kafkaProxy = new KafkaProxy(config);
-            kafkaProxy.startup();
-            kafkaProxy.block();
+            try (KafkaProxy kafkaProxy = new KafkaProxy(config)) {
+                kafkaProxy.startup();
+                kafkaProxy.block();
+            }
         }
         catch (Exception e) {
             LOGGER.error("Exception on startup", e);
diff --git a/kroxylicious/src/main/java/io/kroxylicious/proxy/config/admin/AdminHttpConfiguration.java b/kroxylicious/src/main/java/io/kroxylicious/proxy/config/admin/AdminHttpConfiguration.java
index 34752c21..995ff4d4 100644
--- a/kroxylicious/src/main/java/io/kroxylicious/proxy/config/admin/AdminHttpConfiguration.java
+++ b/kroxylicious/src/main/java/io/kroxylicious/proxy/config/admin/AdminHttpConfiguration.java
@@ -10,7 +10,7 @@ package io.kroxylicious.proxy.config.admin;
 public record AdminHttpConfiguration(String host, Integer port, EndpointsConfiguration endpoints) {
     public AdminHttpConfiguration(String host, Integer port, EndpointsConfiguration endpoints) {
         this.host = host == null ? "0.0.0.0" : host;
-        this.port = port == null ? 9193 : port;
+        this.port = port == null ? 9190 : port;
         this.endpoints = endpoints == null ? new EndpointsConfiguration(null) : endpoints;
     }
 }
diff --git a/kroxylicious/src/main/java/io/kroxylicious/proxy/internal/PortConflictDetector.java b/kroxylicious/src/main/java/io/kroxylicious/proxy/internal/PortConflictDetector.java
index 3457032a..b6748218 100644
--- a/kroxylicious/src/main/java/io/kroxylicious/proxy/internal/PortConflictDetector.java
+++ b/kroxylicious/src/main/java/io/kroxylicious/proxy/internal/PortConflictDetector.java
@@ -16,6 +16,7 @@ import java.util.Set;
 import java.util.stream.Collectors;
 
 import io.kroxylicious.proxy.model.VirtualCluster;
+import io.kroxylicious.proxy.service.HostPort;
 
 /**
  * Detects potential for port conflicts arising between virtual cluster configurations.
@@ -34,8 +35,9 @@ public class PortConflictDetector {
      * Validates the configuration throwing an exception if a conflict is detected.
      *
      * @param virtualClusterMap map of virtual clusters.
+     * @param otherExclusivePort an optional exclusive port that should conflict with virtual cluster ports
      */
-    public void validate(Map<String, VirtualCluster> virtualClusterMap) {
+    public void validate(Map<String, VirtualCluster> virtualClusterMap, Optional<HostPort> otherExclusivePort) {
 
         Set<String> seenVirtualClusters = new HashSet<>();
 
@@ -48,6 +50,15 @@ public class PortConflictDetector {
             var proposedSharedPorts = cluster.getSharedPorts();
             var proposedExclusivePorts = cluster.getExclusivePorts();
 
+            if (otherExclusivePort.isPresent()) {
+                Optional<String> otherInterface = otherExclusivePort.map(hostPort -> hostPort.host().equals("0.0.0.0") ? null : hostPort.host());
+                var checkPorts = cluster.getBindAddress().isEmpty() || otherInterface.isEmpty() || cluster.getBindAddress().equals(otherInterface);
+                if (checkPorts) {
+                    checkForConflictsWithOtherExclusivePort(otherExclusivePort.get(), name, cluster, proposedExclusivePorts, BindingScope.EXCLUSIVE);
+                    checkForConflictsWithOtherExclusivePort(otherExclusivePort.get(), name, cluster, proposedSharedPorts, BindingScope.SHARED);
+                }
+            }
+
             // if this virtual cluster is binding to <any>, we need to check for conflicts on the <any> interface and *all* specific interfaces,
             // otherwise we just check for conflicts on <any> and the specified specific interface
             var exclusiveCheckSet = cluster.getBindAddress().isEmpty() ? inUseExclusivePorts.keySet() : Set.of(ANY_INTERFACE, cluster.getBindAddress());
@@ -132,6 +143,22 @@ public class PortConflictDetector {
         });
     }
 
+    private void checkForConflictsWithOtherExclusivePort(HostPort otherHostPort, String name, VirtualCluster cluster, Set<Integer> proposedExclusivePorts,
+                                                         BindingScope scope) {
+        var ports = Set.of(otherHostPort.port());
+        var conflicts = getSortedPortConflicts(ports, proposedExclusivePorts);
+        if (!conflicts.isEmpty()) {
+            Optional<String> proposedBindingInterface = cluster.getBindAddress();
+            var portConflicts = conflicts.stream().map(String::valueOf).collect(Collectors.joining(","));
+
+            throw new IllegalStateException(("The %s bind of port(s) %s for virtual cluster '%s' to %s would conflict with another (non-cluster) port binding").formatted(
+                    scope.name().toLowerCase(Locale.ROOT),
+                    portConflicts,
+                    name,
+                    proposedBindingInterface.orElse(ANY_STRING)));
+        }
+    }
+
     private List<Integer> getSortedPortConflicts(Set<Integer> ports, Set<Integer> candidates) {
         return ports.stream().filter(candidates::contains).sorted().collect(Collectors.toList());
     }
diff --git a/sample-install/02-deployment.yaml b/sample-install/02-deployment.yaml
index ddc121a9..a5556b83 100644
--- a/sample-install/02-deployment.yaml
+++ b/sample-install/02-deployment.yaml
@@ -27,7 +27,7 @@ spec:
         imagePullPolicy: Always
         args: ["--config", "/opt/kroxylicious/config/config.yaml"]
         ports:
-        - containerPort: 9193
+        - containerPort: 9190
         - containerPort: 9292
         - containerPort: 9293
         - containerPort: 9294
diff --git a/sample-install/03-service.yaml b/sample-install/03-service.yaml
index c387734d..c952a3ad 100644
--- a/sample-install/03-service.yaml
+++ b/sample-install/03-service.yaml
@@ -13,10 +13,10 @@ spec:
   selector:
     app: kroxylicious
   ports:
-  - name: port-9193
+  - name: port-9190
     protocol: TCP
-    port: 9193
-    targetPort: 9193
+    port: 9190
+    targetPort: 9190
   - name: port-9292
     protocol: TCP
     port: 9292


Test patch:
diff --git a/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/tester/AdminHttpClient.java b/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/tester/AdminHttpClient.java
index bb1c919a..e84c7b4d 100644
--- a/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/tester/AdminHttpClient.java
+++ b/kroxylicious-test-tools/src/main/java/io/kroxylicious/test/tester/AdminHttpClient.java
@@ -31,7 +31,7 @@ public class AdminHttpClient {
 
     public HttpResponse<String> getFromAdminEndpoint(String endpoint) {
         try {
-            HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:9193/" + endpoint)).GET().build();
+            HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:9190/" + endpoint)).GET().build();
             return httpClient.send(request, ofString());
         }
         catch (Exception e) {
diff --git a/kroxylicious/src/test/java/io/kroxylicious/proxy/internal/PortConflictDetectorTest.java b/kroxylicious/src/test/java/io/kroxylicious/proxy/internal/PortConflictDetectorTest.java
index c92d45b6..20babf69 100644
--- a/kroxylicious/src/test/java/io/kroxylicious/proxy/internal/PortConflictDetectorTest.java
+++ b/kroxylicious/src/test/java/io/kroxylicious/proxy/internal/PortConflictDetectorTest.java
@@ -18,6 +18,7 @@ import org.junit.jupiter.params.provider.MethodSource;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import io.kroxylicious.proxy.model.VirtualCluster;
+import io.kroxylicious.proxy.service.HostPort;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -37,78 +38,125 @@ class PortConflictDetectorTest {
                 Arguments.of("any:single conflict",
                         createMockVirtualCluster(Set.of(9080), Set.of(), any),
                         createMockVirtualCluster(Set.of(9080), Set.of(), any),
-                        "The exclusive bind of port(s) 9080 to <any> would conflict with existing exclusive port bindings on <any>."),
+                        "The exclusive bind of port(s) 9080 to <any> would conflict with existing exclusive port bindings on <any>.",
+                        null),
                 Arguments.of("any:many exclusive conflicts",
                         createMockVirtualCluster(Set.of(9080, 9081), Set.of(), any),
                         createMockVirtualCluster(Set.of(9080, 9081), Set.of(), any),
-                        "The exclusive bind of port(s) 9080,9081 to <any> would conflict with existing exclusive port bindings on <any>."),
+                        "The exclusive bind of port(s) 9080,9081 to <any> would conflict with existing exclusive port bindings on <any>.",
+                        null),
                 Arguments.of("any:overlap produces exclusive conflict ",
                         createMockVirtualCluster(Set.of(9080, 9081), Set.of(), any),
                         createMockVirtualCluster(Set.of(9081, 9082), Set.of(), any),
-                        "The exclusive bind of port(s) 9081 to <any> would conflict with existing exclusive port bindings on <any>."),
+                        "The exclusive bind of port(s) 9081 to <any> would conflict with existing exclusive port bindings on <any>.",
+                        null),
                 Arguments.of("loopback:single exclusive conflict",
                         createMockVirtualCluster(Set.of(9080), Set.of(), loopback),
                         createMockVirtualCluster(Set.of(9080), Set.of(), loopback),
-                        "The exclusive bind of port(s) 9080 to 127.0.0.1 would conflict with existing exclusive port bindings on 127.0.0.1."),
+                        "The exclusive bind of port(s) 9080 to 127.0.0.1 would conflict with existing exclusive port bindings on 127.0.0.1.",
+                        null),
                 Arguments.of("loopback/any:single exclusive conflict",
                         createMockVirtualCluster(Set.of(9080), Set.of(), loopback),
                         createMockVirtualCluster(Set.of(9080), Set.of(), any),
-                        "The exclusive bind of port(s) 9080 to <any> would conflict with existing exclusive port bindings on 127.0.0.1."),
+                        "The exclusive bind of port(s) 9080 to <any> would conflict with existing exclusive port bindings on 127.0.0.1.",
+                        null),
                 Arguments.of("any/loopback:single exclusive conflict",
                         createMockVirtualCluster(Set.of(9080), Set.of(), any),
                         createMockVirtualCluster(Set.of(9080), Set.of(), loopback),
-                        "The exclusive bind of port(s) 9080 to 127.0.0.1 would conflict with existing exclusive port bindings on <any>."),
+                        "The exclusive bind of port(s) 9080 to 127.0.0.1 would conflict with existing exclusive port bindings on <any>.",
+                        null),
                 Arguments.of("any/loopback:single shared conflict",
                         createMockVirtualCluster(Set.of(), Set.of(9080), any),
                         createMockVirtualCluster(Set.of(), Set.of(9080), loopback),
-                        "The shared bind of port(s) 9080 to 127.0.0.1 would conflict with existing shared port bindings on <any>."),
+                        "The shared bind of port(s) 9080 to 127.0.0.1 would conflict with existing shared port bindings on <any>.",
+                        null),
                 Arguments.of("loopback/any:single shared conflict",
                         createMockVirtualCluster(Set.of(), Set.of(9080), loopback),
                         createMockVirtualCluster(Set.of(), Set.of(9080), any),
-                        "The shared bind of port(s) 9080 to <any> would conflict with existing shared port bindings on 127.0.0.1."),
+                        "The shared bind of port(s) 9080 to <any> would conflict with existing shared port bindings on 127.0.0.1.",
+                        null),
                 Arguments.of("shared/exclusivity mismatch",
                         createMockVirtualCluster(Set.of(), Set.of(9080), any),
                         createMockVirtualCluster(Set.of(9080), Set.of(), any),
-                        "The exclusive bind of port(s) 9080 to <any> would conflict with existing shared port bindings on <any>."),
+                        "The exclusive bind of port(s) 9080 to <any> would conflict with existing shared port bindings on <any>.",
+                        null),
                 Arguments.of("exclusivity/shared mismatch",
                         createMockVirtualCluster(Set.of(9080), Set.of(), any),
                         createMockVirtualCluster(Set.of(), Set.of(9080), any),
-                        "The shared bind of port(s) 9080 to <any> would conflict with existing exclusive port bindings on <any>."),
+                        "The shared bind of port(s) 9080 to <any> would conflict with existing exclusive port bindings on <any>.",
+                        null),
                 Arguments.of("shared tls mismatch",
                         createMockVirtualCluster(Set.of(), Set.of(9080), any, true),
                         createMockVirtualCluster(Set.of(), Set.of(9080), any, false),
-                        "The shared bind of port 9080 to <any> has conflicting TLS settings with existing port on the same interface."),
+                        "The shared bind of port 9080 to <any> has conflicting TLS settings with existing port on the same interface.",
+                        null),
                 Arguments.of("shared tls mismatch",
                         createMockVirtualCluster(Set.of(), Set.of(9080), loopback, false),
                         createMockVirtualCluster(Set.of(), Set.of(9080), loopback, true),
-                        "The shared bind of port 9080 to 127.0.0.1 has conflicting TLS settings with existing port on the same interface."),
+                        "The shared bind of port 9080 to 127.0.0.1 has conflicting TLS settings with existing port on the same interface.",
+                        null),
                 Arguments.of("different exclusive ports",
                         createMockVirtualCluster(Set.of(9080), Set.of(), any),
                         createMockVirtualCluster(Set.of(9081), Set.of(), any),
+                        null,
                         null),
                 Arguments.of("same shared ports",
                         createMockVirtualCluster(Set.of(), Set.of(9080), any),
                         createMockVirtualCluster(Set.of(), Set.of(9080), any),
+                        null,
                         null),
                 Arguments.of("same shared shared ports different interfaces with differing tls",
                         createMockVirtualCluster(Set.of(), Set.of(9080), loopback, true),
                         createMockVirtualCluster(Set.of(), Set.of(9080), privateUse, false),
+                        null,
                         null),
                 Arguments.of("same exclusive port different interfaces",
                         createMockVirtualCluster(Set.of(9080), Set.of(), privateUse),
                         createMockVirtualCluster(Set.of(9080), Set.of(), loopback),
-                        null));
+                        null,
+                        null),
+                Arguments.of("different exclusive ports, collides with other exclusive port on any interface",
+                        createMockVirtualCluster(Set.of(9080), Set.of(), any),
+                        createMockVirtualCluster(Set.of(9081), Set.of(), any),
+                        "The exclusive bind of port(s) 9080 for virtual cluster 'one' to <any> would conflict with another (non-cluster) port binding",
+                        new HostPort("0.0.0.0", 9080)),
+                Arguments.of("different exclusive ports on any interface, collides with other exclusive port on specific interface",
+                        createMockVirtualCluster(Set.of(9080), Set.of(), any),
+                        createMockVirtualCluster(Set.of(9081), Set.of(), any),
+                        "The exclusive bind of port(s) 9080 for virtual cluster 'one' to <any> would conflict with another (non-cluster) port binding",
+                        new HostPort(loopback, 9080)),
+                Arguments.of("different exclusive ports on specific interface, collides with other exclusive port on any interface",
+                        createMockVirtualCluster(Set.of(9080), Set.of(), loopback),
+                        createMockVirtualCluster(Set.of(9081), Set.of(), loopback),
+                        "The exclusive bind of port(s) 9080 for virtual cluster 'one' to 127.0.0.1 would conflict with another (non-cluster) port binding",
+                        new HostPort("0.0.0.0", 9080)),
+                Arguments.of("same shared ports, collides with other exclusive port on any interface",
+                        createMockVirtualCluster(Set.of(), Set.of(9080), any),
+                        createMockVirtualCluster(Set.of(), Set.of(9080), any),
+                        "The shared bind of port(s) 9080 for virtual cluster 'one' to <any> would conflict with another (non-cluster) port binding",
+                        new HostPort("0.0.0.0", 9080)),
+                Arguments.of("same shared shared ports different interfaces with differing tls, collides with other exclusive port",
+                        createMockVirtualCluster(Set.of(), Set.of(9080), loopback, true),
+                        createMockVirtualCluster(Set.of(), Set.of(9080), privateUse, false),
+                        "The shared bind of port(s) 9080 for virtual cluster 'one' to 127.0.0.1 would conflict with another (non-cluster) port binding",
+                        new HostPort(loopback, 9080)),
+                Arguments.of("same exclusive port different interfaces, collides with other exclusive port",
+                        createMockVirtualCluster(Set.of(9080), Set.of(), privateUse),
+                        createMockVirtualCluster(Set.of(9080), Set.of(), loopback),
+                        "The exclusive bind of port(s) 9080 for virtual cluster 'two' to 127.0.0.1 would conflict with another (non-cluster) port binding",
+                        new HostPort(loopback, 9080)));
     }
 
     @ParameterizedTest(name = "{0}")
     @MethodSource
-    public void portConflict(String name, VirtualCluster virtualCluster1, VirtualCluster virtualCluster2, String expectedMessageSuffix) {
+    public void portConflict(String name, VirtualCluster virtualCluster1, VirtualCluster virtualCluster2, String expectedMessageSuffix, HostPort otherExclusivePort) {
         var clusters = Map.of("one", virtualCluster1, "two", virtualCluster2);
+        Optional<HostPort> maybeOtherExclusivePort = Optional.ofNullable(otherExclusivePort);
         if (expectedMessageSuffix == null) {
-            detector.validate(clusters);
+            detector.validate(clusters, maybeOtherExclusivePort);
         }
         else {
-            var e = assertThrows(IllegalStateException.class, () -> detector.validate(clusters));
+            var e = assertThrows(IllegalStateException.class, () -> detector.validate(clusters, maybeOtherExclusivePort));
             assertThat(e).hasStackTraceContaining(expectedMessageSuffix);
         }
     }