Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
10 changes: 9 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -267,6 +274,7 @@ Jenkins Pipeline
| LoadBalancingGroupFailoverTest | ✓ | ✓ |
| SSLTest | ✓ | ✓ |
| DynamicReconfTest | ✓ | ✓ |
| ModClusterAjpTest | — | ✓ |

## Configuration Points

Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
11 changes: 11 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -304,10 +304,28 @@
<profile>
<id>undertow</id>
<activation>
<activeByDefault>true</activeByDefault>
<property>
<name>balancer.type</name>
<value>undertow</value>
</property>
</activation>
<properties>
<balancer.type>undertow</balancer.type>
<test.excluded.groups.balancer>httpd</test.excluded.groups.balancer>
</properties>
</profile>

<!-- Default balancer when -Dbalancer.type is not set -->
<profile>
<id>undertow-default</id>
<activation>
<property>
<name>!balancer.type</name>
</property>
</activation>
<properties>
<balancer.type>undertow</balancer.type>
<test.excluded.groups.balancer>httpd</test.excluded.groups.balancer>
</properties>
</profile>

Expand Down
117 changes: 117 additions & 0 deletions src/test/java/org/jboss/modcluster/test/ajp/ModClusterAjpTest.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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 &rarr; httpd &rarr; {@code mod_proxy_ajp} &rarr; Undertow AJP listener &rarr; response.</p>
*
* <p>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}).</p>
*
* <h3>Relationship to noe-tests</h3>
* <p>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 &mdash; the equivalent WildFly mechanism uses Undertow
* expression filters, which is a different code path.</p>
*
* @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.
*
* <p>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://<host>:8009}, and finally sends an HTTP request through the
* balancer to confirm a 200 response over the AJP data path.</p>
*
* <p>Passes if the balancer's MCMP INFO shows {@code ajp://} scheme and the
* proxied request returns HTTP 200.</p>
*/
@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<String, ModelNode> 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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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}.</p>
*
* <p>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.</p>
*
* @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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <p>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).</p>
*
* <p>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.</p>
*
* @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());
}
}
Loading