diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index db44441..b560549 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,39 +5,50 @@ on:
pull_request:
branches: [main]
+env:
+ WILDFLY_VERSION: 39.0.1.Final
+ TEST_CLASS: StickySessionTest,SslFailoverTest
+
jobs:
test:
- runs-on: ubuntu-latest
+ runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
- wildfly-version: ['39.0.1.Final']
- balancer-type: [undertow]
+ os: [ubuntu-latest, windows-latest]
+ java: ['17', '21', '25']
+ balancer: [undertow, httpd]
+ exclude:
+ - os: windows-latest
+ java: '17'
+ - os: windows-latest
+ balancer: httpd
+
+ name: "${{ matrix.balancer }} / ${{ matrix.os }} / JDK ${{ matrix.java }}"
steps:
- uses: actions/checkout@v4
- - name: Set up JDK 21
+ - name: Set up JDK ${{ matrix.java }}
uses: actions/setup-java@v4
with:
- java-version: '21'
- distribution: 'temurin'
+ java-version: ${{ matrix.java }}
+ distribution: temurin
cache: 'maven'
- name: Download WildFly via Maven
- run: >
- mvn -B generate-test-resources
- -Pdownload-wildfly
- -Dwildfly.version=${{ matrix.wildfly-version }}
- -DskipTests
-
- - name: Run tests
- run: >
- mvn -B test
- -Dtest=InitialLoadTest
- -DexcludedGroups=none
- -Dbalancer.type=${{ matrix.balancer-type }}
- -Dwildfly.version=${{ matrix.wildfly-version }}
+ shell: bash
+ run: mvn -B generate-test-resources -Pdownload-wildfly -Dwildfly.version=${{ env.WILDFLY_VERSION }} -DskipTests
+
+ - name: Run tests (Docker)
+ if: runner.os != 'Windows'
+ shell: bash
+ run: mvn -B test -Dtest=${{ env.TEST_CLASS }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
+
+ - name: Run tests (Native)
+ if: runner.os == 'Windows'
+ shell: bash
+ run: mvn -B test -Pnative -Dtest=${{ env.TEST_CLASS }} -DexcludedGroups=none -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }}
- name: Publish test results
uses: mikepenz/action-junit-report@v5
@@ -49,5 +60,5 @@ jobs:
uses: actions/upload-artifact@v4
if: failure()
with:
- name: surefire-reports-${{ matrix.balancer-type }}-${{ matrix.wildfly-version }}
+ name: surefire-reports-${{ matrix.os }}-${{ matrix.balancer }}-jdk${{ matrix.java }}
path: target/surefire-reports/
diff --git a/.gitignore b/.gitignore
index 1bc6c9b..53ece17 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ target/
*.log
test-output/
*.tmp
+*/target/
# OS
.DS_Store
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 089bd5b..9ab00db 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -40,6 +40,26 @@ When you run tests with a ZIP:
- **Balancer**: `standalone.sh -Djboss.modcluster.advertise=true`
- **Workers**: `standalone.sh` connecting to `balancer:8090`
+## Test Modes
+
+The test suite supports two execution modes, selected via `-Dtest.mode=` (or the `-Pnative` Maven profile):
+
+### Docker Mode (default)
+
+Each worker and balancer runs in its own Docker/Podman container managed by Testcontainers. Containers share a private Docker network with DNS aliases (`worker1`, `worker2`, `balancer`). All containers use identical ports (8080, 9990, 7600) — networking separates them.
+
+### Native Mode (`-Dtest.mode=native`)
+
+Each worker and balancer runs as a local OS process started via `ProcessBuilder`. All processes share the host network and are distinguished by static port offsets (e.g. worker1 at offset 100, worker2 at offset 200). No container runtime is required.
+
+Key native-mode components:
+- **`NativeProcessManager`** — wraps `ProcessBuilder`/`Process` for lifecycle management (start, stop, kill, process tree cleanup)
+- **`NativeServerExtractor`** — extracts WildFly ZIP to `target/native-servers/{name}/`, backs up clean config for per-test reset
+- **`NativePortAllocator`** — assigns fixed port offsets per worker name
+- **`NativeWildFlyWorker`** — native WildFly worker implementation (extends `WildFlyWorker`)
+- **`NativeUndertowBalancer`** — native Undertow balancer (WildFly process with mod_cluster proxy)
+- **`NativeHttpdBalancer`** — native httpd balancer (JBCS httpd process with mod_proxy_cluster)
+
## Component Architecture
### Test Extension (Dependency Injection)
@@ -287,10 +307,15 @@ pom.xml
└─ Awaitility (async testing)
ModClusterTestExtension.java
- ├─ BalancerContainer.java
- │ ├─ UndertowBalancerContainer
- │ └─ HttpdBalancerContainer
- ├─ WildFlyContainer.java
+ ├─ Balancer (abstract)
+ │ ├─ Docker: UndertowBalancerContainer, HttpdBalancerContainer
+ │ └─ Native: NativeUndertowBalancer, NativeHttpdBalancer
+ ├─ WildFlyWorker (abstract)
+ │ ├─ Docker: DockerWildFlyWorker
+ │ └─ Native: NativeWildFlyWorker
+ ├─ NativeProcessManager (process lifecycle)
+ ├─ NativeServerExtractor (ZIP extraction)
+ ├─ NativePortAllocator (port offsets)
└─ HttpClient.java
Test Classes
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index 19a91ab..08a38b6 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -57,11 +57,17 @@ This is useful for slow CI nodes where container networking or Infinispan rebala
| `test.timeout.cluster` | Cluster formation, worker registration, view convergence | `120` s | `-Dtest.timeout.cluster=180` |
| `test.timeout.failover` | Failover completion after worker kill (includes Infinispan rebalancing) | `120` s | `-Dtest.timeout.failover=180` |
+### Test Mode
+
+| Property | Description | Default | Example |
+|----------|-------------|---------|---------|
+| `test.mode` | Test execution mode: `docker` (containers) or `native` (local processes) | `docker` | `-Dtest.mode=native` |
+
### Test Execution
| Property | Description | Default | Example |
|----------|-------------|---------|---------|
-| `testcontainers.reuse.enable` | Reuse containers between runs | `false` | `-Dtestcontainers.reuse.enable=true` |
+| `testcontainers.reuse.enable` | Reuse containers between runs (Docker mode only) | `false` | `-Dtestcontainers.reuse.enable=true` |
| `test` | Specific test to run | All tests | `-Dtest=StickySessionTest` |
## Environment Variables
@@ -80,6 +86,7 @@ Activate profiles with `-P
First attempts to run openssl inside the container. If the container lacks openssl, - * falls back to running openssl on the host and copying the unencrypted key into the container.
- * - * @param container the httpd container - * @param certPrefix the certificate prefix (e.g., "localhost.server", "node2.server") - * @throws Exception if stripping fails both in-container and on the host + *First attempts to run openssl inside the balancer. If it lacks openssl, + * falls back to running openssl on the host and copying the unencrypted key.
*/ - private void stripKeyPassphrase(final GenericContainer> container, final String certPrefix) throws Exception { - // Try in-container first (some images include openssl) - org.testcontainers.containers.Container.ExecResult result = container.execInContainer( + private void stripKeyPassphraseOnBalancer(final Balancer balancer, final String certPrefix, + final String sslDir) throws Exception { + // Try inside the balancer first (some images include openssl) + CommandResult result = balancer.execCommand( "openssl", "rsa", - "-in", HTTPD_SSL_DIR + "/server.key.pem", - "-out", HTTPD_SSL_DIR + "/server.nopass.key.pem", + "-in", sslDir + "/server.key.pem", + "-out", sslDir + "/server.nopass.key.pem", "-passin", "pass:" + KEYSTORE_PASSWORD); - if (result.getExitCode() == 0) { - log.debug("Key passphrase stripped in container"); + if (result.isSuccess()) { + log.debug("Key passphrase stripped in balancer"); return; } - log.info("Container lacks openssl, stripping passphrase on host"); + log.info("Balancer lacks openssl, stripping passphrase on host"); String keyResource = KEYS_RESOURCE_DIR + certPrefix + ".key.pem"; URL keyUrl = Thread.currentThread().getContextClassLoader().getResource(keyResource); @@ -475,118 +447,111 @@ private void stripKeyPassphrase(final GenericContainer> container, final Strin throw new RuntimeException("Failed to strip key passphrase on host: " + output); } - container.copyFileToContainer( - MountableFile.forHostPath(tempKey.getAbsolutePath(), 0644), - HTTPD_SSL_DIR + "/server.nopass.key.pem"); + balancer.copyLocalFile(tempKey.toPath(), sslDir + "/server.nopass.key.pem"); tempKey.delete(); - log.debug("Key passphrase stripped on host and copied to container"); + log.debug("Key passphrase stripped on host and copied to balancer"); } /** - * Writes a configuration string to a file inside the container. - * - * @param container the target container - * @param content the configuration content - * @param containerPath the destination file path inside the container - * @throws Exception if writing fails + * Writes a configuration string to a file inside the balancer. + * Uses a temp file + copyLocalFile to work on both Docker and native (Windows) balancers. */ - private void writeConfigToContainer(final GenericContainer> container, final String content, - final String containerPath) throws Exception { - // Use sh -c with heredoc to write the config file - org.testcontainers.containers.Container.ExecResult result = container.execInContainer( - "sh", "-c", "cat > " + containerPath + " << 'SSLEOF'\n" + content + "SSLEOF"); - - if (result.getExitCode() != 0) { - throw new RuntimeException("Failed to write config to " + containerPath + ": " + result.getStderr()); + private void writeConfigToBalancer(final Balancer balancer, final String content, + final String destPath) throws Exception { + File tempFile = File.createTempFile("ssl-config-", ".conf"); + tempFile.deleteOnExit(); + try { + Files.writeString(tempFile.toPath(), content); + balancer.copyLocalFile(tempFile.toPath(), destPath); + log.debug("Config written to {}", destPath); + } finally { + tempFile.delete(); } - log.debug("Config written to {}", containerPath); } // ---- Private helpers for keystore operations ---- - private static final int MAX_COPY_RETRIES = 5; - private static final long COPY_RETRY_BASE_DELAY_MS = 500; - /** - * Copies the appropriate server keystore and CA chain trust store into the container. - * Maps container name to the corresponding node keystore: - * worker1 to node1, worker2 to node2, balancer to localhost. - * Uses file mode 0644 so the WildFly process can read the files regardless of container user. - * Retries on transient Podman SIGPIPE errors. + * Copies server keystore and CA chain trust store to a worker. + * + * @param worker the worker to copy keystores to + * @param sslDir the SSL directory path on the worker's filesystem */ - private void copyKeystores(final GenericContainer> container, final String containerName) { - final String nodeName = mapToNodeName(containerName); + private void copyKeystoresToWorker(final WildFlyWorker worker, final String sslDir) { + final String nodeName = mapToNodeName(worker.getName()); final String serverKeystoreResource = KEYSTORES_RESOURCE_DIR + nodeName + ".server.keystore.jks"; final String trustStoreResource = KEYSTORES_RESOURCE_DIR + "ca-chain.keystore.jks"; - log.debug("Copying server keystore '{}' into container '{}'", serverKeystoreResource, containerName); - copyFileWithRetry(container, serverKeystoreResource, SSL_DIR + "/server.keystore.jks"); + log.debug("Copying server keystore '{}' to worker '{}'", serverKeystoreResource, worker.getName()); + worker.copyClasspathResource(serverKeystoreResource, sslDir + "/server.keystore.jks"); - log.debug("Copying CA chain trust store into container '{}'", containerName); - copyFileWithRetry(container, trustStoreResource, SSL_DIR + "/ca-chain.keystore.jks"); + log.debug("Copying CA chain trust store to worker '{}'", worker.getName()); + worker.copyClasspathResource(trustStoreResource, sslDir + "/ca-chain.keystore.jks"); } /** - * Copies server, client, and CA chain trust keystores into the container for mTLS. + * Copies server, client, and CA chain trust keystores to a worker for mTLS. * - * @param container target container - * @param serverKeystore server keystore name prefix (e.g., "node1.server" or "node3.server.revoked") - * @param clientKeystore client keystore name prefix (e.g., "node1.client" or "node4.client.revoked") + * @param worker the worker to copy keystores to + * @param serverKeystore server keystore name prefix + * @param clientKeystore client keystore name prefix + * @param sslDir the SSL directory path on the worker's filesystem */ - private void copyMtlsKeystores(final GenericContainer> container, final String serverKeystore, - final String clientKeystore) { + private void copyMtlsKeystoresToWorker(final WildFlyWorker worker, final String serverKeystore, + final String clientKeystore, final String sslDir) { final String serverResource = KEYSTORES_RESOURCE_DIR + serverKeystore + ".keystore.jks"; final String clientResource = KEYSTORES_RESOURCE_DIR + clientKeystore + ".keystore.jks"; final String trustResource = KEYSTORES_RESOURCE_DIR + "ca-chain.keystore.jks"; log.debug("Copying server keystore '{}'", serverResource); - copyFileWithRetry(container, serverResource, SSL_DIR + "/server.keystore.jks"); + worker.copyClasspathResource(serverResource, sslDir + "/server.keystore.jks"); log.debug("Copying client keystore '{}'", clientResource); - copyFileWithRetry(container, clientResource, SSL_DIR + "/client.keystore.jks"); + worker.copyClasspathResource(clientResource, sslDir + "/client.keystore.jks"); log.debug("Copying CA chain trust store"); - copyFileWithRetry(container, trustResource, SSL_DIR + "/ca-chain.keystore.jks"); + worker.copyClasspathResource(trustResource, sslDir + "/ca-chain.keystore.jks"); } /** - * Copies a classpath resource into the container with retry logic for transient Podman SIGPIPE errors. + * Copies server keystore and CA chain trust store to a balancer (always uses localhost keystore). * - * @param container target container - * @param classpathResource resource path on classpath - * @param containerPath destination path inside the container + * @param balancer the balancer to copy keystores to + * @param sslDir the SSL directory path on the balancer's filesystem */ - private void copyFileWithRetry(final GenericContainer> container, final String classpathResource, - final String containerPath) { - Exception lastException = null; - - for (int attempt = 1; attempt <= MAX_COPY_RETRIES; attempt++) { - try { - container.copyFileToContainer( - MountableFile.forClasspathResource(classpathResource, 0644), - containerPath); - return; - } catch (Exception e) { - lastException = e; - - if (ContainerUtils.isTransientDockerError(e) && attempt < MAX_COPY_RETRIES) { - long delay = COPY_RETRY_BASE_DELAY_MS * attempt; - log.warn("Transient error copying '{}' on attempt {}/{}, retrying after {}ms", - classpathResource, attempt, MAX_COPY_RETRIES, delay); - try { - Thread.sleep(delay); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new RuntimeException("Interrupted during copy retry", ie); - } - } else { - break; - } - } - } + private void copyKeystoresToBalancer(final Balancer balancer, final String sslDir) { + final String serverKeystoreResource = KEYSTORES_RESOURCE_DIR + "localhost.server.keystore.jks"; + final String trustStoreResource = KEYSTORES_RESOURCE_DIR + "ca-chain.keystore.jks"; + + log.debug("Copying server keystore '{}' to balancer", serverKeystoreResource); + balancer.copyClasspathResource(serverKeystoreResource, sslDir + "/server.keystore.jks"); + + log.debug("Copying CA chain trust store to balancer"); + balancer.copyClasspathResource(trustStoreResource, sslDir + "/ca-chain.keystore.jks"); + } + + /** + * Copies server, client, and CA chain trust keystores to a balancer for mTLS. + * + * @param balancer the balancer to copy keystores to + * @param serverKeystore server keystore name prefix + * @param clientKeystore client keystore name prefix + * @param sslDir the SSL directory path on the balancer's filesystem + */ + private void copyMtlsKeystoresToBalancer(final Balancer balancer, final String serverKeystore, + final String clientKeystore, final String sslDir) { + final String serverResource = KEYSTORES_RESOURCE_DIR + serverKeystore + ".keystore.jks"; + final String clientResource = KEYSTORES_RESOURCE_DIR + clientKeystore + ".keystore.jks"; + final String trustResource = KEYSTORES_RESOURCE_DIR + "ca-chain.keystore.jks"; + + log.debug("Copying server keystore '{}'", serverResource); + balancer.copyClasspathResource(serverResource, sslDir + "/server.keystore.jks"); + + log.debug("Copying client keystore '{}'", clientResource); + balancer.copyClasspathResource(clientResource, sslDir + "/client.keystore.jks"); - throw new RuntimeException("Failed to copy '" + classpathResource + "' after " + MAX_COPY_RETRIES + " attempts", - lastException); + log.debug("Copying CA chain trust store"); + balancer.copyClasspathResource(trustResource, sslDir + "/ca-chain.keystore.jks"); } /** @@ -608,10 +573,11 @@ private String mapToNodeName(final String containerName) { * Creates Elytron key-store, key-manager, trust-manager, and server-ssl-context resources. * Used for server-only SSL (no client certificate, no mTLS). * - * @param ops Creaper operations handle + * @param ops Creaper operations handle + * @param sslDir the SSL directory path in the WildFly management model * @throws Exception if any management operation fails */ - private void createElytronResources(final Operations ops) throws Exception { + private void createElytronResources(final Operations ops, final String sslDir) throws Exception { final ModelNode credentialRef = new ModelNode(); credentialRef.get("clear-text").set(KEYSTORE_PASSWORD); @@ -619,7 +585,7 @@ private void createElytronResources(final Operations ops) throws Exception { final Address trustKeyStoreAddr = Address.subsystem("elytron").and("key-store", "trustKeyStore"); if (!ops.exists(trustKeyStoreAddr)) { log.debug("Creating trustKeyStore key-store"); - ops.add(trustKeyStoreAddr, Values.of("path", SSL_DIR + "/ca-chain.keystore.jks") + ops.add(trustKeyStoreAddr, Values.of("path", sslDir + "/ca-chain.keystore.jks") .and("credential-reference", credentialRef) .and("type", "JKS")) .assertSuccess(); @@ -637,7 +603,7 @@ private void createElytronResources(final Operations ops) throws Exception { final Address serverKeyStoreAddr = Address.subsystem("elytron").and("key-store", "serverKeyStore"); if (!ops.exists(serverKeyStoreAddr)) { log.debug("Creating serverKeyStore key-store"); - ops.add(serverKeyStoreAddr, Values.of("path", SSL_DIR + "/server.keystore.jks") + ops.add(serverKeyStoreAddr, Values.of("path", sslDir + "/server.keystore.jks") .and("credential-reference", credentialRef) .and("type", "JKS")) .assertSuccess(); @@ -667,10 +633,11 @@ private void createElytronResources(final Operations ops) throws Exception { * server key-store/key-manager with need-client-auth, client key-store/key-manager, * server-ssl-context, and client-ssl-context. * - * @param ops Creaper operations handle + * @param ops Creaper operations handle + * @param sslDir the SSL directory path in the WildFly management model * @throws Exception if any management operation fails */ - private void createMtlsElytronResources(final Operations ops) throws Exception { + private void createMtlsElytronResources(final Operations ops, final String sslDir) throws Exception { final ModelNode credentialRef = new ModelNode(); credentialRef.get("clear-text").set(KEYSTORE_PASSWORD); @@ -678,7 +645,7 @@ private void createMtlsElytronResources(final Operations ops) throws Exception { final Address trustKeyStoreAddr = Address.subsystem("elytron").and("key-store", "trustKeyStore"); if (!ops.exists(trustKeyStoreAddr)) { log.debug("Creating trustKeyStore key-store"); - ops.add(trustKeyStoreAddr, Values.of("path", SSL_DIR + "/ca-chain.keystore.jks") + ops.add(trustKeyStoreAddr, Values.of("path", sslDir + "/ca-chain.keystore.jks") .and("credential-reference", credentialRef) .and("type", "JKS")) .assertSuccess(); @@ -696,7 +663,7 @@ private void createMtlsElytronResources(final Operations ops) throws Exception { final Address serverKeyStoreAddr = Address.subsystem("elytron").and("key-store", "serverKeyStore"); if (!ops.exists(serverKeyStoreAddr)) { log.debug("Creating serverKeyStore key-store"); - ops.add(serverKeyStoreAddr, Values.of("path", SSL_DIR + "/server.keystore.jks") + ops.add(serverKeyStoreAddr, Values.of("path", sslDir + "/server.keystore.jks") .and("credential-reference", credentialRef) .and("type", "JKS")) .assertSuccess(); @@ -725,7 +692,7 @@ private void createMtlsElytronResources(final Operations ops) throws Exception { final Address clientKeyStoreAddr = Address.subsystem("elytron").and("key-store", "clientKeyStore"); if (!ops.exists(clientKeyStoreAddr)) { log.debug("Creating clientKeyStore key-store"); - ops.add(clientKeyStoreAddr, Values.of("path", SSL_DIR + "/client.keystore.jks") + ops.add(clientKeyStoreAddr, Values.of("path", sslDir + "/client.keystore.jks") .and("credential-reference", credentialRef) .and("type", "JKS")) .assertSuccess(); @@ -799,16 +766,27 @@ private void configureMcmpOverSslOnUndertowBalancer(final Operations ops) throws * Writes the certificate-revocation-list attribute on the trust-manager * to enable CRL checking. * - * @param ops Creaper operations handle + * @param ops Creaper operations handle + * @param sslDir the SSL directory path in the WildFly management model * @throws Exception if the management operation fails */ - private void writeCrlAttribute(final Operations ops) throws Exception { + private void writeCrlAttribute(final Operations ops, final String sslDir) throws Exception { final Address trustManagerAddr = Address.subsystem("elytron").and("trust-manager", "trustStoreManager"); final ModelNode crlValue = new ModelNode(); - crlValue.get("path").set(SSL_DIR + "/intermediate.crl.pem"); + crlValue.get("path").set(sslDir + "/intermediate.crl.pem"); log.debug("Setting certificate-revocation-list on trustStoreManager"); ops.writeAttribute(trustManagerAddr, "certificate-revocation-list", crlValue).assertSuccess(); } + + /** + * Compute the SSL directory path from a server home directory. + * + * @param serverHome the server home directory (e.g. {@code "/opt/wildfly"}) + * @return the SSL directory path (e.g. {@code "/opt/wildfly/standalone/configuration/ssl"}) + */ + private static String sslDir(final String serverHome) { + return Path.of(serverHome, SSL_SUBPATH).toString(); + } } diff --git a/src/test/java/org/jboss/modcluster/test/ssl/SslFailoverTest.java b/src/test/java/org/jboss/modcluster/test/ssl/SslFailoverTest.java index cdc58e0..0fb800e 100644 --- a/src/test/java/org/jboss/modcluster/test/ssl/SslFailoverTest.java +++ b/src/test/java/org/jboss/modcluster/test/ssl/SslFailoverTest.java @@ -8,7 +8,7 @@ import org.jboss.modcluster.test.utils.HttpClient; import org.jboss.modcluster.test.utils.HttpClient.HttpResponse; import org.jboss.modcluster.test.utils.TestTimeouts; -import org.jboss.modcluster.test.utils.WildFlyContainer; +import org.jboss.modcluster.test.utils.WildFlyWorker; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.slf4j.Logger; @@ -46,7 +46,7 @@ public class SslFailoverTest { */ @Test public void testSslFailoverViaShutdown(final TestCluster cluster, final HttpClient httpClient) throws Exception { - sslFailoverPattern(cluster, httpClient, WildFlyContainer::stop, "shutdown"); + sslFailoverPattern(cluster, httpClient, WildFlyWorker::stop, "shutdown"); } /** @@ -55,7 +55,7 @@ public void testSslFailoverViaShutdown(final TestCluster cluster, final HttpClie */ @Test public void testSslFailoverViaKill(final TestCluster cluster, final HttpClient httpClient) throws Exception { - sslFailoverPattern(cluster, httpClient, WildFlyContainer::kill, "kill"); + sslFailoverPattern(cluster, httpClient, WildFlyWorker::kill, "kill"); } /** @@ -120,8 +120,8 @@ private void sslFailoverPattern(final TestCluster cluster, final HttpClient http log.info("Iteration {}: session established on {} (JSESSIONID={})", iteration, initialWorker, sessionId); // Trigger failure on session-holder worker - final WildFlyContainer failedWorker = cluster.getWorkerByName(initialWorker); - final WildFlyContainer survivingWorker = getOtherWorker(cluster, initialWorker); + final WildFlyWorker failedWorker = cluster.getWorkerByName(initialWorker); + final WildFlyWorker survivingWorker = getOtherWorker(cluster, initialWorker); log.info("Iteration {}: executing {} on {}", iteration, actionName, initialWorker); failureAction.execute(failedWorker); @@ -137,15 +137,6 @@ private void sslFailoverPattern(final TestCluster cluster, final HttpClient http .isEqualTo(survivingWorker.getName()); }); - // Verify session ID is preserved after failover - final HttpResponse afterFailover = httpClient.getHttpsTrustedWithSession(httpsUrl, "JSESSIONID=" + sessionId); - softly.assertThat(afterFailover.getStatusCode()) - .as("Iteration %d: post-failover HTTPS request should succeed", iteration) - .isEqualTo(200); - softly.assertThat(extractWorkerFromResponse(afterFailover)) - .as("Iteration %d: different worker should handle request after failover", iteration) - .isNotEqualTo(initialWorker); - log.info("Iteration {}: HTTPS failover completed to {}", iteration, survivingWorker.getName()); // Restore worker for next iteration @@ -162,7 +153,7 @@ private void sslFailoverPattern(final TestCluster cluster, final HttpClient http * For undeploy: redeploys the demo app. * For stop/kill: restarts the container and reconfigures SSL. */ - private void restoreWorker(final WildFlyContainer worker, final SSLConfigurator sslConfigurator, + private void restoreWorker(final WildFlyWorker worker, final SSLConfigurator sslConfigurator, final String actionName, final TestCluster cluster) throws Exception { if ("undeploy".equals(actionName)) { log.debug("Re-deploying demo.war on {}", worker.getName()); @@ -209,9 +200,9 @@ private String extractWorkerFromResponse(final HttpResponse response) { * * @param cluster test cluster * @param workerName name of the current worker - * @return WildFlyContainer for the other worker + * @return WildFlyWorker for the other worker */ - private WildFlyContainer getOtherWorker(final TestCluster cluster, final String workerName) { + private WildFlyWorker getOtherWorker(final TestCluster cluster, final String workerName) { switch (workerName) { case "worker1": return cluster.getWorker2(); @@ -234,6 +225,6 @@ private interface FailureAction { * @param worker worker to act on * @throws Exception if the action fails */ - void execute(WildFlyContainer worker) throws Exception; + void execute(WildFlyWorker worker) throws Exception; } } diff --git a/src/test/java/org/jboss/modcluster/test/utils/CommandResult.java b/src/test/java/org/jboss/modcluster/test/utils/CommandResult.java new file mode 100644 index 0000000..1de3c06 --- /dev/null +++ b/src/test/java/org/jboss/modcluster/test/utils/CommandResult.java @@ -0,0 +1,46 @@ +package org.jboss.modcluster.test.utils; + +/** + * Platform-independent result of executing a command. + * Replaces Testcontainers' {@code Container.ExecResult} in the abstract API. + */ +public class CommandResult { + + private final int exitCode; + private final String stdout; + private final String stderr; + + public CommandResult(int exitCode, String stdout, String stderr) { + this.exitCode = exitCode; + this.stdout = stdout != null ? stdout : ""; + this.stderr = stderr != null ? stderr : ""; + } + + /** Process exit code (0 = success). */ + public int getExitCode() { + return exitCode; + } + + /** Standard output content (never null). */ + public String getStdout() { + return stdout; + } + + /** Standard error content (never null). */ + public String getStderr() { + return stderr; + } + + /** Whether the command exited successfully (exit code 0). */ + public boolean isSuccess() { + return exitCode == 0; + } + + @Override + public String toString() { + return "CommandResult{exitCode=" + exitCode + + ", stdout='" + (stdout.length() > 100 ? stdout.substring(0, 100) + "..." : stdout) + "'" + + ", stderr='" + (stderr.length() > 100 ? stderr.substring(0, 100) + "..." : stderr) + "'" + + '}'; + } +} diff --git a/src/test/java/org/jboss/modcluster/test/utils/ContainerUtils.java b/src/test/java/org/jboss/modcluster/test/utils/ContainerUtils.java index 08d71e2..b727bb9 100644 --- a/src/test/java/org/jboss/modcluster/test/utils/ContainerUtils.java +++ b/src/test/java/org/jboss/modcluster/test/utils/ContainerUtils.java @@ -26,6 +26,7 @@ public final class ContainerUtils { private ContainerUtils() { } + /** Set JAVA_HOME on the container if {@code container.java.home} system property is configured. */ public static void applyJavaHomeIfNeeded(GenericContainer> container) { String javaHome = System.getProperty("container.java.home"); if (javaHome != null && !javaHome.isEmpty()) { @@ -255,6 +256,38 @@ public static void copyFileToContainerWithRetry(final GenericContainer> contai } } + /** + * Retry an operation that must succeed, throwing on final failure. + * + * @param action the action to run + * @param label human-readable label for log messages + * @param maxRetries maximum number of attempts + */ + public static void retryOrThrow(Runnable action, String label, int maxRetries) { + Exception lastException = null; + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + action.run(); + return; + } catch (Exception e) { + lastException = e; + if (isTransientDockerError(e) && attempt < maxRetries) { + log.warn("{} failed with transient error (attempt {}/{}), retrying: {}", + label, attempt, maxRetries, e.getMessage()); + try { + Thread.sleep(500L * attempt); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during retry for " + label, ie); + } + } else { + break; + } + } + } + throw new RuntimeException("Failed: " + label + " after " + maxRetries + " attempts", lastException); + } + /** * Force-disconnect and force-remove all containers from a Docker/Podman network. * Used as a safety net before closing a network to ensure {@code network.close()} succeeds. diff --git a/src/test/java/org/jboss/modcluster/test/utils/DockerWildFlyWorker.java b/src/test/java/org/jboss/modcluster/test/utils/DockerWildFlyWorker.java new file mode 100644 index 0000000..1a68924 --- /dev/null +++ b/src/test/java/org/jboss/modcluster/test/utils/DockerWildFlyWorker.java @@ -0,0 +1,344 @@ +package org.jboss.modcluster.test.utils; + +import org.jboss.modcluster.test.utils.balancer.Balancer; +import org.jboss.modcluster.test.utils.balancer.DockerBalancer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.MountableFile; + +import java.nio.file.Path; +import java.time.Duration; + +import static java.time.Duration.ofSeconds; +import static java.time.Duration.ofMillis; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Docker/Testcontainers-based WildFly worker implementation. + * Runs WildFly inside a Docker/Podman container. + */ +public class DockerWildFlyWorker extends WildFlyWorker { + + private static final Logger log = LoggerFactory.getLogger(DockerWildFlyWorker.class); + + private static final int HTTP_PORT = 8080; + private static final int HTTPS_PORT = 8443; + private static final int MANAGEMENT_PORT = 9990; + private static final int JGROUPS_TCP_PORT = 7600; + private static final int JGROUPS_FD_PORT = 57600; + + private GenericContainer> container; + + DockerWildFlyWorker(String name, Balancer balancer) { + super(name, balancer); + } + + /** + * Get the underlying Docker container. Only available on Docker-based workers. + * Use for Docker-specific operations that cannot be expressed through the abstract API. + */ + public GenericContainer> getDockerContainer() { + return container; + } + + private DockerBalancer getDockerBalancer() { + return (DockerBalancer) getBalancer(); + } + + @Override + public void start() { + Path zipPath = ContainerUtils.getWildFlyZipPath(); + + if (zipPath != null && zipPath.toFile().exists()) { + log.info("Building WildFly container from ZIP: {}", zipPath); + startFromZip(zipPath); + } else { + log.info("No ZIP provided, using pre-built container image"); + startFromImage(); + } + } + + private void startFromZip(Path zipPath) { + String imageTag = ImageBuilder.ensureImage(zipPath); + startFromPreBuiltImage(imageTag); + } + + private void startFromImage() { + String wildflyVersion = System.getProperty("wildfly.version", "31.0.1.Final"); + String imageName = "quay.io/wildfly/wildfly:" + wildflyVersion; + startFromPreBuiltImage(imageName); + } + + /** + * Start container from a pre-built image (either from registry or locally built). + * Includes optimized retry logic for transient Podman socket errors (SIGPIPE). + */ + private void startFromPreBuiltImage(String imageName) { + ContainerUtils.startWithRetry(() -> { + container = new GenericContainer<>(imageName) + .withNetwork(getDockerBalancer().getNetwork()) + .withNetworkAliases(getName()) + .withCreateContainerCmdModifier(cmd -> cmd.withHostName(getName())) + .withExposedPorts(HTTP_PORT, HTTPS_PORT, MANAGEMENT_PORT, JGROUPS_TCP_PORT, JGROUPS_FD_PORT) + .withEnv("JAVA_OPTS", javaOpts != null ? javaOpts : System.getProperty("wildfly.java.opts")) + .withCommand("/opt/wildfly/bin/standalone.sh", + "-b", "0.0.0.0", + "-bmanagement", "0.0.0.0", + "-bprivate", "0.0.0.0", + "-Djboss.node.name=" + getName(), + "-Djboss.server.default.config=standalone-ha.xml", + "-Djboss.modcluster.multicast.address=224.0.1.105", + "-Djboss.modcluster.multicast.port=23364") + .waitingFor(Wait.forLogMessage(".*" + STARTUP_LOG_PATTERN + ".*", 1) + .withStartupTimeout(Duration.ofMinutes(5))) + .withLogConsumer(outputFrame -> + System.out.println("[" + getName().toUpperCase() + "] " + outputFrame.getUtf8String().trim())); + + ContainerUtils.applyJavaHomeIfNeeded(container); + container.start(); + log.info("WildFly worker '{}' started", getName()); + + // Configure JGroups TCP for container-based clustering + // (UDP multicast discovery does not work in Docker/Podman networks) + jgroups().configureTcpDiscovery(); + reloadServer(); // apply JGroups TCP config + modCluster().configureStaticProxy(); // create outbound-socket-binding + proxies + reloadServer(); // make proxy config effective + deployment().deployDemoApp(); + }, () -> { + if (container != null) { + try { + container.close(); + } catch (Exception e) { + log.debug("Error during cleanup: {}", e.getMessage()); + } + container = null; + } + }, "WildFly worker '" + getName() + "'"); + } + + @Override + public void stop() { + closeManagementClient(); + + if (container != null) { + String containerId = container.getContainerId(); + + // Step 1: Disconnect from network FIRST + if (containerId != null && getBalancer() != null && getDockerBalancer().getNetwork() != null) { + ContainerUtils.retryOnTransientError(() -> + container.getDockerClient() + .disconnectFromNetworkCmd() + .withContainerId(containerId) + .withNetworkId(getDockerBalancer().getNetwork().getId()) + .withForce(true) + .exec(), + "disconnect worker '" + getName() + "' from network", 3); + } + + // Step 2: Stop container + ContainerUtils.retryOnTransientError(() -> { + if (container.isRunning()) { + container.stop(); + log.info("WildFly worker '{}' stopped", getName()); + } + }, "stop worker '" + getName() + "'", 3); + + // Step 3: Remove container + if (containerId != null) { + ContainerUtils.retryOnTransientError(() -> + container.getDockerClient() + .removeContainerCmd(containerId) + .withForce(true) + .exec(), + "remove worker '" + getName() + "'", 3); + } + + container = null; + clearCachedManagers(); + } + } + + @Override + public void kill() throws Exception { + closeManagementClient(); + + if (container == null) return; + + try { + // isRunning() itself can throw on Podman socket errors — treat that as "maybe running" + boolean running; + try { + running = container.isRunning(); + } catch (Exception e) { + if (ContainerUtils.isTransientDockerError(e)) { + log.warn("Podman socket error checking isRunning() for '{}', will attempt SIGKILL anyway: {}", + getName(), e.getMessage()); + running = true; // assume running, try to kill + } else { + throw e; + } + } + + if (!running) { + log.info("WildFly worker '{}' container already stopped", getName()); + return; + } + + String containerId = container.getContainerId(); + + // SIGKILL with retry — Podman socket can SIGPIPE transiently + ContainerUtils.retryOrThrow(() -> + container.getDockerClient() + .killContainerCmd(containerId) + .withSignal("KILL") + .exec(), + "kill worker '" + getName() + "'", 3); + log.info("WildFly worker '{}' killed (hard stop)", getName()); + + // Verify container is actually dead (Podman may need a moment after SIGKILL) + await().atMost(ofSeconds(10)) + .pollInterval(ofMillis(500)) + .untilAsserted(() -> + assertThat(container.isRunning()) + .as("Container for worker '%s' should be dead after SIGKILL", getName()) + .isFalse() + ); + } finally { + if (container != null) { + String containerId = container.getContainerId(); + + // Disconnect from network before cleanup — prevents MCMP contamination + if (containerId != null && getBalancer() != null && getDockerBalancer().getNetwork() != null) { + ContainerUtils.retryOnTransientError(() -> + container.getDockerClient() + .disconnectFromNetworkCmd() + .withContainerId(containerId) + .withNetworkId(getDockerBalancer().getNetwork().getId()) + .withForce(true) + .exec(), + "disconnect killed worker '" + getName() + "' from network", 3); + } + + // Force-remove the dead container (no need for SIGTERM via stop()) + if (containerId != null) { + ContainerUtils.retryOnTransientError(() -> + container.getDockerClient() + .removeContainerCmd(containerId) + .withForce(true) + .exec(), + "remove killed worker '" + getName() + "'", 3); + } + } + container = null; + clearCachedManagers(); + } + } + + @Override + public boolean isRunning() { + return container != null && container.isRunning(); + } + + @Override + public String getHttpUrl() { + return "http://" + container.getHost() + ":" + container.getMappedPort(HTTP_PORT); + } + + @Override + public String getHttpsUrl() { + return "https://" + container.getHost() + ":" + container.getMappedPort(HTTPS_PORT); + } + + @Override + public String getManagementUrl() { + return "http://" + container.getHost() + ":" + container.getMappedPort(MANAGEMENT_PORT); + } + + @Override + public String getInternalHttpUrl() { + return "http://" + getName() + ":" + HTTP_PORT; + } + + @Override + public String getServerHome() { + return "/opt/wildfly"; + } + + @Override + public String getTempDirectory() { + return "/tmp"; + } + + @Override + public String getProxyHost() { + return "balancer"; + } + + @Override + protected String getManagementHost() { + return container.getHost(); + } + + @Override + protected int getManagementPort() { + return container.getMappedPort(MANAGEMENT_PORT); + } + + @Override + public CommandResult execCommand(String... command) throws Exception { + Container.ExecResult result = container.execInContainer(command); + return new CommandResult(result.getExitCode(), result.getStdout(), result.getStderr()); + } + + @Override + public void copyClasspathResource(String classpathResource, String destPath) { + ContainerUtils.retryOrThrow(() -> + container.copyFileToContainer( + MountableFile.forClasspathResource(classpathResource, 0644), + destPath), + "copy classpath resource '" + classpathResource + "' to worker '" + getName() + "'", 5); + } + + @Override + public void copyLocalFile(Path hostPath, String destPath) { + ContainerUtils.retryOrThrow(() -> + container.copyFileToContainer( + MountableFile.forHostPath(hostPath), + destPath), + "copy local file '" + hostPath + "' to worker '" + getName() + "'", 5); + } + + @Override + public String readFile(String path) throws Exception { + Container.ExecResult result = container.execInContainer("cat", path); + return result.getStdout(); + } + + @Override + public String getServerLog() throws Exception { + Container.ExecResult result = container.execInContainer( + "cat", "/opt/wildfly/standalone/log/server.log"); + return result.getStdout(); + } + + @Override + public String getServerLog(int lines) throws Exception { + Container.ExecResult result = container.execInContainer( + "sh", "-c", + String.format("tail -%d /opt/wildfly/standalone/log/server.log 2>/dev/null || echo 'Log file not found'", lines)); + return result.getStdout(); + } + + @Override + public String grepServerLog(String pattern) throws Exception { + Container.ExecResult result = container.execInContainer( + "sh", "-c", + String.format("grep -i '%s' /opt/wildfly/standalone/log/server.log || echo 'No matches found'", pattern)); + return result.getStdout(); + } +} diff --git a/src/test/java/org/jboss/modcluster/test/utils/HttpClient.java b/src/test/java/org/jboss/modcluster/test/utils/HttpClient.java index 9d19f29..31b53a5 100644 --- a/src/test/java/org/jboss/modcluster/test/utils/HttpClient.java +++ b/src/test/java/org/jboss/modcluster/test/utils/HttpClient.java @@ -1,6 +1,7 @@ package org.jboss.modcluster.test.utils; import okhttp3.OkHttpClient; +import okhttp3.Protocol; import okhttp3.Request; import okhttp3.Response; import org.slf4j.Logger; @@ -16,6 +17,7 @@ import java.security.KeyStore; import java.security.cert.X509Certificate; import java.time.Duration; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -58,6 +60,9 @@ public HttpResponse get(String url) throws IOException { * Perform a GET request with custom headers. */ public HttpResponse get(String url, MapThe ZIP is expected to contain a directory tree with {@code sbin/httpd} and * {@code modules/}. The builder auto-detects the httpd root, symlinks it to - * {@code /usr/local/apache2} (the path expected by {@link BalancerContainer}), + * {@code /usr/local/apache2} (the path expected by {@link Balancer}), * and ensures {@code bin/httpd} and {@code conf/httpd.conf} exist. */ private static String buildFromZip(File zipFile) { diff --git a/src/test/java/org/jboss/modcluster/test/utils/NativePortAllocator.java b/src/test/java/org/jboss/modcluster/test/utils/NativePortAllocator.java new file mode 100644 index 0000000..eb1b8cb --- /dev/null +++ b/src/test/java/org/jboss/modcluster/test/utils/NativePortAllocator.java @@ -0,0 +1,159 @@ +package org.jboss.modcluster.test.utils; + +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Static port allocation for native (non-Docker) test mode. + * + *
In Docker mode, each container has its own network namespace, so all workers + * use identical ports (8080, 9990, 7600, etc.) and are distinguished by hostname. + * In native mode, all processes share the host network namespace, so each worker + * must use unique ports. + * + *
WildFly's {@code -Djboss.socket.binding.port-offset=N} shifts every + * socket-binding port by {@code N}. This class assigns a fixed offset per worker name: + * + *
| Instance | Offset | HTTP | HTTPS | Management | JGroups TCP | JGroups FD |
|---|---|---|---|---|---|---|
| balancer | 0 | 8080 | 8443 | 9990 | — | — |
| worker1 | 100 | 8180 | 8543 | 10090 | 7700 | 57700 |
| worker2 | 200 | 8280 | 8643 | 10190 | 7800 | 57800 |
| worker3 | 300 | 8380 | 8743 | 10290 | 7900 | 57900 |
| worker4 | 400 | 8480 | 8843 | 10390 | 8000 | 58000 |
Offsets are static (not dynamic) because JGroups TCPPING requires
+ * {@code initial_hosts} to be configured before the server starts — dynamic
+ * port discovery is not feasible with TCPPING.
+ *
+ * @see TestMode
+ */
+public final class NativePortAllocator {
+
+ /** Base HTTP port before offset. */
+ private static final int BASE_HTTP = 8080;
+
+ /** Base HTTPS port before offset. */
+ private static final int BASE_HTTPS = 8443;
+
+ /** Base management port before offset. */
+ private static final int BASE_MANAGEMENT = 9990;
+
+ /** Base JGroups TCP port before offset. */
+ private static final int BASE_JGROUPS_TCP = 7600;
+
+ /** Base JGroups failure-detection (FD_SOCK2) port before offset. */
+ private static final int BASE_JGROUPS_FD = 57600;
+
+ /** MCMP port for httpd — no offset needed since httpd is a single process. */
+ public static final int HTTPD_MCMP_PORT = 8090;
+
+ /** Maximum number of workers supported. */
+ private static final int MAX_WORKERS = 4;
+
+ /** Port offset step between consecutive workers. */
+ private static final int OFFSET_STEP = 100;
+
+ private static final Map All workers run on {@code localhost}, so the hosts string uses
+ * offset ports to distinguish them. Example for 4 workers:
+ * {@code "localhost[7700],localhost[7800],localhost[7900],localhost[8000]"}
+ *
+ * In Docker mode, the equivalent would be
+ * {@code "worker1[7600],worker2[7600],..."} (same port, different hostnames).
+ *
+ * @return comma-separated TCPPING initial_hosts value
+ */
+ public static String tcppingInitialHosts() {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 1; i <= MAX_WORKERS; i++) {
+ if (sb.length() > 0) {
+ sb.append(",");
+ }
+ sb.append("localhost[").append(jgroupsTcpPort("worker" + i)).append("]");
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/test/java/org/jboss/modcluster/test/utils/NativeProcessManager.java b/src/test/java/org/jboss/modcluster/test/utils/NativeProcessManager.java
new file mode 100644
index 0000000..12c4091
--- /dev/null
+++ b/src/test/java/org/jboss/modcluster/test/utils/NativeProcessManager.java
@@ -0,0 +1,321 @@
+package org.jboss.modcluster.test.utils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Manages a native OS process (WildFly server or httpd) for the native test mode.
+ *
+ * Wraps {@link ProcessBuilder} and {@link Process} to provide:
+ * Process stdout and stderr are merged and redirected to a log file in
+ * the working directory ({@code process-output.log}). This file is used by
+ * {@link #waitForStartup(String, Duration)} and can be read for diagnostics.
+ *
+ * Thread safety: instances are not thread-safe. The static shutdown hook
+ * uses a thread-safe list to track all active processes.
+ *
+ * @see TestMode
+ * @see NativePortAllocator
+ */
+public class NativeProcessManager {
+
+ private static final Logger log = LoggerFactory.getLogger(NativeProcessManager.class);
+
+ /** All active processes, tracked for shutdown hook cleanup. */
+ private static final List Stdout and stderr are merged and redirected to {@code process-output.log}
+ * in the working directory. The process is registered with the global shutdown
+ * hook for automatic cleanup.
+ *
+ * @throws IOException if the process cannot be started
+ * @throws IllegalStateException if the process is already running
+ */
+ public void start() throws IOException {
+ if (process != null && process.isAlive()) {
+ throw new IllegalStateException("Process '" + name + "' is already running (pid "
+ + process.pid() + ")");
+ }
+
+ ProcessBuilder pb = new ProcessBuilder(command)
+ .directory(workDir.toFile())
+ .redirectErrorStream(true)
+ .redirectOutput(outputLog.toFile());
+
+ pb.environment().putAll(environment);
+
+ log.info("Starting '{}': {} (workDir={})", name, String.join(" ", command), workDir);
+ process = pb.start();
+ TRACKED_PROCESSES.add(process);
+ log.info("Process '{}' started (pid {}), output -> {}", name, process.pid(), outputLog);
+ }
+
+ /**
+ * Wait for a log pattern to appear in the process output, indicating successful startup.
+ *
+ * Polls the output log file at 1-second intervals, checking each new line
+ * for the given pattern (substring match). Returns as soon as the pattern is found.
+ *
+ * @param pattern substring to search for in each log line (e.g. "WFLYSRV0025")
+ * @param timeout maximum time to wait for the pattern
+ * @throws RuntimeException if the pattern is not found within the timeout,
+ * or if the process exits before the pattern appears
+ */
+ public void waitForStartup(String pattern, Duration timeout) {
+ log.info("Waiting for '{}' startup pattern '{}' (timeout: {})", name, pattern, timeout);
+ long deadline = System.currentTimeMillis() + timeout.toMillis();
+
+ while (System.currentTimeMillis() < deadline) {
+ if (!process.isAlive()) {
+ String logContent = readOutputLog();
+ throw new RuntimeException("Process '" + name + "' exited with code "
+ + process.exitValue() + " before startup pattern '" + pattern
+ + "' appeared. Output:\n" + logContent);
+ }
+
+ String logContent = readOutputLog();
+ if (logContent.contains(pattern)) {
+ log.info("Startup pattern '{}' detected for '{}'", pattern, name);
+ return;
+ }
+
+ try {
+ Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted waiting for '" + name + "' startup", e);
+ }
+ }
+
+ String logContent = readOutputLog();
+ throw new RuntimeException("Timeout waiting for '" + name + "' startup pattern '"
+ + pattern + "' after " + timeout + ". Output:\n" + logContent);
+ }
+
+ /**
+ * Gracefully stop the process and its entire process tree.
+ *
+ * On Windows, batch scripts (e.g. {@code standalone.bat}) spawn child processes
+ * (e.g. {@code java.exe}) that are not killed when the parent cmd.exe is destroyed.
+ * This method kills all descendants first, then the root process.
+ *
+ * Waits up to 30 seconds for the process to exit. If it does not exit
+ * within that time, it is forcibly destroyed.
+ */
+ public void stop() {
+ if (process == null || !process.isAlive()) {
+ log.debug("Process '{}' is not running, nothing to stop", name);
+ return;
+ }
+
+ log.info("Stopping process '{}' (pid {})", name, process.pid());
+ destroyProcessTree(process);
+
+ try {
+ boolean exited = process.waitFor(30, TimeUnit.SECONDS);
+ if (!exited) {
+ log.warn("Process '{}' did not exit within 30s after tree kill", name);
+ process.destroyForcibly();
+ process.waitFor(10, TimeUnit.SECONDS);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ process.destroyForcibly();
+ }
+
+ TRACKED_PROCESSES.remove(process);
+ log.info("Process '{}' stopped", name);
+ }
+
+ /**
+ * Forcibly kill the process and its entire process tree.
+ *
+ * Does not wait for graceful shutdown — all processes in the tree are
+ * destroyed immediately. Use {@link #stop()} for graceful shutdown.
+ */
+ public void kill() {
+ if (process == null || !process.isAlive()) {
+ log.debug("Process '{}' is not running, nothing to kill", name);
+ return;
+ }
+
+ log.info("Killing process '{}' (pid {})", name, process.pid());
+ destroyProcessTree(process);
+
+ try {
+ process.waitFor(10, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+
+ TRACKED_PROCESSES.remove(process);
+ log.info("Process '{}' killed", name);
+ }
+
+ /**
+ * Check whether the process is currently running.
+ *
+ * @return {@code true} if the process has been started and has not yet exited
+ */
+ public boolean isRunning() {
+ return process != null && process.isAlive();
+ }
+
+ /**
+ * Get the path to the combined stdout/stderr output log.
+ *
+ * @return path to the process output log file
+ */
+ public Path getOutputLog() {
+ return outputLog;
+ }
+
+ /**
+ * Read the full contents of the process output log.
+ *
+ * @return the log file contents, or an empty string if the file does not exist
+ */
+ public String readOutputLog() {
+ try {
+ if (Files.exists(outputLog)) {
+ return Files.readString(outputLog, StandardCharsets.UTF_8);
+ }
+ } catch (IOException e) {
+ log.warn("Failed to read output log for '{}': {}", name, e.getMessage());
+ }
+ return "";
+ }
+
+ /**
+ * Execute a command as a subprocess and return the result.
+ *
+ * This is a static utility method — it does not interact with the managed
+ * process. It starts a new subprocess, waits for it to complete (up to 2 minutes),
+ * and captures its stdout and stderr separately.
+ *
+ * @param workDir working directory for the command
+ * @param command the command and arguments to execute
+ * @return a {@link CommandResult} with exit code, stdout, and stderr
+ * @throws Exception if the command cannot be started or times out
+ */
+ public static CommandResult execCommand(Path workDir, String... command) throws Exception {
+ ProcessBuilder pb = new ProcessBuilder(command)
+ .directory(workDir.toFile());
+
+ Process proc = pb.start();
+ proc.getOutputStream().close();
+
+ CompletableFuture On Windows, a batch script like {@code standalone.bat} spawns {@code java.exe}
+ * as a child process. Calling {@link Process#destroy()} only kills the parent
+ * {@code cmd.exe}, leaving the child {@code java.exe} running and holding ports.
+ * This method walks the process tree bottom-up, destroying descendants first,
+ * then the root process.
+ */
+ private static void destroyProcessTree(Process process) {
+ ProcessHandle handle = process.toHandle();
+ handle.descendants().forEach(descendant -> {
+ log.info("Destroying descendant process (pid {})", descendant.pid());
+ descendant.destroyForcibly();
+ });
+ process.destroyForcibly();
+ }
+}
diff --git a/src/test/java/org/jboss/modcluster/test/utils/NativeServerExtractor.java b/src/test/java/org/jboss/modcluster/test/utils/NativeServerExtractor.java
new file mode 100644
index 0000000..e500116
--- /dev/null
+++ b/src/test/java/org/jboss/modcluster/test/utils/NativeServerExtractor.java
@@ -0,0 +1,279 @@
+package org.jboss.modcluster.test.utils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.Enumeration;
+import java.util.Set;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+/**
+ * Extracts a WildFly or JBCS httpd distribution ZIP to a per-instance directory
+ * for native (non-Docker) test mode.
+ *
+ * Each WildFly worker needs its own extracted copy because workers modify
+ * configuration files (e.g. JGroups TCP, mod_cluster proxy settings), maintain
+ * independent data directories, and write separate server logs. Sharing a single
+ * extracted directory would cause conflicts.
+ *
+ * The extraction target is {@code target/native-servers/{instanceName}/}.
+ * If the target already exists, it is reused without re-extracting. This speeds
+ * up repeated test runs during development.
+ *
+ * After extraction, on POSIX systems, shell scripts in {@code bin/} are made
+ * executable. A management user ({@code admin/admin}) is created via
+ * {@code add-user.sh} to allow Creaper management connections.
+ *
+ * Usage:
+ * If the target directory already contains an extracted server (detected by
+ * the presence of a {@code bin/} subdirectory), extraction is skipped and the
+ * existing path is returned.
+ *
+ * Post-extraction setup:
+ * Preserves the directory structure from the ZIP. Creates parent
+ * directories as needed.
+ *
+ * @param zipPath path to the ZIP file
+ * @param targetDir directory to extract into
+ * @throws IOException if extraction fails
+ */
+ private static void unzip(Path zipPath, Path targetDir) throws IOException {
+ try (ZipFile zf = new ZipFile(zipPath.toFile())) {
+ Enumeration extends ZipEntry> entries = zf.entries();
+ while (entries.hasMoreElements()) {
+ ZipEntry entry = entries.nextElement();
+ Path entryPath = targetDir.resolve(entry.getName()).normalize();
+
+ // Zip-slip protection
+ if (!entryPath.startsWith(targetDir)) {
+ throw new IOException("ZIP entry outside target directory: " + entry.getName());
+ }
+
+ if (entry.isDirectory()) {
+ Files.createDirectories(entryPath);
+ } else {
+ Files.createDirectories(entryPath.getParent());
+ try (InputStream is = zf.getInputStream(entry)) {
+ Files.copy(is, entryPath, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Make shell scripts in the given directory executable (POSIX systems only).
+ *
+ * On Windows, {@code .bat} scripts don't need execute permission, so this
+ * method is a no-op if POSIX file permissions are not supported.
+ *
+ * @param binDir the {@code bin/} directory containing shell scripts
+ */
+ private static void makeScriptsExecutable(Path binDir) {
+ if (!Files.isDirectory(binDir)) {
+ return;
+ }
+
+ try {
+ Set Runs {@code add-user.sh} (or {@code add-user.bat} on Windows) to create
+ * a management user with the default credentials ({@code admin/admin}).
+ *
+ * @param serverHome the WildFly server home directory
+ * @throws Exception if the add-user command fails
+ */
+ private static void addManagementUser(Path serverHome) throws Exception {
+ String script = TestMode.isWindows() ? "add-user.bat" : "add-user.sh";
+ Path scriptPath = serverHome.resolve("bin").resolve(script);
+
+ if (!Files.exists(scriptPath)) {
+ log.warn("add-user script not found: {}", scriptPath);
+ return;
+ }
+
+ CommandResult result = NativeProcessManager.execCommand(
+ serverHome.toAbsolutePath(),
+ scriptPath.toAbsolutePath().toString(), "-u", MGMT_USER, "-p", MGMT_PASSWORD);
+
+ if (!result.isSuccess()) {
+ if (result.getStderr().contains("already exists")) {
+ log.debug("Management user '{}' already exists in {}", MGMT_USER, serverHome);
+ } else {
+ log.warn("add-user failed for '{}' (exit {}): {}",
+ serverHome, result.getExitCode(), result.getStderr());
+ }
+ } else {
+ log.info("Created management user '{}' in {}", MGMT_USER, serverHome);
+ }
+ }
+
+ /**
+ * Save backups of original configuration files so they can be restored
+ * before each test run, ensuring clean server state.
+ *
+ * Both files are backed up because different server roles use different configs:
+ * the balancer uses {@code standalone.xml} (default) while workers use
+ * {@code standalone-ha.xml}.
+ */
+ private static void backupOriginalConfig(Path serverHome) throws IOException {
+ for (String configFile : new String[]{"standalone.xml", "standalone-ha.xml"}) {
+ Path config = serverHome.resolve("standalone/configuration/" + configFile);
+ Path backup = serverHome.resolve("standalone/configuration/" + configFile + ".original");
+ if (Files.exists(config) && !Files.exists(backup)) {
+ Files.copy(config, backup);
+ log.info("Backed up original {} in {}", configFile, serverHome);
+ }
+ }
+ }
+
+ /**
+ * Deploy the custom load metric module into the WildFly modules directory.
+ *
+ * In Docker mode, this module is baked into the image via Containerfile.
+ * In native mode, we copy the JAR and module.xml from the test resources
+ * into the server's module path.
+ *
+ * @param serverHome the WildFly server home directory
+ */
+ private static void deployCustomLoadMetricModule(Path serverHome) {
+ Path jarSource = Path.of("src/test/resources/custom-load-metric/target/custom-load-metric.jar");
+ Path moduleXmlSource = Path.of("src/test/resources/custom-load-metric/module.xml");
+
+ if (!Files.exists(jarSource)) {
+ log.debug("Custom load metric JAR not found at {}, skipping module deployment", jarSource);
+ return;
+ }
+
+ Path moduleDir = serverHome.resolve("modules/org/jboss/modcluster/test/metric/main");
+ try {
+ Files.createDirectories(moduleDir);
+ Files.copy(jarSource, moduleDir.resolve("custom-load-metric.jar"), StandardCopyOption.REPLACE_EXISTING);
+ Files.copy(moduleXmlSource, moduleDir.resolve("module.xml"), StandardCopyOption.REPLACE_EXISTING);
+ log.info("Deployed custom load metric module to {}", moduleDir);
+ } catch (IOException e) {
+ log.warn("Failed to deploy custom load metric module: {}", e.getMessage());
+ }
+ }
+
+}
diff --git a/src/test/java/org/jboss/modcluster/test/utils/NativeWildFlyWorker.java b/src/test/java/org/jboss/modcluster/test/utils/NativeWildFlyWorker.java
new file mode 100644
index 0000000..0de6373
--- /dev/null
+++ b/src/test/java/org/jboss/modcluster/test/utils/NativeWildFlyWorker.java
@@ -0,0 +1,505 @@
+package org.jboss.modcluster.test.utils;
+
+import org.jboss.dmr.ModelNode;
+import org.jboss.modcluster.test.base.BalancerType;
+import org.jboss.modcluster.test.utils.balancer.Balancer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.wildfly.extras.creaper.core.online.OnlineManagementClient;
+import org.wildfly.extras.creaper.core.online.operations.Address;
+import org.wildfly.extras.creaper.core.online.operations.Operations;
+import org.wildfly.extras.creaper.core.online.operations.Values;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Native (non-Docker) WildFly worker implementation.
+ *
+ * Runs WildFly as a local OS process via {@link NativeProcessManager}, started from
+ * a per-instance ZIP extraction ({@link NativeServerExtractor}). All workers share the
+ * host network namespace and are distinguished by static port offsets
+ * ({@link NativePortAllocator}).
+ *
+ * This implementation mirrors the lifecycle of {@link DockerWildFlyWorker}:
+ * The pre-configuration step boots WildFly in {@code --admin-only} mode, applies
+ * management operations via Creaper, then shuts down. The result is a modified
+ * {@code standalone-ha.xml}. Because this output is deterministic for a given set of
+ * inputs (port offsets, balancer type, max-attempts, etc.), we cache it.
+ *
+ * A SHA-256 hash is computed from all inputs that affect the configuration (see
+ * {@link #computeConfigHash()}). The first run for a given hash boots admin-only and
+ * saves the result as {@code standalone-ha.xml.cached- The cache is automatically invalidated when any input changes (different balancer
+ * type, different max-attempts, etc.) because the hash will differ. Adding a new
+ * configuration parameter only requires adding it to {@link #computeConfigHash()}.
+ * The cache is physically cleared by {@code mvn clean} (deletes {@code target/}).
+ *
+ * File I/O operations ({@link #copyClasspathResource}, {@link #copyLocalFile},
+ * {@link #readFile}) operate directly on the local filesystem instead of
+ * copying into a container.
+ *
+ * Thread safety: instances are not thread-safe. A JVM shutdown hook in
+ * {@link NativeProcessManager} ensures all processes are killed on exit.
+ *
+ * @see TestMode
+ * @see NativePortAllocator
+ * @see NativeServerExtractor
+ */
+public class NativeWildFlyWorker extends WildFlyWorker {
+
+ private static final Logger log = LoggerFactory.getLogger(NativeWildFlyWorker.class);
+
+
+ private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(5);
+
+ private Path serverHome;
+ private NativeProcessManager processManager;
+
+ /**
+ * Create a new native WildFly worker.
+ *
+ * @param name unique worker name (e.g. "worker1", "worker2")
+ * @param balancer the balancer this worker is associated with
+ */
+ NativeWildFlyWorker(String name, Balancer balancer) {
+ super(name, balancer);
+ }
+
+ @Override
+ public void start() {
+ try {
+ serverHome = NativeServerExtractor.extract(getName());
+ resetServerState();
+
+ String configHash = computeConfigHash();
+ Path cachedConfig = getCachedConfigPath(configHash);
+ Path config = serverHome.resolve("standalone/configuration/standalone-ha.xml");
+
+ if (Files.exists(cachedConfig)) {
+ Files.copy(cachedConfig, config, StandardCopyOption.REPLACE_EXISTING);
+ log.info("Using cached config for worker '{}' (hash={}, skipped admin-only boot)", getName(), configHash);
+ } else {
+ preConfigureViaAdminServer();
+ Files.copy(config, cachedConfig, StandardCopyOption.REPLACE_EXISTING);
+ log.info("Cached config for worker '{}' (hash={})", getName(), configHash);
+ }
+
+ List The hash covers every value read by {@link #configureJGroupsTcp} and
+ * {@link #configureModClusterProxy}. If a new configuration parameter is added
+ * to either method, it must be added here as well — otherwise stale cached
+ * configs will be reused silently.
+ *
+ * @return first 8 hex characters of the SHA-256 digest
+ */
+ private String computeConfigHash() {
+ String input = String.join("|",
+ NativePortAllocator.tcppingInitialHosts(),
+ String.valueOf(getBalancer().getInternalMcmpPort()),
+ String.valueOf(modCluster().getDesiredMaxAttempts()),
+ getBalancer().getType().getName()
+ );
+ try {
+ byte[] hash = MessageDigest.getInstance("SHA-256")
+ .digest(input.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(8);
+ for (int i = 0; i < 4; i++) {
+ sb.append(String.format("%02x", hash[i]));
+ }
+ return sb.toString();
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private Path getCachedConfigPath(String hash) {
+ return serverHome.resolve("standalone/configuration/standalone-ha.xml.cached-" + hash);
+ }
+
+ /**
+ * Pre-configure JGroups TCP and mod_cluster proxy via a WildFly admin-only subprocess.
+ * Starts the server in {@code --admin-only} mode as a separate process, connects
+ * via Creaper {@link Operations} over the management port, applies configuration,
+ * then stops the subprocess. The configuration is persisted to
+ * {@code standalone-ha.xml} so the real server boots fully configured.
+ */
+ private void preConfigureViaAdminServer() throws Exception {
+ log.info("Pre-configuring worker '{}' via admin-only server", getName());
+
+ List Uses {@code standalone.sh} on Unix or {@code standalone.bat} on Windows.
+ * Binds all interfaces to {@code 0.0.0.0} so the server is reachable on localhost.
+ *
+ * @return the command and arguments as a list
+ */
+ private List The mode is selected by the {@code test.mode} system property (case-insensitive).
+ * If not set, defaults to {@link #DOCKER}.
+ *
+ * Usage:
+ * The default {@code standalone-ha.xml} UDP stack uses MPING (multicast discovery) which
- * requires UDP multicast between containers. Docker bridge networks historically don't forward
- * multicast. Podman with netavark bridge networks may support it, but this is not guaranteed
- * across all environments (CI, different Podman versions, Docker). TCPPING with static member
- * lists is deterministic and works in all container networking configurations. Required because UDP multicast discovery does not work reliably in all environments:
+ * Docker bridge networks do not forward multicast, and Podman support varies across versions.
+ * TCPPING with static member lists is deterministic and works everywhere.
+ *
+ * Adapts automatically to the current {@link TestMode}:
+ * Changes are persistent in the management model and take effect after reload.
*
* Dispatches based on the {@code test.mode} system property:
+ * Docker: returns {@code "/opt/wildfly"} (fixed path inside the container image).
+ * Native: returns the path where the WildFly distribution was extracted
+ * (e.g. {@code "target/native-servers/worker1/wildfly-39.0.1.Final"}).
+ *
+ * Used by SSL configurators, load metric module installers, and EJB tests
+ * to locate server binaries and configuration files without hardcoding paths.
+ *
+ * @return the absolute path to the WildFly server home directory
+ */
+ public abstract String getServerHome();
+
+ /**
+ * Get the system temporary directory path appropriate for this worker's environment.
+ *
+ * Docker: returns {@code "/tmp"} (known to exist in Linux containers).
+ * Native: returns {@code java.io.tmpdir} (OS-appropriate — {@code /tmp} on Linux,
+ * {@code C:\Users\...\AppData\Local\Temp} on Windows).
+ *
+ * @return absolute path to the system temporary directory
+ */
+ public abstract String getTempDirectory();
+
+ /**
+ * Execute a command inside the worker environment.
+ */
+ public abstract CommandResult execCommand(String... command) throws Exception;
+
+ /**
+ * Copy a classpath resource to the worker's filesystem.
+ */
+ public abstract void copyClasspathResource(String classpathResource, String destPath);
+
+ /**
+ * Copy a local file to the worker's filesystem.
+ */
+ public abstract void copyLocalFile(Path hostPath, String destPath) throws Exception;
+
+ /**
+ * Read a file from the worker's filesystem.
+ */
+ public abstract String readFile(String path) throws Exception;
+
+ /**
+ * Get the server log content.
+ */
+ public abstract String getServerLog() throws Exception;
+
+ /**
+ * Get the last N lines from the server log.
+ */
+ public abstract String getServerLog(int lines) throws Exception;
+
+ /**
+ * Grep the server log for specific patterns.
+ */
+ public abstract String grepServerLog(String pattern) throws Exception;
+
+ // ---- Concrete methods (shared across all implementations) ----
+
+ /**
+ * Override JVM options for this worker. Must be called before {@link #start()}.
+ * Useful for tests that need more heap (e.g., heap load metric tests).
+ */
+ public WildFlyWorker withJavaOpts(String javaOpts) {
+ this.javaOpts = javaOpts;
+ return this;
+ }
+
+ /**
+ * Pre-configure max-attempts before worker startup.
+ * The value is applied during proxy configuration, before the worker
+ * joins the cluster — avoiding a disruptive reload in a running cluster.
+ *
+ * @param maxAttempts the maximum number of retry attempts, or -1 to keep defaults
+ */
+ public WildFlyWorker withMaxAttempts(int maxAttempts) {
+ modCluster().setDesiredMaxAttempts(maxAttempts);
+ return this;
+ }
+
+ /** Get the unique name of this worker (e.g. "worker1"). */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Get the balancer that this worker is associated with.
+ */
+ public Balancer getBalancer() {
+ return balancer;
+ }
+
+ /**
+ * Graceful shutdown: management API shutdown + stop.
+ */
+ public void shutdown() {
+ if (managementClient != null) {
+ try {
+ log.info("Initiating management API shutdown for worker '{}'", name);
+ new Administration(managementClient).shutdown();
+ Thread.sleep(2000); // Let JGroups send LEAVE
+ } catch (IOException e) {
+ log.debug("Management connection closed during shutdown (expected): {}", e.getMessage());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (Exception e) {
+ log.warn("Management API shutdown failed for '{}': {}", name, e.getMessage());
+ }
+ }
+ stop();
+ }
+
+ /**
+ * Get Creaper ManagementClient for this WildFly instance.
+ * Creates client on first call, reuses it afterwards.
+ */
+ public OnlineManagementClient getManagementClient() throws IOException {
+ if (managementClient == null) {
+ managementClient = ManagementClientFactory.create(
+ getManagementHost(), getManagementPort());
+ log.debug("Created management client for worker '{}'", name);
+ }
+ return managementClient;
+ }
+
+ /**
+ * Get Creaper Operations helper for this WildFly instance.
+ */
+ public Operations getOperations() throws IOException {
+ return new Operations(getManagementClient());
+ }
+
+ /**
+ * Get Creaper Administration helper for this WildFly instance.
+ */
+ public Administration getAdministration() throws IOException {
+ return new Administration(getManagementClient());
+ }
+
+ /** Get the deployment manager for deploying/undeploying applications. */
+ public WildFlyDeploymentManager deployment() {
+ if (deploymentManager == null) {
+ deploymentManager = new WildFlyDeploymentManager(this);
+ }
+ return deploymentManager;
+ }
+
+ /** Get the mod_cluster subsystem manager for proxy configuration. */
+ public WildFlyModClusterManager modCluster() {
+ if (modClusterManager == null) {
+ modClusterManager = new WildFlyModClusterManager(this);
+ }
+ return modClusterManager;
+ }
+
+ /** Get the Undertow subsystem manager for listener and host configuration. */
+ public WildFlyUndertowManager undertow() {
+ if (undertowManager == null) {
+ undertowManager = new WildFlyUndertowManager(this);
+ }
+ return undertowManager;
+ }
+
+ /** Get the load metrics manager for configuring custom load providers. */
+ public WildFlyLoadMetricsManager loadMetrics() {
+ if (loadMetricsManager == null) {
+ loadMetricsManager = new WildFlyLoadMetricsManager(this);
+ }
+ return loadMetricsManager;
+ }
+
+ /** Get the JGroups manager for cluster view and protocol configuration. */
+ public WildFlyJGroupsManager jgroups() {
+ if (jgroupsManager == null) {
+ jgroupsManager = new WildFlyJGroupsManager(this);
+ }
+ return jgroupsManager;
+ }
+
+ /**
+ * Execute a CLI command on this WildFly instance using Creaper.
+ *
+ * @deprecated Use getManagementClient() and Creaper operations instead
+ */
+ @Deprecated
+ public String executeCli(String command) throws Exception {
+ OnlineManagementClient client = getManagementClient();
+ ModelNode result = client.execute(command);
+ return result.toJSONString(false);
+ }
+
+ /**
+ * Execute a CLI command using shell (fallback for complex commands).
+ */
+ public String executeCliViaShell(String command) throws Exception {
+ CommandResult result = execCommand(
+ "sh", "-c",
+ "jboss-cli.sh --connect --controller=localhost:9990 --command='" + command + "'");
+
+ if (result.getExitCode() != 0) {
+ throw new RuntimeException("CLI command failed: " + result.getStderr());
+ }
+
+ return result.getStdout();
+ }
+
+ /**
+ * Reload the server configuration and wait for management to be ready.
+ * Does not reconfigure static proxy or redeploy applications.
+ */
+ public void reloadServer() throws Exception {
+ log.info("Reloading worker '{}'", name);
+
+ // Invalidate cached client — reload drops the connection
+ if (managementClient != null) {
+ try {
+ managementClient.close();
+ } catch (IOException ignored) {
+ }
+ managementClient = null;
+ }
+
+ try {
+ getAdministration().reload();
+ } catch (Exception e) {
+ if (e instanceof java.util.concurrent.TimeoutException
+ || e.getCause() instanceof java.util.concurrent.TimeoutException
+ || (e.getMessage() != null && e.getMessage().contains("Waiting for server timed out"))) {
+ log.warn("Reload timed out for '{}', waiting with fresh connection (bootTimeout=120s)", name);
+ managementClient = null;
+ getAdministration().waitUntilRunning();
+ } else {
+ throw e;
+ }
+ }
+ log.info("Worker '{}' reloaded successfully", name);
+ }
+
+ /**
+ * Restart the server (full JVM restart, heavier than reload).
+ */
+ public void restartServer() throws Exception {
+ log.info("Restarting worker '{}'", name);
+ getAdministration().restart();
+ managementClient = null;
+ log.info("Worker '{}' restarted successfully", name);
+ }
+
+ /**
+ * Reload the server configuration.
+ * All management model state (deployments, proxy config) persists across reloads.
+ */
+ public void reload() throws Exception {
+ reloadServer();
+ }
+
+ protected void closeManagementClient() {
+ if (managementClient != null) {
+ try {
+ managementClient.close();
+ } catch (IOException e) {
+ log.warn("Error closing management client for worker '{}'", name, e);
+ }
+ managementClient = null;
+ }
+ }
+
+ protected void clearCachedManagers() {
+ deploymentManager = null;
+ modClusterManager = null;
+ undertowManager = null;
+ loadMetricsManager = null;
+ jgroupsManager = null;
+ }
+}
diff --git a/src/test/java/org/jboss/modcluster/test/utils/balancer/Balancer.java b/src/test/java/org/jboss/modcluster/test/utils/balancer/Balancer.java
new file mode 100644
index 0000000..6e00388
--- /dev/null
+++ b/src/test/java/org/jboss/modcluster/test/utils/balancer/Balancer.java
@@ -0,0 +1,263 @@
+package org.jboss.modcluster.test.utils.balancer;
+
+import org.jboss.modcluster.test.base.BalancerType;
+import org.jboss.modcluster.test.utils.CommandResult;
+import org.jboss.modcluster.test.utils.TestMode;
+import org.jboss.modcluster.test.utils.TestTimeouts;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * Abstract load balancer (Undertow or httpd with mod_cluster).
+ * Platform-independent API — Docker and native implementations provide
+ * concrete process management, file I/O, and networking.
+ */
+public abstract class Balancer {
+
+ private static final Logger log = LoggerFactory.getLogger(Balancer.class);
+
+ protected BalancerType type;
+
+ protected static final int HTTP_PORT = 8080;
+ protected static final int HTTPS_PORT = 8443;
+ protected static final int MCMP_PORT = 8090;
+ protected static final int MANAGEMENT_PORT = 9990;
+
+ /** How often the Undertow balancer pings backend workers (milliseconds). */
+ public static final int HEALTH_CHECK_INTERVAL_MS = 1000;
+
+ /** Time a broken node stays registered before removal (milliseconds). */
+ public static final int BROKEN_NODE_TIMEOUT_MS = 3000;
+
+ /**
+ * Create a balancer for the given type and current test mode.
+ *
+ * Dispatches based on the {@code test.mode} system property:
+ * For WildFly-based balancers (Undertow): returns the WildFly installation root
+ * (e.g. {@code "/opt/wildfly"} in Docker, or the extracted path in native mode).
+ *
+ * For httpd-based balancers: returns the httpd installation root
+ * (e.g. {@code "/usr/local/apache2"} in Docker).
+ *
+ * @return the absolute path to the server home directory
+ */
+ public abstract String getServerHome();
+
+ /**
+ * Get the directory containing the main configuration file (e.g. httpd.conf).
+ *
+ * For httpd-based balancers the conf directory varies by distribution:
+ * {@code /usr/local/apache2/conf} (Docker), {@code httpdHome/etc/httpd/conf} (JBCS Windows).
+ * Defaults to {@code getServerHome() + "/conf"}.
+ *
+ * @return absolute path to the configuration directory
+ */
+ public String getConfDir() {
+ return getServerHome() + "/conf";
+ }
+
+ /**
+ * Get the path to the mod_proxy_cluster.conf file.
+ * Docker: {@code conf/extra/mod_proxy_cluster.conf}. Native: {@code conf.d/mod_proxy_cluster.conf}.
+ */
+ public String getModProxyClusterConfPath() {
+ return getConfDir() + "/extra/mod_proxy_cluster.conf";
+ }
+
+ /**
+ * Execute a command inside the balancer environment.
+ */
+ public abstract CommandResult execCommand(String... command) throws Exception;
+
+ /**
+ * Copy a classpath resource to the balancer's filesystem.
+ */
+ public abstract void copyClasspathResource(String classpathResource, String destPath);
+
+ /**
+ * Copy a local file to the balancer's filesystem.
+ */
+ public abstract void copyLocalFile(Path hostPath, String destPath);
+
+ /**
+ * Get the balancer's logs.
+ */
+ public abstract String getLogs();
+
+ // ---- Abstract mod_cluster operations ----
+
+ /** MCMP port as seen from inside the network (not mapped). */
+ public abstract int getInternalMcmpPort();
+
+ /** MCMP port used for SSL connections (8443 for Undertow, 8090 for httpd). */
+ public abstract int getMcmpSslPort();
+
+ /** Query registered worker info via MCMP INFO or management API. */
+ public abstract Map Uses the same WildFly/EAP ZIP as workers, started in {@code --admin-only} mode,
+ * then configured with a mod_cluster filter and reloaded to normal mode.
+ *
+ * MCMP operations (node/context/group management) are delegated to
+ * {@link UndertowBalancerOperations}, which is shared with the native implementation.
+ */
+class DockerUndertowBalancer extends DockerBalancer {
+
+ private static final Logger log = LoggerFactory.getLogger(DockerUndertowBalancer.class);
+
+ private final UndertowBalancerOperations ops = new UndertowBalancerOperations(
+ () -> new String[]{
+ container.getHost(),
+ String.valueOf(container.getMappedPort(MANAGEMENT_PORT))
+ });
+
+ @Override
+ public void stop() {
+ ops.close();
+ super.stop();
+ }
+
+ @Override
+ public String getServerHome() {
+ return "/opt/wildfly";
+ }
+
+ @Override
+ public int getInternalMcmpPort() {
+ return HTTP_PORT;
+ }
+
+ @Override
+ public int getMcmpSslPort() {
+ return HTTPS_PORT;
+ }
+
+ @Override
+ public void start() {
+ Network freshNetwork = Network.newNetwork();
+ ownsNetwork = true;
+ this.start(freshNetwork, "balancer");
+ }
+
+ @Override
+ public void start(Network network, String networkAlias) {
+ type = BalancerType.UNDERTOW;
+ this.network = network;
+ this.networkAlias = networkAlias;
+
+ Path zipPath = ContainerUtils.getWildFlyZipPath();
+
+ if (zipPath != null && zipPath.toFile().exists()) {
+ log.info("Building Undertow balancer from ZIP: {}", zipPath);
+ startFromZip(zipPath, networkAlias);
+ } else {
+ log.info("No ZIP provided, using pre-built Undertow balancer image");
+ startFromImage(networkAlias);
+ }
+ }
+
+ private void startFromZip(Path zipPath, String networkAlias) {
+ String imageTag = ImageBuilder.ensureImage(zipPath);
+
+ ContainerUtils.startWithRetry(() -> {
+ container = new GenericContainer<>(imageTag)
+ .withNetwork(network)
+ .withNetworkAliases(networkAlias)
+ .withExposedPorts(HTTP_PORT, HTTPS_PORT, MANAGEMENT_PORT)
+ .withEnv("JAVA_OPTS", System.getProperty("wildfly.java.opts"))
+ .withCommand("/opt/wildfly/bin/standalone.sh",
+ "-Djboss.node.name=" + networkAlias,
+ "-bmanagement", "0.0.0.0",
+ "--admin-only")
+ .waitingFor(Wait.forLogMessage(".*" + WildFlyWorker.STARTUP_LOG_PATTERN + ".*", 1)
+ .withStartupTimeout(TestTimeouts.CONTAINER_STARTUP))
+ .withLogConsumer(outputFrame ->
+ log.debug("[UNDERTOW-BALANCER-{}] {}", networkAlias.toUpperCase(),
+ outputFrame.getUtf8String().trim()));
+
+ applyJavaHomeIfNeeded(container);
+ container.start();
+ log.info("Undertow balancer '{}' started in admin-only mode on network: {}", networkAlias, network.getId());
+
+ configureAsBalancer();
+ }, () -> {
+ if (container != null) {
+ try {
+ container.close();
+ } catch (Exception e) {
+ log.debug("Error during cleanup: {}", e.getMessage());
+ }
+ container = null;
+ }
+ }, "Undertow balancer '" + networkAlias + "'");
+ }
+
+ /**
+ * Configure this WildFly instance to act as a mod_cluster balancer.
+ * Uses Creaper Operations API following the order from noe-tests CLILib.
+ * Server must be started in {@code --admin-only} mode.
+ */
+ private void configureAsBalancer() {
+ try {
+ OnlineManagementClient client = ManagementClientFactory.create(
+ container.getHost(), container.getMappedPort(MANAGEMENT_PORT));
+
+ Operations creaperOps = new Operations(client);
+
+ log.info("Configuring Undertow mod_cluster filter on balancer (admin-only mode)");
+
+ String containerIp = container.getContainerInfo().getNetworkSettings().getNetworks()
+ .values().iterator().next().getIpAddress();
+ log.info("Container IP: {}", containerIp);
+
+ Address publicInterfaceAddr = Address.of("interface", "public");
+ creaperOps.undefineAttribute(publicInterfaceAddr, "any-address");
+ creaperOps.writeAttribute(publicInterfaceAddr, "inet-address", containerIp)
+ .assertSuccess("Failed to configure public interface");
+ log.info("Public interface configured to: {}", containerIp);
+
+ Address multicastAddr = Address
+ .of("socket-binding-group", "standard-sockets")
+ .and("socket-binding", "modcluster");
+
+ creaperOps.add(multicastAddr,
+ Values.of("port", 0)
+ .and("multicast-address", "224.0.1.105")
+ .and("multicast-port", 23364))
+ .assertSuccess("Failed to add multicast socket binding");
+ log.info("Multicast socket binding created");
+
+ creaperOps.add(UndertowBalancerOperations.MOD_CLUSTER_FILTER_ADDR,
+ Values.of("management-socket-binding", "http")
+ .and("advertise-socket-binding", "modcluster")
+ .and("health-check-interval", Balancer.HEALTH_CHECK_INTERVAL_MS)
+ .and("broken-node-timeout", Balancer.BROKEN_NODE_TIMEOUT_MS)
+ .and("max-retries", 1)
+ .and("failover-strategy", "LOAD_BALANCED"))
+ .assertSuccess("Failed to add mod_cluster filter");
+ log.info("Mod_cluster filter created with health checks and failover enabled");
+
+ Address filterRefAddr = Address.subsystem("undertow")
+ .and("server", "default-server")
+ .and("host", "default-host")
+ .and("filter-ref", "modcluster");
+
+ creaperOps.add(filterRefAddr).assertSuccess("Failed to add filter-ref");
+ log.info("Filter-ref added to default-host");
+
+ log.info("Reloading server to transition from admin-only to normal mode");
+ new Administration(client).reload();
+ client.close();
+
+ OnlineManagementClient readyClient = ManagementClientFactory.create(
+ container.getHost(), container.getMappedPort(MANAGEMENT_PORT));
+ new Administration(readyClient).waitUntilRunning();
+ readyClient.close();
+
+ log.info("Undertow balancer configured successfully. MCMP on HTTP socket binding (port {})", HTTP_PORT);
+
+ } catch (Exception e) {
+ log.error("Failed to configure balancer", e);
+ throw new RuntimeException("Balancer configuration failed", e);
+ }
+ }
+
+ // ---- MCMP operations delegated to UndertowBalancerOperations ----
+
+ @Override
+ public Map Runs httpd as a local OS process, using a JBCS (JBoss Core Services) httpd
+ * distribution extracted from a ZIP file in {@code distributions/}. MCMP operations
+ * use the platform-agnostic {@link McmpClient}.
+ *
+ * Startup flow:
+ * Runs WildFly as a local OS process, configured with a mod_cluster filter.
+ * Uses the same Creaper-based configuration as {@link DockerUndertowBalancer},
+ * but without any Docker/container dependency.
+ *
+ * Startup flow:
+ * MCMP operations are delegated to {@link UndertowBalancerOperations},
+ * shared with the Docker implementation.
+ *
+ * @see DockerUndertowBalancer
+ * @see UndertowBalancerOperations
+ */
+class NativeUndertowBalancer extends Balancer {
+
+ private static final Logger log = LoggerFactory.getLogger(NativeUndertowBalancer.class);
+
+
+ private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(5);
+
+ private String instanceName = "balancer";
+ private Path serverHome;
+ private NativeProcessManager processManager;
+ private UndertowBalancerOperations ops;
+
+ private UndertowBalancerOperations ops() {
+ if (ops == null) {
+ ops = new UndertowBalancerOperations(
+ () -> new String[]{"localhost", String.valueOf(NativePortAllocator.managementPort(instanceName))});
+ }
+ return ops;
+ }
+
+ @Override
+ public void start() {
+ type = BalancerType.UNDERTOW;
+
+ try {
+ serverHome = NativeServerExtractor.extract(instanceName);
+ restoreCleanState();
+
+ List The balancer uses {@code standalone.xml} (no {@code -Djboss.server.default.config}
+ * flag), so that file must be restored. Tests like {@code SettingsTest} modify the
+ * public interface in {@code standalone.xml} and those changes would corrupt
+ * subsequent balancer instances if not reverted.
+ */
+ private void restoreCleanState() throws IOException {
+ Path configDir = serverHome.resolve("standalone/configuration");
+
+ for (String configFile : new String[]{"standalone.xml", "standalone-ha.xml"}) {
+ Path backup = configDir.resolve(configFile + ".original");
+ Path config = configDir.resolve(configFile);
+ if (Files.exists(backup)) {
+ Files.copy(backup, config, StandardCopyOption.REPLACE_EXISTING);
+ log.info("Restored original {} from backup", configFile);
+ }
+ }
+
+ // Clear data and tmp directories to remove stale runtime state
+ for (String dir : new String[]{"standalone/data", "standalone/tmp",
+ "standalone/configuration/standalone_xml_history"}) {
+ Path dirPath = serverHome.resolve(dir);
+ if (Files.isDirectory(dirPath)) {
+ Files.walk(dirPath)
+ .sorted(Comparator.reverseOrder())
+ .forEach(p -> {
+ try {
+ if (!p.equals(dirPath)) {
+ Files.delete(p);
+ }
+ } catch (IOException e) {
+ log.warn("Failed to clean {}: {}", p, e.getMessage());
+ }
+ });
+ log.debug("Cleared {}", dirPath);
+ }
+ }
+ }
+
+ /**
+ * Configure this WildFly instance as a mod_cluster balancer.
+ * Mirrors the Docker implementation's {@code configureAsBalancer()} logic.
+ */
+ private void configureAsBalancer() throws Exception {
+ OnlineManagementClient client = ManagementClientFactory.create(
+ "localhost", NativePortAllocator.managementPort(instanceName));
+
+ Operations creaperOps = new Operations(client);
+
+ log.info("Configuring Undertow mod_cluster filter on native balancer (admin-only mode)");
+
+ // Multicast socket binding for advertisement
+ Address multicastAddr = Address
+ .of("socket-binding-group", "standard-sockets")
+ .and("socket-binding", "modcluster");
+
+ if (!creaperOps.exists(multicastAddr)) {
+ creaperOps.add(multicastAddr,
+ Values.of("port", 0)
+ .and("multicast-address", "224.0.1.105")
+ .and("multicast-port", 23364))
+ .assertSuccess("Failed to add multicast socket binding");
+ }
+
+ // Add mod_cluster filter
+ if (!creaperOps.exists(UndertowBalancerOperations.MOD_CLUSTER_FILTER_ADDR)) {
+ creaperOps.add(UndertowBalancerOperations.MOD_CLUSTER_FILTER_ADDR,
+ Values.of("management-socket-binding", "http")
+ .and("advertise-socket-binding", "modcluster")
+ .and("health-check-interval", Balancer.HEALTH_CHECK_INTERVAL_MS)
+ .and("broken-node-timeout", Balancer.BROKEN_NODE_TIMEOUT_MS)
+ .and("max-retries", 1)
+ .and("failover-strategy", "LOAD_BALANCED"))
+ .assertSuccess("Failed to add mod_cluster filter");
+ }
+
+ // Add filter-ref to default-host
+ Address filterRefAddr = Address.subsystem("undertow")
+ .and("server", "default-server")
+ .and("host", "default-host")
+ .and("filter-ref", "modcluster");
+
+ if (!creaperOps.exists(filterRefAddr)) {
+ creaperOps.add(filterRefAddr).assertSuccess("Failed to add filter-ref");
+ }
+
+ // Reload from admin-only to normal mode
+ log.info("Reloading from admin-only to normal mode");
+ new Administration(client).reload();
+ client.close();
+
+ // Wait until running
+ OnlineManagementClient readyClient = ManagementClientFactory.create(
+ "localhost", NativePortAllocator.managementPort(instanceName));
+ new Administration(readyClient).waitUntilRunning();
+ readyClient.close();
+
+ log.info("Native Undertow balancer configured successfully. MCMP on HTTP port {}",
+ NativePortAllocator.httpPort(instanceName));
+ }
+
+ // ---- Networking methods ----
+
+ @Override
+ public String getHttpUrl() {
+ return "http://localhost:" + NativePortAllocator.httpPort(instanceName);
+ }
+
+ @Override
+ public String getHttpsUrl() {
+ return "https://localhost:" + NativePortAllocator.httpsPort(instanceName);
+ }
+
+ @Override
+ public String getMcmpUrl() {
+ return "http://localhost:" + NativePortAllocator.httpPort(instanceName);
+ }
+
+ @Override
+ public String getInternalHttpUrl() {
+ return "http://localhost:" + NativePortAllocator.httpPort(instanceName);
+ }
+
+ @Override
+ public String getProxyHost() {
+ return "localhost";
+ }
+
+ @Override
+ public String getManagementHost() {
+ return "localhost";
+ }
+
+ @Override
+ public int getManagementPort() {
+ return NativePortAllocator.managementPort(instanceName);
+ }
+
+ @Override
+ public String getServerHome() {
+ return serverHome != null ? serverHome.toAbsolutePath().toString() : null;
+ }
+
+ @Override
+ public boolean isRunning() {
+ return processManager != null && processManager.isRunning();
+ }
+
+ @Override
+ public int getInternalMcmpPort() {
+ return NativePortAllocator.httpPort(instanceName);
+ }
+
+ @Override
+ public int getMcmpSslPort() {
+ return NativePortAllocator.httpsPort(instanceName);
+ }
+
+ // ---- File I/O and command execution ----
+
+ @Override
+ public CommandResult execCommand(String... command) throws Exception {
+ return NativeProcessManager.execCommand(serverHome, command);
+ }
+
+ @Override
+ public void copyClasspathResource(String classpathResource, String destPath) {
+ try {
+ Path dest = Path.of(destPath);
+ if (!dest.isAbsolute()) {
+ dest = serverHome.resolve(destPath);
+ }
+ Files.createDirectories(dest.getParent());
+
+ URL resource = Thread.currentThread().getContextClassLoader().getResource(classpathResource);
+ if (resource == null) {
+ throw new RuntimeException("Classpath resource not found: " + classpathResource);
+ }
+
+ try (InputStream is = resource.openStream()) {
+ Files.copy(is, dest, StandardCopyOption.REPLACE_EXISTING);
+ }
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to copy classpath resource '" + classpathResource + "'", e);
+ }
+ }
+
+ @Override
+ public void copyLocalFile(Path hostPath, String destPath) {
+ try {
+ Path dest = Path.of(destPath);
+ if (!dest.isAbsolute()) {
+ dest = serverHome.resolve(destPath);
+ }
+ Files.createDirectories(dest.getParent());
+ Files.copy(hostPath, dest, StandardCopyOption.REPLACE_EXISTING);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to copy local file '" + hostPath + "'", e);
+ }
+ }
+
+ @Override
+ public String getLogs() {
+ return processManager != null ? processManager.readOutputLog() : "";
+ }
+
+ // ---- MCMP operations delegated to UndertowBalancerOperations ----
+
+ @Override
+ public Map This helper encapsulates all management model operations used to query and control
+ * the Undertow mod_cluster filter: listing workers, enabling/disabling/stopping nodes,
+ * managing contexts, load-balancing groups, and retries. It operates through the WildFly
+ * management API (Creaper) and is independent of the deployment platform.
+ *
+ * Both {@link DockerUndertowBalancer} and {@code NativeUndertowBalancer} compose this
+ * helper to avoid duplicating the ~300 lines of Creaper operations. The caller provides
+ * management host and port; this class manages the Creaper client lifecycle.
+ *
+ * Thread safety: instances are not thread-safe. The management client is lazily
+ * created and cached until explicitly invalidated via {@link #invalidateClient()}.
+ *
+ * @see DockerUndertowBalancer
+ */
+class UndertowBalancerOperations {
+
+ private static final Logger log = LoggerFactory.getLogger(UndertowBalancerOperations.class);
+
+ /** Management model address for the mod_cluster filter. */
+ static final Address MOD_CLUSTER_FILTER_ADDR = Address.subsystem("undertow")
+ .and("configuration", "filter")
+ .and("mod-cluster", "modcluster");
+
+
+ private final ManagementHostProvider hostProvider;
+ private OnlineManagementClient managementClient;
+
+ /**
+ * Provides the management host and port for Creaper connections.
+ *
+ * This is a callback interface because Docker-based balancers resolve the
+ * host/port from container port mappings (which change on restart), while
+ * native balancers use fixed localhost + port.
+ */
+ @FunctionalInterface
+ interface ManagementHostProvider {
+ /**
+ * Get the management connection info.
+ *
+ * @return a two-element array: {@code [host, portString]}
+ */
+ String[] getHostAndPort();
+ }
+
+ /**
+ * Create a new operations helper.
+ *
+ * @param hostProvider callback that supplies the management host and port
+ */
+ UndertowBalancerOperations(ManagementHostProvider hostProvider) {
+ this.hostProvider = hostProvider;
+ }
+
+ /**
+ * Get or create the Creaper management client.
+ *
+ * @return an active management client
+ * @throws IOException if the connection cannot be established
+ */
+ private OnlineManagementClient getManagementClient() throws IOException {
+ if (managementClient == null) {
+ String[] hp = hostProvider.getHostAndPort();
+ managementClient = ManagementClientFactory.create(hp[0], Integer.parseInt(hp[1]));
+ }
+ return managementClient;
+ }
+
+ /**
+ * Close and discard the cached management client.
+ * Call after a reload or stop to force reconnection on next use.
+ */
+ void invalidateClient() {
+ if (managementClient != null) {
+ try {
+ managementClient.close();
+ } catch (Exception e) {
+ log.debug("Ignoring error closing management client: {}", e.getMessage());
+ }
+ managementClient = null;
+ }
+ }
+
+ /**
+ * Close the cached management client without discarding it silently.
+ * Used during shutdown to release resources.
+ */
+ void close() {
+ invalidateClient();
+ }
+
+ /**
+ * Get information about all registered worker nodes.
+ *
+ * Iterates over all balancers and their nodes in the mod_cluster filter,
+ * reading runtime resource attributes for each node.
+ *
+ * @return map of node name to its management model resource
+ * @throws Exception if any management operation fails
+ */
+ Map The Undertow management model does not support operations directly on
+ * load-balancing-group resources, so this iterates over all nodes and invokes
+ * the operation on each node whose {@code load-balancing-group} attribute matches.
+ *
+ * @param groupName the load-balancing group name
+ * @param operation the operation name ("enable", "disable", or "stop")
+ * @throws Exception if no nodes are found in the group or any operation fails
+ */
+ void invokeGroupOperation(String groupName, String operation) throws Exception {
+ Operations ops = new Operations(getManagementClient());
+
+ List
+ *
+ *
+ * {@code
+ * Path serverHome = NativeServerExtractor.extract("worker1");
+ * // serverHome = target/native-servers/worker1/wildfly-39.0.1.Final
+ * }
+ *
+ * @see TestMode
+ * @see NativePortAllocator
+ */
+public final class NativeServerExtractor {
+
+ private static final Logger log = LoggerFactory.getLogger(NativeServerExtractor.class);
+
+ /** Root directory for all native server extractions. */
+ private static final Path NATIVE_SERVERS_DIR = Path.of("target", "native-servers");
+
+ /** Default management user credentials, matching those in Docker mode. */
+ private static final String MGMT_USER = "admin";
+ private static final String MGMT_PASSWORD = "admin";
+
+ private NativeServerExtractor() {
+ }
+
+ /**
+ * Extract the WildFly distribution ZIP to a per-instance directory and prepare it
+ * for use as a test server.
+ *
+ *
+ *
+ *
+ * @param instanceName unique name for this server instance (e.g. "worker1", "balancer")
+ * @return the server home directory path (e.g. {@code target/native-servers/worker1/wildfly-39.0.1.Final})
+ * @throws RuntimeException if extraction fails or no ZIP is found
+ */
+ public static Path extract(String instanceName) {
+ Path zipPath = ContainerUtils.getWildFlyZipPath();
+ if (zipPath == null || !zipPath.toFile().exists()) {
+ throw new RuntimeException("No WildFly ZIP found. Set -Dwildfly.zip.path or "
+ + "place a wildfly-*.zip / jboss-eap-*.zip in distributions/");
+ }
+
+ return extractZip(zipPath, instanceName);
+ }
+
+ /**
+ * Extract a distribution ZIP to a per-instance directory.
+ *
+ * @param zipPath path to the distribution ZIP file
+ * @param instanceName unique name for the instance directory
+ * @return the server home directory (root directory inside the ZIP)
+ * @throws RuntimeException if extraction fails
+ */
+ public static Path extractZip(Path zipPath, String instanceName) {
+ Path instanceDir = NATIVE_SERVERS_DIR.resolve(instanceName);
+ String rootDir = ImageBuilder.detectZipRootDir(zipPath.toFile());
+
+ if (rootDir == null) {
+ throw new RuntimeException("Could not detect root directory in ZIP: " + zipPath);
+ }
+
+ Path serverHome = instanceDir.resolve(rootDir);
+
+ if (Files.isDirectory(serverHome.resolve("bin"))) {
+ log.info("Reusing existing extraction for '{}': {}", instanceName, serverHome);
+ try {
+ backupOriginalConfig(serverHome);
+ } catch (IOException e) {
+ log.warn("Failed to ensure config backups for '{}': {}", instanceName, e.getMessage());
+ }
+ deployCustomLoadMetricModule(serverHome);
+ return serverHome;
+ }
+
+ log.info("Extracting {} to {} for instance '{}'", zipPath.getFileName(), instanceDir, instanceName);
+
+ try {
+ Files.createDirectories(instanceDir);
+ unzip(zipPath, instanceDir);
+ makeScriptsExecutable(serverHome.resolve("bin"));
+ addManagementUser(serverHome);
+ backupOriginalConfig(serverHome);
+ deployCustomLoadMetricModule(serverHome);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to extract WildFly ZIP for '" + instanceName + "'", e);
+ }
+
+ log.info("Extraction complete for '{}': {}", instanceName, serverHome);
+ return serverHome;
+ }
+
+ /**
+ * Extract all entries from a ZIP file into the target directory.
+ *
+ *
+ *
+ *
+ * Configuration caching
+ *
+ *
+ *
+ *
+ * {@code
+ * // In Maven: -Dtest.mode=native
+ * // In code:
+ * if (TestMode.current() == TestMode.NATIVE) { ... }
+ * }
+ */
+public enum TestMode {
+
+ /** Run workers and balancers inside Docker/Podman containers via Testcontainers. */
+ DOCKER,
+
+ /** Run workers and balancers as local OS processes (no container runtime required). */
+ NATIVE;
+
+ private static final String SYSTEM_PROPERTY = "test.mode";
+ private static final TestMode DEFAULT = DOCKER;
+
+ /**
+ * Resolve the current test mode from the {@code test.mode} system property.
+ *
+ * @return the configured {@link TestMode}, or {@link #DOCKER} if the property is absent
+ * @throws IllegalArgumentException if the property value is not a valid mode name
+ */
+ public static TestMode current() {
+ String value = System.getProperty(SYSTEM_PROPERTY);
+ if (value == null || value.isEmpty()) {
+ return DEFAULT;
+ }
+ return valueOf(value.toUpperCase());
+ }
+
+ /**
+ * Check whether the current mode is {@link #DOCKER}.
+ *
+ * @return {@code true} if running in Docker/container mode
+ */
+ public boolean isDocker() {
+ return this == DOCKER;
+ }
+
+ /**
+ * Check whether the current mode is {@link #NATIVE}.
+ *
+ * @return {@code true} if running in native/process mode
+ */
+ public boolean isNative() {
+ return this == NATIVE;
+ }
+
+ /** Whether the host OS is Windows. */
+ public static boolean isWindows() {
+ return System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).contains("win");
+ }
+}
diff --git a/src/test/java/org/jboss/modcluster/test/utils/TestTimeouts.java b/src/test/java/org/jboss/modcluster/test/utils/TestTimeouts.java
index 3c347c2..dba29c9 100644
--- a/src/test/java/org/jboss/modcluster/test/utils/TestTimeouts.java
+++ b/src/test/java/org/jboss/modcluster/test/utils/TestTimeouts.java
@@ -26,6 +26,9 @@ private TestTimeouts() {
/** Creaper management API connection timeout. */
public static final int CONNECTION_TIMEOUT_MS = intProp("test.timeout.connection.ms", 10_000);
+ /** Timeout for subprocess commands executed via {@link NativeProcessManager#execCommand}. */
+ public static final Duration EXEC_COMMAND = durationSeconds("test.timeout.exec.command", 120);
+
// -- Cluster & balancer operations --
/** Timeout for context registration/deregistration on the balancer. */
diff --git a/src/test/java/org/jboss/modcluster/test/utils/UndertowSessionCookieConfigurator.java b/src/test/java/org/jboss/modcluster/test/utils/UndertowSessionCookieConfigurator.java
index 88b1e7c..cf18942 100644
--- a/src/test/java/org/jboss/modcluster/test/utils/UndertowSessionCookieConfigurator.java
+++ b/src/test/java/org/jboss/modcluster/test/utils/UndertowSessionCookieConfigurator.java
@@ -24,7 +24,7 @@ public class UndertowSessionCookieConfigurator {
* @param cookieName Custom cookie name, or null for default
* @throws Exception if configuration fails
*/
- public void setSessionCookieName(final WildFlyContainer worker, final String cookieName) throws Exception {
+ public void setSessionCookieName(final WildFlyWorker worker, final String cookieName) throws Exception {
log.info("Configuring session cookie name '{}' on worker '{}'", cookieName, worker.getName());
final Operations ops = worker.getOperations();
diff --git a/src/test/java/org/jboss/modcluster/test/utils/WildFlyContainer.java b/src/test/java/org/jboss/modcluster/test/utils/WildFlyContainer.java
deleted file mode 100644
index 2598960..0000000
--- a/src/test/java/org/jboss/modcluster/test/utils/WildFlyContainer.java
+++ /dev/null
@@ -1,576 +0,0 @@
-package org.jboss.modcluster.test.utils;
-
-import org.jboss.modcluster.test.utils.balancer.BalancerContainer;
-import org.jboss.dmr.ModelNode;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.testcontainers.containers.Container;
-import org.testcontainers.containers.GenericContainer;
-import org.testcontainers.containers.wait.strategy.Wait;
-import org.wildfly.extras.creaper.core.online.OnlineManagementClient;
-import org.wildfly.extras.creaper.core.online.operations.Operations;
-import org.wildfly.extras.creaper.core.online.operations.admin.Administration;
-
-import java.io.IOException;
-import java.nio.file.Path;
-import java.time.Duration;
-
-import static java.time.Duration.ofSeconds;
-import static java.time.Duration.ofMillis;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.awaitility.Awaitility.await;
-
-/**
- * Container wrapper for WildFly/EAP workers with mod_cluster subsystem.
- * Builds container from ZIP distribution.
- */
-public class WildFlyContainer {
-
- private static final Logger log = LoggerFactory.getLogger(WildFlyContainer.class);
-
- private static final int HTTP_PORT = 8080;
- private static final int HTTPS_PORT = 8443;
- private static final int MANAGEMENT_PORT = 9990;
- private static final int JGROUPS_TCP_PORT = 7600;
- private static final int JGROUPS_FD_PORT = 57600;
-
- private final String name;
- private final BalancerContainer balancer;
- private String javaOpts;
- private GenericContainer> container;
- private OnlineManagementClient managementClient;
- private WildFlyDeploymentManager deploymentManager;
- private WildFlyModClusterManager modClusterManager;
- private WildFlyUndertowManager undertowManager;
- private WildFlyLoadMetricsManager loadMetricsManager;
- private WildFlyJGroupsManager jgroupsManager;
-
- public WildFlyContainer(String name, BalancerContainer balancer) {
- this.name = name;
- this.balancer = balancer;
- }
-
- /**
- * Override JVM options for this worker. Must be called before {@link #start()}.
- * Useful for tests that need more heap (e.g., heap load metric tests).
- */
- public WildFlyContainer withJavaOpts(String javaOpts) {
- this.javaOpts = javaOpts;
- return this;
- }
-
- /**
- * Pre-configure max-attempts for this worker. Must be called before {@link #start()}.
- * The value is applied during proxy configuration, before the worker joins the cluster —
- * avoiding a disruptive reload in a running cluster that can trigger Infinispan deadlocks.
- *
- * @param maxAttempts the maximum number of retry attempts, or -1 to keep defaults
- */
- public WildFlyContainer withMaxAttempts(int maxAttempts) {
- modCluster().setDesiredMaxAttempts(maxAttempts);
- return this;
- }
-
- public void start() {
- Path zipPath = ContainerUtils.getWildFlyZipPath();
-
- if (zipPath != null && zipPath.toFile().exists()) {
- log.info("Building WildFly container from ZIP: {}", zipPath);
- startFromZip(zipPath);
- } else {
- log.info("No ZIP provided, using pre-built container image");
- startFromImage();
- }
- }
-
- /**
- * Start WildFly from a ZIP distribution (WildFly or EAP).
- * Uses direct docker build to avoid Testcontainers large file transfer issues.
- */
- private void startFromZip(Path zipPath) {
- String imageTag = ImageBuilder.ensureImage(zipPath);
- startFromPreBuiltImage(imageTag);
- }
-
- /**
- * Start WildFly from pre-built container image (fallback).
- * Note: This image reference is a placeholder and may not exist.
- * Provide a ZIP in distributions/ for reliable operation.
- */
- private void startFromImage() {
- String wildflyVersion = System.getProperty("wildfly.version", "31.0.1.Final");
- // Placeholder image — may not exist, provide a ZIP instead
- String imageName = "quay.io/wildfly/wildfly:" + wildflyVersion;
-
- startFromPreBuiltImage(imageName);
- }
-
- /**
- * Start container from a pre-built image (either from registry or locally built).
- * Includes optimized retry logic for transient Podman socket errors (SIGPIPE).
- */
- private void startFromPreBuiltImage(String imageName) {
- ContainerUtils.startWithRetry(() -> {
- container = new GenericContainer<>(imageName)
- .withNetwork(balancer.getNetwork())
- .withNetworkAliases(name)
- .withCreateContainerCmdModifier(cmd -> cmd.withHostName(name))
- .withExposedPorts(HTTP_PORT, HTTPS_PORT, MANAGEMENT_PORT, JGROUPS_TCP_PORT, JGROUPS_FD_PORT)
- .withEnv("JAVA_OPTS", javaOpts != null ? javaOpts : System.getProperty("wildfly.java.opts"))
- .withCommand("/opt/wildfly/bin/standalone.sh",
- "-b", "0.0.0.0",
- "-bmanagement", "0.0.0.0",
- "-bprivate", "0.0.0.0",
- "-Djboss.node.name=" + name,
- "-Djboss.server.default.config=standalone-ha.xml",
- "-Djboss.modcluster.multicast.address=224.0.1.105",
- "-Djboss.modcluster.multicast.port=23364")
- .waitingFor(Wait.forLogMessage(".*WFLYSRV0025.*", 1)
- .withStartupTimeout(TestTimeouts.CONTAINER_STARTUP))
- .withLogConsumer(outputFrame ->
- System.out.println("[" + name.toUpperCase() + "] " + outputFrame.getUtf8String().trim()));
-
- ContainerUtils.applyJavaHomeIfNeeded(container);
- container.start();
- log.info("WildFly worker '{}' started", name);
-
- // Configure JGroups TCP for container-based clustering
- // (UDP multicast discovery does not work in Docker/Podman networks)
- jgroups().configureTcpDiscovery();
- reloadServer(); // apply JGroups TCP config
- modCluster().configureStaticProxy(); // create outbound-socket-binding + proxies
- reloadServer(); // make proxy config effective
- deployment().deployDemoApp();
- }, () -> {
- if (container != null) {
- try {
- container.close();
- } catch (Exception e) {
- log.debug("Error during cleanup: {}", e.getMessage());
- }
- container = null;
- }
- }, "WildFly worker '" + name + "'");
- }
-
-
- public void shutdown() {
- if (managementClient != null) {
- try {
- log.info("Initiating management API shutdown for worker '{}'", name);
- new Administration(managementClient).shutdown();
- Thread.sleep(2000); // Let JGroups send LEAVE
- } catch (IOException e) {
- log.debug("Management connection closed during shutdown (expected): {}", e.getMessage());
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- } catch (Exception e) {
- log.warn("Management API shutdown failed for '{}': {}", name, e.getMessage());
- }
- }
- stop(); // Docker container cleanup
- }
-
- public void stop() {
- closeManagementClient();
-
- if (container != null) {
- String containerId = container.getContainerId();
-
- // Step 1: Disconnect from network FIRST
- if (containerId != null && balancer != null && balancer.getNetwork() != null) {
- ContainerUtils.retryOnTransientError(() ->
- container.getDockerClient()
- .disconnectFromNetworkCmd()
- .withContainerId(containerId)
- .withNetworkId(balancer.getNetwork().getId())
- .withForce(true)
- .exec(),
- "disconnect worker '" + name + "' from network", 3);
- }
-
- // Step 2: Stop container
- ContainerUtils.retryOnTransientError(() -> {
- if (container.isRunning()) {
- container.stop();
- log.info("WildFly worker '{}' stopped", name);
- }
- }, "stop worker '" + name + "'", 3);
-
- // Step 3: Remove container
- if (containerId != null) {
- ContainerUtils.retryOnTransientError(() ->
- container.getDockerClient()
- .removeContainerCmd(containerId)
- .withForce(true)
- .exec(),
- "remove worker '" + name + "'", 3);
- }
-
- container = null;
- clearCachedManagers();
- }
- }
-
- /**
- * Hard kill the worker (simulates crash/SIGKILL).
- * Kills the container immediately without graceful shutdown.
- * Retries on transient Podman socket errors (SIGPIPE / broken pipe).
- * Throws if the SIGKILL fails after retries — callers must know the container is still alive.
- */
- public void kill() throws Exception {
- closeManagementClient();
-
- if (container == null) return;
-
- try {
- // isRunning() itself can throw on Podman socket errors — treat that as "maybe running"
- boolean running;
- try {
- running = container.isRunning();
- } catch (Exception e) {
- if (ContainerUtils.isTransientDockerError(e)) {
- log.warn("Podman socket error checking isRunning() for '{}', will attempt SIGKILL anyway: {}",
- name, e.getMessage());
- running = true; // assume running, try to kill
- } else {
- throw e;
- }
- }
-
- if (!running) {
- log.info("WildFly worker '{}' container already stopped", name);
- return;
- }
-
- String containerId = container.getContainerId();
-
- // SIGKILL with retry — Podman socket can SIGPIPE transiently
- int maxAttempts = 3;
- for (int attempt = 1; attempt <= maxAttempts; attempt++) {
- try {
- container.getDockerClient()
- .killContainerCmd(containerId)
- .withSignal("KILL")
- .exec();
- log.info("WildFly worker '{}' killed (hard stop)", name);
- break;
- } catch (Exception e) {
- if (ContainerUtils.isTransientDockerError(e) && attempt < maxAttempts) {
- log.warn("Transient error killing '{}' (attempt {}/{}): {}", name, attempt, maxAttempts, e.getMessage());
- Thread.sleep(500L * attempt);
- } else {
- throw e;
- }
- }
- }
-
- // Verify container is actually dead (Podman may need a moment after SIGKILL)
- await().atMost(ofSeconds(10))
- .pollInterval(ofMillis(500))
- .untilAsserted(() ->
- assertThat(container.isRunning())
- .as("Container for worker '%s' should be dead after SIGKILL", name)
- .isFalse()
- );
- } finally {
- if (container != null) {
- String containerId = container.getContainerId();
-
- // Disconnect from network before cleanup — prevents MCMP contamination
- if (containerId != null && balancer != null && balancer.getNetwork() != null) {
- ContainerUtils.retryOnTransientError(() ->
- container.getDockerClient()
- .disconnectFromNetworkCmd()
- .withContainerId(containerId)
- .withNetworkId(balancer.getNetwork().getId())
- .withForce(true)
- .exec(),
- "disconnect killed worker '" + name + "' from network", 3);
- }
-
- // Force-remove the dead container (no need for SIGTERM via stop())
- if (containerId != null) {
- ContainerUtils.retryOnTransientError(() ->
- container.getDockerClient()
- .removeContainerCmd(containerId)
- .withForce(true)
- .exec(),
- "remove killed worker '" + name + "'", 3);
- }
- }
- container = null;
- clearCachedManagers();
- }
- }
-
- private void closeManagementClient() {
- if (managementClient != null) {
- try {
- managementClient.close();
- } catch (IOException e) {
- log.warn("Error closing management client for worker '{}'", name, e);
- }
- managementClient = null;
- }
- }
-
- private void clearCachedManagers() {
- deploymentManager = null;
- modClusterManager = null;
- undertowManager = null;
- loadMetricsManager = null;
- jgroupsManager = null;
- }
-
- public String getHttpUrl() {
- return "http://" + container.getHost() + ":" + container.getMappedPort(HTTP_PORT);
- }
-
- public String getHttpsUrl() {
- return "https://" + container.getHost() + ":" + container.getMappedPort(HTTPS_PORT);
- }
-
- public String getManagementUrl() {
- return "http://" + container.getHost() + ":" + container.getMappedPort(MANAGEMENT_PORT);
- }
-
- public String getInternalHttpUrl() {
- return "http://" + name + ":" + HTTP_PORT;
- }
-
- public String getName() {
- return name;
- }
-
- /**
- * Get the balancer container that this worker is associated with.
- *
- * @return the balancer container
- */
- public BalancerContainer getBalancer() {
- return balancer;
- }
-
- public GenericContainer> getContainer() {
- return container;
- }
-
- /**
- * Get Creaper ManagementClient for this WildFly instance.
- * Creates client on first call, reuses it afterwards.
- */
- public OnlineManagementClient getManagementClient() throws IOException {
- if (managementClient == null) {
- managementClient = ManagementClientFactory.create(
- container.getHost(), container.getMappedPort(MANAGEMENT_PORT));
- log.debug("Created management client for worker '{}'", name);
- }
- return managementClient;
- }
-
- /**
- * Get Creaper Operations helper for this WildFly instance.
- */
- public Operations getOperations() throws IOException {
- return new Operations(getManagementClient());
- }
-
- /**
- * Get Creaper Administration helper for this WildFly instance.
- */
- public Administration getAdministration() throws IOException {
- return new Administration(getManagementClient());
- }
-
- /**
- * Get deployment manager for this worker.
- * Provides access to deployment operations (deploy, undeploy, status checks).
- *
- * @return cached deployment manager instance
- */
- public WildFlyDeploymentManager deployment() {
- if (deploymentManager == null) {
- deploymentManager = new WildFlyDeploymentManager(this);
- }
- return deploymentManager;
- }
-
- /**
- * Get mod_cluster configuration manager for this worker.
- * Provides access to mod_cluster subsystem operations (proxy config, attributes).
- *
- * @return cached mod_cluster manager instance
- */
- public WildFlyModClusterManager modCluster() {
- if (modClusterManager == null) {
- modClusterManager = new WildFlyModClusterManager(this);
- }
- return modClusterManager;
- }
-
- /**
- * Get Undertow subsystem manager for this worker.
- * Provides access to Undertow server, socket binding, and listener management.
- *
- * @return cached Undertow manager instance
- */
- public WildFlyUndertowManager undertow() {
- if (undertowManager == null) {
- undertowManager = new WildFlyUndertowManager(this);
- }
- return undertowManager;
- }
-
- /**
- * Get load metrics manager for this worker.
- * Provides access to load metric configuration (custom metrics, load values).
- *
- * @return cached load metrics manager instance
- */
- public WildFlyLoadMetricsManager loadMetrics() {
- if (loadMetricsManager == null) {
- loadMetricsManager = new WildFlyLoadMetricsManager(this);
- }
- return loadMetricsManager;
- }
-
- /**
- * Get JGroups manager for this worker.
- * Provides access to JGroups subsystem configuration (TCP/TCPPING discovery).
- *
- * @return cached JGroups manager instance
- */
- public WildFlyJGroupsManager jgroups() {
- if (jgroupsManager == null) {
- jgroupsManager = new WildFlyJGroupsManager(this);
- }
- return jgroupsManager;
- }
-
- /**
- * Execute a CLI command on this WildFly instance using Creaper.
- *
- * @deprecated Use getManagementClient() and Creaper operations instead
- */
- @Deprecated
- public String executeCli(String command) throws Exception {
- OnlineManagementClient client = getManagementClient();
- ModelNode result = client.execute(command);
- return result.toJSONString(false);
- }
-
- /**
- * Execute a CLI command using shell (fallback for complex commands).
- */
- public String executeCliViaShell(String command) throws Exception {
- Container.ExecResult execResult = container.execInContainer(
- "sh", "-c",
- "jboss-cli.sh --connect --controller=localhost:9990 --command='" + command + "'"
- );
-
- if (execResult.getExitCode() != 0) {
- throw new RuntimeException("CLI command failed: " + execResult.getStderr());
- }
-
- return execResult.getStdout();
- }
-
-
- /**
- * Reload the server configuration and wait for management to be ready.
- * Does not reconfigure static proxy or redeploy applications.
- * Use this when the management model already contains the desired configuration
- * (e.g., MCMP-over-SSL settings that must take effect via reload).
- */
- public void reloadServer() throws Exception {
- log.info("Reloading worker '{}'", name);
-
- // Invalidate cached client — reload drops the connection
- if (managementClient != null) {
- try {
- managementClient.close();
- } catch (IOException ignored) {
- }
- managementClient = null;
- }
-
- try {
- getAdministration().reload();
- } catch (Exception e) {
- if (e instanceof java.util.concurrent.TimeoutException
- || e.getCause() instanceof java.util.concurrent.TimeoutException
- || (e.getMessage() != null && e.getMessage().contains("Waiting for server timed out"))) {
- log.warn("Reload timed out for '{}', waiting with fresh connection (bootTimeout=120s)", name);
- managementClient = null;
- getAdministration().waitUntilRunning();
- } else {
- throw e;
- }
- }
- log.info("Worker '{}' reloaded successfully", name);
- }
-
- /**
- * Restart the server (full JVM restart, heavier than reload).
- * Uses {@code :shutdown(restart=true)} — the process controller (PID 1) stays
- * alive and spawns a new JVM. The mod_cluster subsystem reinitializes and
- * re-registers with the balancer from scratch, respecting the current
- * {@code excluded-contexts} configuration.
- */
- public void restartServer() throws Exception {
- log.info("Restarting worker '{}'", name);
- getAdministration().restart();
- managementClient = null; // force reconnect on next use
- log.info("Worker '{}' restarted successfully", name);
- }
-
- /**
- * Reload the server configuration.
- * All management model state (deployments, proxy config) persists across reloads.
- */
- public void reload() throws Exception {
- reloadServer();
- }
-
- /**
- * Get the last N lines from the WildFly server log.
- *
- * @param lines Number of lines to retrieve
- * @return Server log content
- */
- public String getServerLog(int lines) throws Exception {
- Container.ExecResult result = container.execInContainer(
- "sh", "-c",
- String.format("tail -%d /opt/wildfly/standalone/log/server.log 2>/dev/null || echo 'Log file not found'", lines)
- );
- return result.getStdout();
- }
-
- /**
- * Get the full server log.
- *
- * @return Complete server log content
- */
- public String getServerLog() throws Exception {
- Container.ExecResult result = container.execInContainer(
- "cat", "/opt/wildfly/standalone/log/server.log"
- );
- return result.getStdout();
- }
-
- /**
- * Grep the server log for specific patterns.
- *
- * @param pattern Regex pattern to search for
- * @return Matching lines from the log
- */
- public String grepServerLog(String pattern) throws Exception {
- Container.ExecResult result = container.execInContainer(
- "sh", "-c",
- String.format("grep -i '%s' /opt/wildfly/standalone/log/server.log || echo 'No matches found'", pattern)
- );
- return result.getStdout();
- }
-
-}
diff --git a/src/test/java/org/jboss/modcluster/test/utils/WildFlyDeploymentManager.java b/src/test/java/org/jboss/modcluster/test/utils/WildFlyDeploymentManager.java
index 451989f..2303c55 100644
--- a/src/test/java/org/jboss/modcluster/test/utils/WildFlyDeploymentManager.java
+++ b/src/test/java/org/jboss/modcluster/test/utils/WildFlyDeploymentManager.java
@@ -29,9 +29,9 @@ public class WildFlyDeploymentManager {
private static final long DEPLOY_RETRY_BASE_DELAY_MS = 2000;
private static final int RELOAD_AFTER_ATTEMPT = 3;
- private final WildFlyContainer container;
+ private final WildFlyWorker container;
- WildFlyDeploymentManager(WildFlyContainer container) {
+ WildFlyDeploymentManager(WildFlyWorker container) {
this.container = container;
}
diff --git a/src/test/java/org/jboss/modcluster/test/utils/WildFlyJGroupsManager.java b/src/test/java/org/jboss/modcluster/test/utils/WildFlyJGroupsManager.java
index 7d957c6..8c80880 100644
--- a/src/test/java/org/jboss/modcluster/test/utils/WildFlyJGroupsManager.java
+++ b/src/test/java/org/jboss/modcluster/test/utils/WildFlyJGroupsManager.java
@@ -10,7 +10,7 @@
import org.wildfly.extras.creaper.core.online.operations.Values;
import org.awaitility.core.ConditionTimeoutException;
-import org.testcontainers.containers.Container.ExecResult;
+import org.jboss.modcluster.test.utils.CommandResult;
import java.time.Duration;
import java.util.Collections;
@@ -33,46 +33,61 @@ public class WildFlyJGroupsManager {
private static final Logger log = LoggerFactory.getLogger(WildFlyJGroupsManager.class);
- private final WildFlyContainer container;
+ private final WildFlyWorker container;
- WildFlyJGroupsManager(final WildFlyContainer container) {
+ WildFlyJGroupsManager(final WildFlyWorker container) {
this.container = container;
}
/**
* Configure JGroups to use TCP transport with TCPPING discovery.
- * Required because UDP multicast discovery does not work in Docker/Podman networks.
- * Workers discover each other using container network aliases (worker1, worker2, etc.).
- * Changes are persistent and take effect after reload.
*
- * Why TCP/TCPPING instead of UDP multicast
- *
+ *
+ *
+ * Criteria for switching back to UDP multicast
*
- *
*/
public void configureTcpDiscovery() throws Exception {
Operations ops = container.getOperations();
+ boolean isNative = TestMode.current().isNative();
// Switch JGroups channel from UDP to TCP stack
Address channelAddress = Address.subsystem("jgroups").and("channel", "ee");
ops.writeAttribute(channelAddress, "stack", "tcp").assertSuccess();
ops.writeAttribute(channelAddress, "statistics-enabled", true).assertSuccess();
- // Add TCPPING at position 0 (top of stack) with container network aliases.
+ // Build TCPPING initial_hosts based on test mode:
+ // Docker: hostname-based (worker1[7600],worker2[7600],...) — same port, different hosts
+ // Native: localhost with offset ports (localhost[7700],localhost[7800],...) — same host, different ports
+ String initialHosts = isNative
+ ? NativePortAllocator.tcppingInitialHosts()
+ : "worker1[7600],worker2[7600],worker3[7600],worker4[7600]";
+
+ // Determine the address this node publishes for peer connectivity:
+ // Docker: container hostname (DNS-resolvable on the Docker network)
+ // Native: "localhost" (all processes on the same host)
+ String externalAddr = isNative ? "localhost" : container.getName();
+
+ // Add TCPPING at position 0 (top of stack).
// add-index=0 is critical: discovery protocols must be at the top of the
// JGroups protocol stack. Without it, TCPPING is appended at the end and
// cluster discovery fails, breaking Infinispan and distributable sessions.
@@ -85,8 +100,7 @@ public void configureTcpDiscovery() throws Exception {
if (!ops.exists(tcppingAddress)) {
ModelNode properties = new ModelNode();
- properties.get("initial_hosts").set(
- "worker1[7600],worker2[7600],worker3[7600],worker4[7600]");
+ properties.get("initial_hosts").set(initialHosts);
properties.get("port_range").set("0");
ops.add(tcppingAddress, Values.of("add-index", 0)
@@ -104,34 +118,31 @@ public void configureTcpDiscovery() throws Exception {
.and("stack", "tcp")
.and("protocol", "MPING"));
- // Configure TCP transport for container networking.
+ // Configure TCP transport.
// When JGroups binds to 0.0.0.0, it auto-detects a physical address via
- // InetAddress.getLocalHost(). In Podman rootless containers this often resolves
- // to 127.0.0.1 or a wrong interface, making the node unreachable by peers.
- // Setting external_addr forces JGroups to publish the Docker/Podman
- // DNS-resolvable hostname instead, and increasing sock_conn_timeout handles
- // the extra latency in Podman rootless networking (slirp4netns/pasta).
+ // InetAddress.getLocalHost(). In containers this often resolves to 127.0.0.1
+ // or a wrong interface. Setting external_addr forces JGroups to publish the
+ // correct address. sock_conn_timeout handles extra latency in Podman networking.
Address tcpTransport = Address.subsystem("jgroups")
.and("stack", "tcp")
.and("transport", "TCP");
ops.invoke("map-put", tcpTransport,
Values.of("name", "properties")
.and("key", "external_addr")
- .and("value", container.getName())).assertSuccess();
+ .and("value", externalAddr)).assertSuccess();
ops.invoke("map-put", tcpTransport,
Values.of("name", "properties")
.and("key", "sock_conn_timeout")
.and("value", "10000")).assertSuccess();
- log.info("JGroups TCP transport configured: external_addr='{}', sock_conn_timeout=10000 on worker '{}'",
- container.getName(), container.getName());
+ log.info("JGroups TCP transport configured: external_addr='{}', initial_hosts='{}', sock_conn_timeout=10000 on worker '{}'",
+ externalAddr, initialHosts, container.getName());
// Configure FD_SOCK2 for socket-based failure detection.
// FD_SOCK2 detects member failure almost instantly when the TCP socket is closed,
- // unlike FD_ALL3 which relies on heartbeat timeouts (hard to tune for CI Podman
- // networking where heartbeat gaps of 8-33s cause false suspicions or slow detection).
+ // unlike FD_ALL3 which relies on heartbeat timeouts.
// WildFly 40+ / EAP 8.2+ removed FD_SOCK2 from the default TCP stack (WFLY-20710),
- // so we re-add it. The external_addr must be set to the container hostname so peers
- // can connect to the FD_SOCK2 server socket; without it, FD_SOCK2 publishes 127.0.0.1.
+ // so we re-add it. The external_addr must be set so peers can connect to the
+ // FD_SOCK2 server socket; without it, FD_SOCK2 publishes 127.0.0.1.
Address fdSock2Address = Address.subsystem("jgroups")
.and("stack", "tcp")
.and("protocol", "FD_SOCK2");
@@ -139,30 +150,24 @@ public void configureTcpDiscovery() throws Exception {
ops.invoke("map-put", fdSock2Address,
Values.of("name", "properties")
.and("key", "external_addr")
- .and("value", container.getName())).assertSuccess();
+ .and("value", externalAddr)).assertSuccess();
log.info("FD_SOCK2 external_addr='{}' configured on worker '{}'",
- container.getName(), container.getName());
+ externalAddr, container.getName());
} else {
// Re-add FD_SOCK2 at index 2: after TCPPING(0) and MERGE3(1), before FD_ALL3(2→3).
- // This matches the standard JGroups TCP stack protocol ordering.
ModelNode fdSock2Props = new ModelNode();
- fdSock2Props.get("external_addr").set(container.getName());
+ fdSock2Props.get("external_addr").set(externalAddr);
ops.add(fdSock2Address, Values.of("add-index", 2)
.and("properties", fdSock2Props)).assertSuccess();
log.info("FD_SOCK2 re-added to TCP stack with external_addr='{}' on worker '{}' " +
- "(WFLY-20710 removed it; needed for reliable failure detection in containers)",
- container.getName(), container.getName());
+ "(WFLY-20710 removed it; needed for reliable failure detection)",
+ externalAddr, container.getName());
}
// Tune FD_ALL3 for fast failure detection as a backup to FD_SOCK2.
- // FD_SOCK2 handles primary failure detection (instant socket-close on ring neighbor).
- // FD_ALL3 detects failures for non-ring members and hung (alive but unresponsive) nodes.
// 5s timeout with 1.5s interval gives ~3 heartbeat windows — fast enough for
- // Infinispan's 6s clusterReplyTimeout (ISPN000476), while tolerating brief heartbeat
- // gaps in container networking. With FD_SOCK2 guaranteed present (re-added above),
- // view changes are driven by socket-close events, not heartbeat misses, so FD_ALL3
- // false suspicions during view processing are much less likely.
+ // Infinispan's 6s clusterReplyTimeout (ISPN000476).
Address fdAll3Address = Address.subsystem("jgroups")
.and("stack", "tcp")
.and("protocol", "FD_ALL3");
@@ -189,14 +194,6 @@ public void configureTcpDiscovery() throws Exception {
.and("key", "join_timeout")
.and("value", "10000")).assertSuccess();
- // VERIFY_SUSPECT/VERIFY_SUSPECT2 is left at its WildFly default (5000ms).
- // Reducing it to 2000ms caused false-positive split-brain: during view changes
- // (e.g. graceful stop of one node), heartbeat processing stalls on surviving
- // workers. FD_ALL3 suspects healthy members, and with only 2s verification,
- // the suspicion is confirmed before the healthy node responds — triggering
- // ISPN000481 ("originator not in cluster view"). The default 5s gives enough
- // headroom for FD_ALL3 false positives to self-correct.
-
log.info("JGroups TCP clustering configured on worker '{}'", container.getName());
}
@@ -368,7 +365,7 @@ private void logNetworkDiagnostics() {
log.warn("=== Network diagnostics from '{}' (cluster formation failed) ===", container.getName());
try {
// Show /etc/hosts to check hostname resolution
- ExecResult hostsResult = container.getContainer().execInContainer("cat", "/etc/hosts");
+ CommandResult hostsResult = container.execCommand("cat", "/etc/hosts");
log.warn("/etc/hosts on '{}':\n{}", container.getName(), hostsResult.getStdout().trim());
// Test DNS and TCP connectivity to each worker
@@ -376,12 +373,12 @@ private void logNetworkDiagnostics() {
for (String worker : workers) {
if (worker.equals(container.getName())) continue;
- ExecResult dnsResult = container.getContainer().execInContainer("getent", "hosts", worker);
+ CommandResult dnsResult = container.execCommand("getent", "hosts", worker);
log.warn("DNS '{}' from '{}': exit={} result='{}'",
worker, container.getName(), dnsResult.getExitCode(), dnsResult.getStdout().trim());
if (dnsResult.getExitCode() == 0) {
- ExecResult tcpResult = container.getContainer().execInContainer("bash", "-c",
+ CommandResult tcpResult = container.execCommand("bash", "-c",
"timeout 3 bash -c 'echo > /dev/tcp/" + worker + "/7600' 2>&1 && echo 'TCP_OK' || echo 'TCP_FAIL'");
log.warn("TCP {}:7600 from '{}': {}",
worker, container.getName(), tcpResult.getStdout().trim());
@@ -389,7 +386,7 @@ private void logNetworkDiagnostics() {
}
// Show network interfaces
- ExecResult ipResult = container.getContainer().execInContainer("ip", "addr", "show");
+ CommandResult ipResult = container.execCommand("ip", "addr", "show");
log.warn("Network interfaces on '{}':\n{}", container.getName(), ipResult.getStdout().trim());
} catch (Exception ex) {
log.warn("Failed to collect diagnostics from '{}': {}", container.getName(), ex.getMessage());
diff --git a/src/test/java/org/jboss/modcluster/test/utils/WildFlyLoadMetricsManager.java b/src/test/java/org/jboss/modcluster/test/utils/WildFlyLoadMetricsManager.java
index 991ddba..3972fe6 100644
--- a/src/test/java/org/jboss/modcluster/test/utils/WildFlyLoadMetricsManager.java
+++ b/src/test/java/org/jboss/modcluster/test/utils/WildFlyLoadMetricsManager.java
@@ -3,7 +3,7 @@
import org.jboss.dmr.ModelNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.testcontainers.containers.Container;
+import org.jboss.modcluster.test.utils.CommandResult;
import org.wildfly.extras.creaper.core.online.ModelNodeResult;
import org.wildfly.extras.creaper.core.online.operations.Address;
import org.wildfly.extras.creaper.core.online.operations.Operations;
@@ -20,12 +20,21 @@ public class WildFlyLoadMetricsManager {
private static final Logger log = LoggerFactory.getLogger(WildFlyLoadMetricsManager.class);
- private final WildFlyContainer container;
+ private final WildFlyWorker container;
- WildFlyLoadMetricsManager(WildFlyContainer container) {
+ WildFlyLoadMetricsManager(WildFlyWorker container) {
this.container = container;
}
+ /**
+ * Get the default load file path for this worker's custom load metric.
+ * Each worker gets a unique filename to avoid collisions in native mode
+ * (where workers share the host filesystem).
+ */
+ public String getLoadFilePath() {
+ return container.getTempDirectory() + "/modcluster-load-" + container.getName() + ".txt";
+ }
+
/**
* Configure which built-in load metric to use.
* By default, WildFly has "cpu" metric. This only changes config if needed.
@@ -152,12 +161,13 @@ public void writeLoadValue(int loadValue, String filePath) throws Exception {
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
- Container.ExecResult result = container.getContainer().execInContainer(
- "sh", "-c", String.format("echo 'LOAD: %d' > %s", loadValue, filePath));
-
- if (result.getExitCode() != 0) {
- throw new RuntimeException("Failed to write load value to " + filePath +
- " on worker '" + container.getName() + "': " + result.getStderr());
+ java.io.File tempFile = java.io.File.createTempFile("modcluster-load-", ".txt");
+ tempFile.deleteOnExit();
+ try {
+ java.nio.file.Files.writeString(tempFile.toPath(), "LOAD: " + loadValue + "\n");
+ container.copyLocalFile(tempFile.toPath(), filePath);
+ } finally {
+ tempFile.delete();
}
log.debug("Load value {} written to {} on worker '{}'", loadValue, filePath, container.getName());
@@ -188,11 +198,11 @@ public void writeLoadValue(int loadValue, String filePath) throws Exception {
*/
public boolean hasCustomLoadMetricModule() throws Exception {
try {
- Container.ExecResult jarCheck = container.getContainer().execInContainer(
- "test", "-f", "/opt/wildfly/modules/org/jboss/modcluster/test/metric/main/custom-load-metric.jar"
+ CommandResult jarCheck = container.execCommand(
+ "test", "-f", container.getServerHome() + "/modules/org/jboss/modcluster/test/metric/main/custom-load-metric.jar"
);
- Container.ExecResult xmlCheck = container.getContainer().execInContainer(
- "test", "-f", "/opt/wildfly/modules/org/jboss/modcluster/test/metric/main/module.xml"
+ CommandResult xmlCheck = container.execCommand(
+ "test", "-f", container.getServerHome() + "/modules/org/jboss/modcluster/test/metric/main/module.xml"
);
return jarCheck.getExitCode() == 0 && xmlCheck.getExitCode() == 0;
} catch (Exception e) {
@@ -207,9 +217,9 @@ public boolean hasCustomLoadMetricModule() throws Exception {
* @throws Exception if listing fails
*/
public String listCustomLoadMetricModule() throws Exception {
- Container.ExecResult result = container.getContainer().execInContainer(
+ CommandResult result = container.execCommand(
"sh", "-c",
- "ls -la /opt/wildfly/modules/org/jboss/modcluster/test/metric/main/ 2>&1"
+ "ls -la " + container.getServerHome() + "/modules/org/jboss/modcluster/test/metric/main/ 2>&1"
);
return result.getStdout();
}
diff --git a/src/test/java/org/jboss/modcluster/test/utils/WildFlyModClusterManager.java b/src/test/java/org/jboss/modcluster/test/utils/WildFlyModClusterManager.java
index deafe22..8cba01d 100644
--- a/src/test/java/org/jboss/modcluster/test/utils/WildFlyModClusterManager.java
+++ b/src/test/java/org/jboss/modcluster/test/utils/WildFlyModClusterManager.java
@@ -22,14 +22,14 @@ public class WildFlyModClusterManager {
private static final Logger log = LoggerFactory.getLogger(WildFlyModClusterManager.class);
- private final WildFlyContainer container;
+ private final WildFlyWorker container;
private String mcmpListener = "default";
private int mcmpPort = -1;
private String mcmpSslContext;
private int desiredMaxAttempts = -1;
- WildFlyModClusterManager(WildFlyContainer container) {
+ WildFlyModClusterManager(WildFlyWorker container) {
this.container = container;
}
@@ -61,6 +61,10 @@ public void setDesiredMaxAttempts(int maxAttempts) {
log.info("Pre-configured max-attempts={} on worker '{}'", maxAttempts, container.getName());
}
+ int getDesiredMaxAttempts() {
+ return desiredMaxAttempts;
+ }
+
/**
* Configure static proxy connection to the balancer.
* Creates an outbound-socket-binding and configures mod_cluster to use it.
@@ -86,7 +90,7 @@ public void configureStaticProxy() throws Exception {
address.add("socket-binding-group", "standard-sockets");
address.add("remote-destination-outbound-socket-binding", "modcluster-balancer");
addSocketBinding.get("operation").set("add");
- addSocketBinding.get("host").set("balancer");
+ addSocketBinding.get("host").set(container.getProxyHost());
addSocketBinding.get("port").set(effectiveMcmpPort);
ModelNode result = client.execute(addSocketBinding);
diff --git a/src/test/java/org/jboss/modcluster/test/utils/WildFlyUndertowManager.java b/src/test/java/org/jboss/modcluster/test/utils/WildFlyUndertowManager.java
index 2f4d87e..a469160 100644
--- a/src/test/java/org/jboss/modcluster/test/utils/WildFlyUndertowManager.java
+++ b/src/test/java/org/jboss/modcluster/test/utils/WildFlyUndertowManager.java
@@ -17,9 +17,9 @@ public class WildFlyUndertowManager {
private static final Logger log = LoggerFactory.getLogger(WildFlyUndertowManager.class);
- private final WildFlyContainer container;
+ private final WildFlyWorker container;
- WildFlyUndertowManager(WildFlyContainer container) {
+ WildFlyUndertowManager(WildFlyWorker container) {
this.container = container;
}
diff --git a/src/test/java/org/jboss/modcluster/test/utils/WildFlyWorker.java b/src/test/java/org/jboss/modcluster/test/utils/WildFlyWorker.java
new file mode 100644
index 0000000..e56dce0
--- /dev/null
+++ b/src/test/java/org/jboss/modcluster/test/utils/WildFlyWorker.java
@@ -0,0 +1,385 @@
+package org.jboss.modcluster.test.utils;
+
+import org.jboss.modcluster.test.utils.balancer.Balancer;
+import org.jboss.dmr.ModelNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.wildfly.extras.creaper.core.online.OnlineManagementClient;
+import org.wildfly.extras.creaper.core.online.operations.Operations;
+import org.wildfly.extras.creaper.core.online.operations.admin.Administration;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+/**
+ * Abstract wrapper for a WildFly/EAP worker with mod_cluster subsystem.
+ * Platform-independent API — Docker and native implementations provide
+ * concrete process management, file I/O, and networking.
+ */
+public abstract class WildFlyWorker {
+
+ private static final Logger log = LoggerFactory.getLogger(WildFlyWorker.class);
+
+ /** WildFly log message code emitted when the server has started successfully. */
+ public static final String STARTUP_LOG_PATTERN = "WFLYSRV0025";
+
+ private final String name;
+ private final Balancer balancer;
+ protected String javaOpts;
+ protected OnlineManagementClient managementClient;
+ private WildFlyDeploymentManager deploymentManager;
+ private WildFlyModClusterManager modClusterManager;
+ private WildFlyUndertowManager undertowManager;
+ private WildFlyLoadMetricsManager loadMetricsManager;
+ private WildFlyJGroupsManager jgroupsManager;
+
+ protected WildFlyWorker(String name, Balancer balancer) {
+ this.name = name;
+ this.balancer = balancer;
+ }
+
+ /**
+ * Create a WildFlyWorker for the current test mode.
+ *
+ *
+ *
+ *
+ * @param name unique worker name (e.g. "worker1")
+ * @param balancer the balancer this worker is associated with
+ * @return a new worker instance for the current test mode
+ */
+ public static WildFlyWorker create(String name, Balancer balancer) {
+ if (TestMode.current().isNative()) {
+ return new NativeWildFlyWorker(name, balancer);
+ }
+ return new DockerWildFlyWorker(name, balancer);
+ }
+
+ // ---- Abstract methods (platform-specific) ----
+
+ /** Start the WildFly server process and wait until management is available. */
+ public abstract void start();
+
+ /** Stop the WildFly server process and release all resources. */
+ public abstract void stop();
+
+ /**
+ * Hard kill the worker (simulates crash/SIGKILL).
+ */
+ public abstract void kill() throws Exception;
+
+ /** Whether the WildFly server process is currently running. */
+ public abstract boolean isRunning();
+
+ /** External HTTP URL for test client requests (e.g. {@code http://localhost:8180}). */
+ public abstract String getHttpUrl();
+
+ /** External HTTPS URL for test client requests (e.g. {@code https://localhost:8543}). */
+ public abstract String getHttpsUrl();
+
+ /** External management URL for Creaper connections (e.g. {@code http://localhost:10090}). */
+ public abstract String getManagementUrl();
+
+ /**
+ * Get the internal URL reachable by other workers/balancer on the same network.
+ * Docker: uses container hostname. Native: uses localhost with port offset.
+ */
+ public abstract String getInternalHttpUrl();
+
+ /**
+ * Get the hostname the balancer is reachable at from this worker.
+ * Docker: returns the network alias (e.g. "balancer"). Native: returns "localhost".
+ */
+ public abstract String getProxyHost();
+
+ /**
+ * Get the management interface host for Creaper connections.
+ */
+ protected abstract String getManagementHost();
+
+ /**
+ * Get the management interface port for Creaper connections.
+ */
+ protected abstract int getManagementPort();
+
+ /**
+ * Get the server home directory path.
+ *
+ *
+ *
+ *
+ * @param type the balancer type (UNDERTOW or HTTPD)
+ * @return a new balancer instance for the current test mode
+ */
+ public static Balancer create(BalancerType type) {
+ TestMode mode = TestMode.current();
+ switch (type) {
+ case UNDERTOW:
+ return mode.isNative() ? new NativeUndertowBalancer() : new DockerUndertowBalancer();
+ case HTTPD:
+ return mode.isNative() ? new NativeHttpdBalancer() : new DockerHttpdBalancer();
+ default:
+ throw new IllegalArgumentException("Unknown balancer type: " + type);
+ }
+ }
+
+ // ---- Abstract lifecycle methods ----
+
+ /** Start the balancer process and wait until it is ready to accept connections. */
+ public abstract void start();
+
+ /** Stop the balancer process and release all resources. */
+ public abstract void stop();
+
+ /**
+ * Start this balancer on the same logical network as another balancer.
+ * Docker: shares Docker network. Native: everything is on localhost already.
+ *
+ * @param other existing balancer to share network with
+ * @param alias alias for this balancer on the network
+ */
+ public abstract void startOnSameNetworkAs(Balancer other, String alias);
+
+ // ---- Abstract platform-specific methods ----
+
+ /** External HTTP URL for test client requests (e.g. {@code http://localhost:8080}). */
+ public abstract String getHttpUrl();
+
+ /** External HTTPS URL for test client requests (e.g. {@code https://localhost:8443}). */
+ public abstract String getHttpsUrl();
+
+ /** External MCMP URL for management protocol queries (e.g. {@code http://localhost:8090}). */
+ public abstract String getMcmpUrl();
+
+ /** Internal HTTP URL reachable by workers on the same network. */
+ public abstract String getInternalHttpUrl();
+
+ /**
+ * Get the hostname this balancer is reachable at from workers.
+ * Docker: returns the network alias (e.g. "balancer", "balancer2").
+ * Native: returns "localhost".
+ */
+ public abstract String getProxyHost();
+
+ /**
+ * Get the management interface host for Creaper connections.
+ */
+ public abstract String getManagementHost();
+
+ /**
+ * Get the management interface port for Creaper connections.
+ */
+ public abstract int getManagementPort();
+
+ /** Whether the balancer process is currently running. */
+ public abstract boolean isRunning();
+
+ /**
+ * Get the server home directory path.
+ *
+ *
+ *
+ *
+ * @see McmpClient
+ * @see NativePortAllocator
+ */
+class NativeHttpdBalancer extends Balancer {
+
+ private static final Logger log = LoggerFactory.getLogger(NativeHttpdBalancer.class);
+
+ /** httpd ports — no offset (single httpd instance). */
+ private static final int HTTP_PORT = 8080;
+ private static final int HTTPS_PORT = 8443;
+ private static final int MCMP_PORT = NativePortAllocator.HTTPD_MCMP_PORT;
+
+ private Path httpdHome;
+ private Path httpdBinary;
+ private Path confFile;
+ private NativeProcessManager processManager;
+ private McmpClient mcmpClient;
+
+ @Override
+ public void start() {
+ type = BalancerType.HTTPD;
+
+ try {
+ Path jbcsZip = findJbcsZip();
+ Path extractionRoot = extractJbcsZip(jbcsZip);
+ extractConnectorsIfAvailable(jbcsZip);
+ httpdBinary = findHttpdBinary(extractionRoot);
+ // Derive httpdHome from the binary location (parent of bin/ or sbin/)
+ httpdHome = httpdBinary.getParent().getParent();
+
+ runPostinstallIfNeeded(httpdHome);
+
+ confFile = findHttpdConf(httpdHome);
+ if (confFile == null) {
+ throw new RuntimeException("httpd.conf not found under " + httpdHome
+ + " (even after postinstall). Check the JBCS distribution layout.");
+ }
+
+ patchHttpdConf();
+ copyModProxyClusterConf();
+ removeConflictingConfigs();
+ Files.createDirectories(confFile.getParent().resolve("extra"));
+
+ List
+ *
+ *
+ *