diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cddd569..1e2793b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: env: WILDFLY_VERSION: 39.0.1.Final - TEST_CLASS: StickySessionTest,SslFailoverTest,SslCrlTest + TEST_CLASS: StickySessionTest,SslFailoverTest,SslCrlTest,ModClusterAjpTest MOD_PROXY_CLUSTER_REPO: https://github.com/modcluster/mod_proxy_cluster.git jobs: @@ -44,12 +44,12 @@ jobs: - 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 }} + run: mvn -B test -Dtest=${{ env.TEST_CLASS }} -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 }} + run: mvn -B test -Pnative -Dtest=${{ env.TEST_CLASS }} -Dbalancer.type=${{ matrix.balancer }} -Dwildfly.version=${{ env.WILDFLY_VERSION }} - name: Publish test results uses: mikepenz/action-junit-report@v6 @@ -101,7 +101,6 @@ jobs: run: | mvn -B test -Pnative \ -Dtest=${{ env.TEST_CLASS }} \ - -DexcludedGroups=none \ -Dbalancer.type=httpd \ -Dhttpd.home=/usr \ -Dhttpd.modules.path=${{ github.workspace }}/target/mod_proxy_cluster/native/build/modules \ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9ab00db..0771641 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -211,7 +211,14 @@ DockerImageName.parse("quay.io/modcluster/mod_cluster-httpd:latest") **To be implemented** -### 8. Integration (`org.jboss.modcluster.test.integration`) +### 8. AJP Protocol (`org.jboss.modcluster.test.ajp`) +- AJP data path through mod_cluster +- Worker registration with AJP scheme +- End-to-end request proxying via mod_proxy_ajp + +**Example**: `ModClusterAjpTest.java` (httpd only — `@Tag("httpd")`) + +### 9. Integration (`org.jboss.modcluster.test.integration`) - EJB over HTTP - WebSockets - Full application scenarios @@ -267,6 +274,7 @@ Jenkins Pipeline | LoadBalancingGroupFailoverTest | ✓ | ✓ | | SSLTest | ✓ | ✓ | | DynamicReconfTest | ✓ | ✓ | +| ModClusterAjpTest | — | ✓ | ## Configuration Points diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc index e456e97..c12b722 100644 --- a/CONTRIBUTING.adoc +++ b/CONTRIBUTING.adoc @@ -110,6 +110,7 @@ public class MyModClusterTest { Choose the appropriate package for your test: +* `/ajp/` — AJP protocol data path tests (httpd only, `@Tag("httpd")`) * `/cli/` — Management and configuration via CLI/Creaper * `/failover/` — Failover scenarios, session migration, worker failure * `/loadbalancing/` — Load distribution, metrics, algorithms diff --git a/README.md b/README.md index 456c5fe..2495a9b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ This test suite uses: ``` src/test/java/org/jboss/modcluster/test/ +├── ajp/ # AJP protocol tests (httpd only) +│ └── ModClusterAjpTest.java ├── apps/ # Test application endpoints │ ├── ejb/ # EJB beans, client, and builders │ └── ... # WebSocket, demo app, etc. @@ -308,6 +310,9 @@ String result = worker.executeCli("/subsystem=modcluster:read-resource"); ### EJB over HTTP Tests - **EjbViaHttpTest** - HTTP invoker endpoint registration, stateful EJB stickiness with failover, stateless EJB invocation +### AJP Protocol Tests +- **ModClusterAjpTest** - AJP data path through mod_cluster (httpd only) + ### High Availability Tests - **HighAvailabilityTest** - Hot standby, multiple balancers - **SoakTest** - Long-running stability testing @@ -333,14 +338,15 @@ This test suite aims for feature parity with `noe-tests/modcluster` (64 test fil | Initial Load | Yes | InitialLoadTest | | EJB over HTTP | Yes | EjbViaHttpTest | | Soak/Stress Testing | Yes | SoakTest | +| AJP Protocol | Partial | ModClusterAjpTest | ### Not Yet Implemented -| Area | noe-tests Reference | -|------|-------------------| -| AJP Protocol | ModClusterAJP.groovy | -| mod_proxy / mod_rewrite | ModProxyTest.groovy, ModRewriteTest.groovy | -| Bug-specific regressions | JBCS*, JBQA* test files | +| Area | noe-tests Reference | Notes | +|------|-------------------|-------| +| AJP Secret Validation | ModClusterAJP.groovy | WildFly lacks native AJP secret support (Tomcat-only feature) | +| mod_proxy / mod_rewrite | ModProxyTest.groovy, ModRewriteTest.groovy | | +| Bug-specific regressions | JBCS*, JBQA* test files | | ## How It Works diff --git a/TESTING.md b/TESTING.md index 34dc0f6..911112b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -257,6 +257,17 @@ mvn test -Dtest=org.jboss.modcluster.test.ssl.* mvn test -Dtest=StickySessionTest,SSLTest,LoadBalancingGroupFailoverTest ``` +### httpd-Only Tests + +Some tests require the httpd balancer and are annotated with `@Tag("httpd")`. +These are automatically excluded when running with `-Dbalancer.type=undertow` +(the default) via the Maven undertow profile's `excludedGroups`. + +```bash +# Run AJP protocol tests (requires httpd) +mvn test -Dtest=ModClusterAjpTest -Dbalancer.type=httpd +``` + ## Matrix Testing ### Both Balancers Sequentially diff --git a/pom.xml b/pom.xml index 4fa03a9..14f5a45 100644 --- a/pom.xml +++ b/pom.xml @@ -304,10 +304,28 @@ undertow - true + + balancer.type + undertow + + + + undertow + httpd + + + + + + undertow-default + + + !balancer.type + undertow + httpd diff --git a/src/test/java/org/jboss/modcluster/test/ajp/ModClusterAjpTest.java b/src/test/java/org/jboss/modcluster/test/ajp/ModClusterAjpTest.java new file mode 100644 index 0000000..3e572f5 --- /dev/null +++ b/src/test/java/org/jboss/modcluster/test/ajp/ModClusterAjpTest.java @@ -0,0 +1,117 @@ +package org.jboss.modcluster.test.ajp; + +import org.jboss.dmr.ModelNode; +import org.jboss.modcluster.test.base.ModClusterTestExtension; +import org.jboss.modcluster.test.base.ModClusterTestExtension.TestCluster; +import org.jboss.modcluster.test.utils.HttpClient; +import org.jboss.modcluster.test.utils.HttpClient.HttpResponse; +import org.jboss.modcluster.test.utils.NativePortAllocator; +import org.jboss.modcluster.test.utils.TestMode; +import org.jboss.modcluster.test.utils.TestTimeouts; +import org.jboss.modcluster.test.utils.WildFlyWorker; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.util.Map; + +import static java.time.Duration.ofSeconds; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.jboss.modcluster.test.utils.WildFlyDeploymentManager.DEMO_APP; + +/** + * Tests mod_cluster communication with backend workers over the AJP protocol. + * + *

By default, mod_cluster registers the worker's HTTP listener with the balancer + * and all proxied traffic flows over HTTP. This test reconfigures the mod_cluster + * subsystem to register an AJP listener instead, verifying the full AJP data path: + * client → httpd → {@code mod_proxy_ajp} → Undertow AJP listener → response.

+ * + *

Requires the httpd balancer because Undertow-based balancers do not support + * AJP backend connections (the {@code @Tag("httpd")} annotation causes this test + * to be skipped when {@code -Dbalancer.type=undertow}).

+ * + *

Relationship to noe-tests

+ *

Ported from noe-tests {@code ModClusterAJP.groovy}. The original test also + * validates AJP secret matching ({@code AJPSecret} directive vs. Tomcat's + * {@code secretRequired}/{@code secret} connector attributes). Those tests are + * omitted here because WildFly's Undertow AJP listener does not support native + * secret validation — the equivalent WildFly mechanism uses Undertow + * expression filters, which is a different code path.

+ * + * @see org.jboss.modcluster.test.utils.WildFlyUndertowManager#addAjpListener(String, String, String, int) + * @see org.jboss.modcluster.test.utils.WildFlyModClusterManager#setListener(String) + */ +@Tag("httpd") +@ExtendWith(ModClusterTestExtension.class) +public class ModClusterAjpTest { + + private static final Logger log = LoggerFactory.getLogger(ModClusterAjpTest.class); + + private static final int AJP_PORT = 8009; + private static final String AJP_LISTENER = "ajp"; + private static final String AJP_SOCKET_BINDING = "ajp"; + + /** + * Verifies that mod_cluster registers the worker over AJP and that HTTP requests + * are correctly proxied through the AJP data path. + * + *

The test adds an AJP listener on port {@value AJP_PORT}, switches the + * mod_cluster proxy's {@code listener} attribute from {@code "default"} (HTTP) + * to {@code "ajp"}, and waits for the worker to re-register. It then asserts + * that the balancer's MCMP INFO reports the worker with {@code Type: ajp} and + * URI {@code ajp://:8009}, and finally sends an HTTP request through the + * balancer to confirm a 200 response over the AJP data path.

+ * + *

Passes if the balancer's MCMP INFO shows {@code ajp://} scheme and the + * proxied request returns HTTP 200.

+ */ + @Test + public void testTrafficFlowsThroughAjp(final TestCluster cluster, + final HttpClient httpClient) throws Exception { + cluster.startWorkers(1); + WildFlyWorker worker = cluster.getWorker1(); + + worker.undertow().addAjpListener(AJP_LISTENER, "default-server", AJP_SOCKET_BINDING, AJP_PORT); + worker.reload(); + worker.modCluster().setListener(AJP_LISTENER); + + log.info("Waiting for worker to re-register with AJP scheme on balancer"); + await().atMost(TestTimeouts.CLUSTER_FORMATION) + .pollInterval(ofSeconds(5)) + .untilAsserted(() -> { + Map workers = cluster.getBalancer().getWorkerInfo(); + assertThat(workers).containsKey(worker.getName()); + URI uri = new URI(workers.get(worker.getName()).get("uri").asString()); + assertThat(uri.getScheme()).isEqualTo("ajp"); + int expectedPort = TestMode.current().isNative() + ? AJP_PORT + NativePortAllocator.offset(worker.getName()) + : AJP_PORT; + assertThat(uri.getPort()).isEqualTo(expectedPort); + }); + + URI registeredUri = new URI(cluster.getBalancer().getWorkerInfo() + .get(worker.getName()).get("uri").asString()); + log.info("Worker registered as: {}", registeredUri); + + worker.deployment().deployDemoApp(); + + String url = cluster.getBalancer().getHttpUrl() + "/" + DEMO_APP + "/"; + await().atMost(TestTimeouts.CLUSTER_FORMATION) + .pollInterval(ofSeconds(2)) + .ignoreExceptions() + .untilAsserted(() -> { + HttpResponse response = httpClient.get(url); + assertThat(response.getStatusCode()).isEqualTo(200); + }); + + HttpResponse response = httpClient.get(url); + log.info("Response via AJP: status={}", response.getStatusCode()); + assertThat(response.getStatusCode()).isEqualTo(200); + } + +} diff --git a/src/test/java/org/jboss/modcluster/test/cli/MultipleUndertowServerSupportTest.java b/src/test/java/org/jboss/modcluster/test/cli/MultipleUndertowServerSupportTest.java index 67ea16d..51a90a3 100644 --- a/src/test/java/org/jboss/modcluster/test/cli/MultipleUndertowServerSupportTest.java +++ b/src/test/java/org/jboss/modcluster/test/cli/MultipleUndertowServerSupportTest.java @@ -161,10 +161,9 @@ public void testRegisterOneNodeWithTwoBalancers(final TestCluster cluster) throw balancer2.startOnSameNetworkAs(balancer1, "balancer2"); log.info("Second balancer started: {}", balancer2.getHttpUrl()); - // Create second Undertow server + socket binding + AJP listener on worker + // Create second Undertow server + AJP listener on worker worker.undertow().addServer(secondServerName); - worker.undertow().addSocketBinding(socketBindingName, SECOND_LISTENER_PORT); - worker.undertow().addAjpListener(ajpListenerName, secondServerName, socketBindingName); + worker.undertow().addAjpListener(ajpListenerName, secondServerName, socketBindingName, SECOND_LISTENER_PORT); // Create outbound-socket-binding pointing to balancer2 Operations ops = worker.getOperations(); @@ -310,13 +309,9 @@ public void proxyConfigurationIndependence(final TestCluster cluster) throws Exc // Create second Undertow server worker.undertow().addServer(secondServerName); - // Create socket bindings - worker.undertow().addSocketBinding(secondSocketName, secondSocketPort); - worker.undertow().addSocketBinding(thirdSocketName, thirdSocketPort); - // Create AJP listeners on the second server - worker.undertow().addAjpListener(secondListenerName, secondServerName, secondSocketName); - worker.undertow().addAjpListener(thirdListenerName, secondServerName, thirdSocketName); + worker.undertow().addAjpListener(secondListenerName, secondServerName, secondSocketName, secondSocketPort); + worker.undertow().addAjpListener(thirdListenerName, secondServerName, thirdSocketName, thirdSocketPort); // Create two additional mod_cluster proxies ops.add(secondProxyAddr, Values.of("listener", secondListenerName)) 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 8cba01d..5f39f39 100644 --- a/src/test/java/org/jboss/modcluster/test/utils/WildFlyModClusterManager.java +++ b/src/test/java/org/jboss/modcluster/test/utils/WildFlyModClusterManager.java @@ -209,6 +209,30 @@ public void writeModClusterAttribute(String attributeName, Object value) throws } } + /** + * Set the Undertow listener that mod_cluster uses to register with the balancer. + * + *

The mod_cluster subsystem advertises one Undertow listener to the balancer + * via MCMP CONFIG messages. By default this is {@code "default"} (the HTTP listener + * on port 8080). Changing it to an AJP listener causes the worker to register with + * {@code Type: ajp} and the balancer to proxy traffic via {@code mod_proxy_ajp} + * instead of {@code mod_proxy_http}.

+ * + *

The AJP listener must already exist on the worker + * (see {@link WildFlyUndertowManager#addAjpListener(String, String, String)}). + * This method triggers a server reload to apply the change.

+ * + * @param listenerName the Undertow listener name (e.g., {@code "default"} for HTTP, + * or {@code "ajp"} for an AJP listener) + * @throws IOException if there's a connection error + * @throws OperationException if the management operation fails + * @see WildFlyUndertowManager#addAjpListener(String, String, String, int) + */ + public void setListener(String listenerName) throws IOException, OperationException { + writeModClusterAttribute("listener", listenerName); + log.info("Set mod_cluster listener to '{}' on worker '{}'", listenerName, container.getName()); + } + /** * Set the balancer name this worker registers under on the balancer. * Controls which load-balancing group the worker belongs to. 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 a469160..3667f80 100644 --- a/src/test/java/org/jboss/modcluster/test/utils/WildFlyUndertowManager.java +++ b/src/test/java/org/jboss/modcluster/test/utils/WildFlyUndertowManager.java @@ -125,18 +125,41 @@ public void setHttpListenerEnableHttp2(final String serverName, final String lis /** * Add an AJP listener to a given Undertow server. * - * @param listenerName name of the AJP listener - * @param serverName name of the Undertow server to add the listener to - * @param socketBindingName name of the socket binding to use + *

Creates a socket binding on the given port (if it does not already exist), + * then adds an AJP listener bound to it on the specified server (if it does not + * already exist).

+ * + *

Idempotent — safe to call multiple times; existing resources are skipped. + * Does not reload the server; call {@link WildFlyWorker#reload()} after all + * listeners have been added.

+ * + * @param listenerName name of the AJP listener (e.g., {@code "ajp"}) + * @param serverName the Undertow server to add the listener to (e.g., {@code "default-server"}) + * @param socketBindingName name of the socket binding (e.g., {@code "ajp"}) + * @param port the port for the socket binding (e.g., 8009) * @throws Exception if the management operation fails + * @see WildFlyModClusterManager#setListener(String) */ public void addAjpListener(final String listenerName, final String serverName, - final String socketBindingName) throws Exception { - OnlineManagementClient client = container.getManagementClient(); + final String socketBindingName, final int port) throws Exception { + Operations ops = container.getOperations(); - client.apply(new AddUndertowListener.AjpBuilder(listenerName, serverName, socketBindingName) - .build()); - log.info("Added AJP listener '{}' on server '{}' with socket binding '{}' on worker '{}'", - listenerName, serverName, socketBindingName, container.getName()); + Address sbAddr = Address.of("socket-binding-group", "standard-sockets") + .and("socket-binding", socketBindingName); + if (!ops.exists(sbAddr)) { + addSocketBinding(socketBindingName, port); + } + + Address listenerAddr = Address.subsystem("undertow") + .and("server", serverName) + .and("ajp-listener", listenerName); + if (!ops.exists(listenerAddr)) { + OnlineManagementClient client = container.getManagementClient(); + client.apply(new AddUndertowListener.AjpBuilder(listenerName, serverName, socketBindingName) + .build()); + } + + log.info("AJP listener '{}' on port {} on server '{}' added on worker '{}'", + listenerName, port, serverName, container.getName()); } }