Add clustering tests to new test framework

Closes #39962

Signed-off-by: Michal Hajas <mhajas@redhat.com>
Co-authored-by: Pedro Ruivo <pruivo@redhat.com>
This commit is contained in:
Michal Hajas
2025-06-13 20:26:07 +02:00
committed by GitHub
parent 7736ca20e9
commit d2f4635ea0
21 changed files with 569 additions and 41 deletions

View File

@@ -105,6 +105,12 @@
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-clustering</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-parent</artifactId>
<version>999.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>keycloak-test-framework-clustering</artifactId>
<dependencies>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-core</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,15 @@
package org.keycloak.testframework;
import org.keycloak.testframework.clustering.LoadBalancerSupplier;
import org.keycloak.testframework.injection.Supplier;
import org.keycloak.testframework.server.ClusteredKeycloakServerSupplier;
import java.util.List;
public class ClusteringTestFrameworkExtension implements TestFrameworkExtension {
@Override
public List<Supplier<?, ?>> suppliers() {
return List.of(new ClusteredKeycloakServerSupplier(), new LoadBalancerSupplier());
}
}

View File

@@ -0,0 +1,11 @@
package org.keycloak.testframework.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectLoadBalancer {
}

View File

@@ -0,0 +1,26 @@
package org.keycloak.testframework.clustering;
import org.keycloak.testframework.server.ClusteredKeycloakServer;
import org.keycloak.testframework.server.KeycloakUrls;
import java.util.HashMap;
public class LoadBalancer {
private final ClusteredKeycloakServer server;
private final HashMap<Integer, KeycloakUrls> urls = new HashMap<>();
public LoadBalancer(ClusteredKeycloakServer server) {
this.server = server;
}
public KeycloakUrls node(int nodeIndex) {
if (nodeIndex >= server.clusterSize()) {
throw new IllegalArgumentException("Node index out of bounds. Requested nodeIndex: %d, cluster size: %d".formatted(server.clusterSize(), nodeIndex));
}
return urls.computeIfAbsent(nodeIndex, i -> new KeycloakUrls(server.getBaseUrl(i), server.getManagementBaseUrl(i)));
}
public int clusterSize() {
return server.clusterSize();
}
}

View File

@@ -0,0 +1,27 @@
package org.keycloak.testframework.clustering;
import org.keycloak.testframework.annotations.InjectLoadBalancer;
import org.keycloak.testframework.injection.InstanceContext;
import org.keycloak.testframework.injection.RequestedInstance;
import org.keycloak.testframework.injection.Supplier;
import org.keycloak.testframework.server.ClusteredKeycloakServer;
import org.keycloak.testframework.server.KeycloakServer;
public class LoadBalancerSupplier implements Supplier<LoadBalancer, InjectLoadBalancer> {
@Override
public LoadBalancer getValue(InstanceContext<LoadBalancer, InjectLoadBalancer> instanceContext) {
KeycloakServer server = instanceContext.getDependency(KeycloakServer.class);
if (server instanceof ClusteredKeycloakServer clusteredKeycloakServer) {
return new LoadBalancer(clusteredKeycloakServer);
}
throw new IllegalStateException("Load balancer can only be used with ClusteredKeycloakServer");
}
@Override
public boolean compatible(InstanceContext<LoadBalancer, InjectLoadBalancer> a, RequestedInstance<LoadBalancer, InjectLoadBalancer> b) {
return true;
}
}

View File

@@ -0,0 +1,144 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testframework.server;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Objects;
import io.quarkus.bootstrap.utils.BuildToolHelper;
import org.jboss.logging.Logger;
import org.keycloak.it.utils.DockerKeycloakDistribution;
import org.keycloak.testframework.database.JBossLogConsumer;
import org.testcontainers.images.RemoteDockerImage;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.LazyFuture;
public class ClusteredKeycloakServer implements KeycloakServer {
private static final boolean MANUAL_STOP = true;
private static final int REQUEST_PORT = 8080;
private static final int MANAGEMENT_PORT = 9000;
public static final String SNAPSHOT_IMAGE = "-";
private final DockerKeycloakDistribution[] containers;
private final String images;
private static LazyFuture<String> defaultImage() {
return DockerKeycloakDistribution.createImage(true);
}
public ClusteredKeycloakServer(int mumServers, String images) {
containers = new DockerKeycloakDistribution[mumServers];
this.images = images;
}
@Override
public void start(KeycloakServerConfigBuilder configBuilder) {
String[] imagePeServer = null;
if (images == null || images.isEmpty() || (imagePeServer = images.split(",")).length == 1) {
startContainersWithSameImage(configBuilder, imagePeServer == null ? SNAPSHOT_IMAGE : imagePeServer[0]);
} else {
startContainersWithMixedImage(configBuilder, imagePeServer);
}
}
private void startContainersWithMixedImage(KeycloakServerConfigBuilder configBuilder, String[] imagePeServer) {
assert imagePeServer != null;
if (containers.length != imagePeServer.length) {
throw new IllegalArgumentException("The number of containers and the number of images must match");
}
int[] exposedPorts = new int[]{REQUEST_PORT, MANAGEMENT_PORT};
LazyFuture<String> snapshotImage = null;
for (int i = 0; i < containers.length; ++i) {
LazyFuture<String> resolvedImage;
if (SNAPSHOT_IMAGE.equals(imagePeServer[i])) {
if (snapshotImage == null) {
snapshotImage = defaultImage();
}
resolvedImage = snapshotImage;
} else {
resolvedImage = new RemoteDockerImage(DockerImageName.parse(imagePeServer[i]));
}
var container = new DockerKeycloakDistribution(false, MANUAL_STOP, REQUEST_PORT, exposedPorts, resolvedImage);
containers[i] = container;
copyProvidersAndConfigs(container, configBuilder);
container.setCustomLogConsumer(new JBossLogConsumer(Logger.getLogger("managed.keycloak." + i)));
container.run(configBuilder.toArgs());
}
}
private void startContainersWithSameImage(KeycloakServerConfigBuilder configBuilder, String image) {
int[] exposedPorts = new int[]{REQUEST_PORT, MANAGEMENT_PORT};
LazyFuture<String> imageFuture = image == null || SNAPSHOT_IMAGE.equals(image) ?
defaultImage() :
new RemoteDockerImage(DockerImageName.parse(image));
for (int i = 0; i < containers.length; ++i) {
var container = new DockerKeycloakDistribution(false, MANUAL_STOP, REQUEST_PORT, exposedPorts, imageFuture);
containers[i] = container;
copyProvidersAndConfigs(container, configBuilder);
container.setCustomLogConsumer(new JBossLogConsumer(Logger.getLogger("managed.keycloak." + i)));
container.run(configBuilder.toArgs());
}
}
private void copyProvidersAndConfigs(DockerKeycloakDistribution container, KeycloakServerConfigBuilder configBuilder) {
for (var dependency : configBuilder.toDependencies()) {
container.copyProvider(dependency.getGroupId(), dependency.getArtifactId());
}
for(var config : configBuilder.toConfigFiles()) {
container.copyConfigFile(config);
}
}
@Override
public void stop() {
Arrays.stream(containers)
.filter(Objects::nonNull)
.forEach(DockerKeycloakDistribution::stop);
}
@Override
public String getBaseUrl() {
return getBaseUrl(0);
}
@Override
public String getManagementBaseUrl() {
return getManagementBaseUrl(0);
}
public String getBaseUrl(int index) {
return "http://localhost:%d".formatted(containers[index].getMappedPort(REQUEST_PORT));
}
public String getManagementBaseUrl(int index) {
return "http://localhost:%d".formatted(containers[index].getMappedPort(MANAGEMENT_PORT));
}
public int clusterSize() {
return containers.length;
}
}

View File

@@ -0,0 +1,42 @@
package org.keycloak.testframework.server;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
public class ClusteredKeycloakServerSupplier extends AbstractKeycloakServerSupplier {
private static final Logger LOGGER = Logger.getLogger(ClusteredKeycloakServerSupplier.class);
@ConfigProperty(name = "numContainer", defaultValue = "2")
int numContainers = 2;
@ConfigProperty(name = "images", defaultValue = ClusteredKeycloakServer.SNAPSHOT_IMAGE)
String images = ClusteredKeycloakServer.SNAPSHOT_IMAGE;
@Override
public KeycloakServer getServer() {
return new ClusteredKeycloakServer(numContainers, images);
}
@Override
public boolean requiresDatabase() {
return true;
}
@Override
public String getAlias() {
return "cluster";
}
@Override
public Logger getLogger() {
return LOGGER;
}
@Override
protected String cache() {
return "ispn";
}
}

View File

@@ -0,0 +1 @@
org.keycloak.testframework.ClusteringTestFrameworkExtension

View File

@@ -53,9 +53,13 @@ public abstract class AbstractContainerTestDatabase implements TestDatabase {
@Override
public Map<String, String> serverConfig() {
return serverConfig(false);
}
public Map<String, String> serverConfig(boolean internal) {
return Map.of(
"db", getDatabaseVendor(),
"db-url", getJdbcUrl(),
"db-url", getJdbcUrl(internal),
"db-username", getUsername(),
"db-password", getPassword()
);
@@ -79,8 +83,13 @@ public abstract class AbstractContainerTestDatabase implements TestDatabase {
return "keycloak";
}
public String getJdbcUrl() {
return container.getJdbcUrl();
public String getJdbcUrl(boolean internal) {
var url = container.getJdbcUrl();
if (internal) {
var ip = container.getContainerInfo().getNetworkSettings().getNetworks().values().iterator().next().getIpAddress();
return url.replace(container.getHost() + ":" + container.getFirstMappedPort(), ip + ":" + container.getExposedPorts().get(0));
}
return url;
}
public abstract String getDatabaseVendor();

View File

@@ -1,11 +1,13 @@
package org.keycloak.testframework.database;
import org.keycloak.testframework.annotations.InjectTestDatabase;
import org.keycloak.testframework.config.Config;
import org.keycloak.testframework.injection.InstanceContext;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.injection.RequestedInstance;
import org.keycloak.testframework.injection.Supplier;
import org.keycloak.testframework.injection.SupplierOrder;
import org.keycloak.testframework.server.KeycloakServer;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.keycloak.testframework.server.KeycloakServerConfigInterceptor;
@@ -37,7 +39,17 @@ public abstract class AbstractDatabaseSupplier implements Supplier<TestDatabase,
@Override
public KeycloakServerConfigBuilder intercept(KeycloakServerConfigBuilder serverConfig, InstanceContext<TestDatabase, InjectTestDatabase> instanceContext) {
return serverConfig.options(instanceContext.getValue().serverConfig());
String kcServerType = Config.getSelectedSupplier(KeycloakServer.class);
TestDatabase database = instanceContext.getValue();
// If both KeycloakServer and TestDatabase run in container, we need to configure Keycloak with internal
// url that is accessible within docker network
if ("cluster".equals(kcServerType) &&
database instanceof AbstractContainerTestDatabase containerDatabase) {
return serverConfig.options(containerDatabase.serverConfig(true));
}
return serverConfig.options(database.serverConfig());
}
@Override

View File

@@ -21,7 +21,7 @@ public abstract class AbstractKeycloakServerSupplier implements Supplier<Keycloa
KeycloakServerConfig serverConfig = SupplierHelpers.getInstance(annotation.config());
KeycloakServerConfigBuilder command = KeycloakServerConfigBuilder.startDev()
.cache("local")
.cache(cache())
.bootstrapAdminClient(Config.getAdminClientId(), Config.getAdminClientSecret())
.bootstrapAdminUser(Config.getAdminUsername(), Config.getAdminPassword());
@@ -74,6 +74,10 @@ public abstract class AbstractKeycloakServerSupplier implements Supplier<Keycloa
public abstract Logger getLogger();
protected String cache() {
return "local";
}
@Override
public int order() {
return SupplierOrder.KEYCLOAK_SERVER;

View File

@@ -47,6 +47,7 @@
<module>remote</module>
<module>remote-providers</module>
<module>ui</module>
<module>clustering</module>
</modules>
</project>