+
Skip to content

Add clustering tests to new test framework #40283

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 13, 2025
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,11 @@ jobs:
name: Integration test setup
uses: ./.github/actions/integration-test-setup

# This step is necessary because test/clustering requires building a new Keycloak image built from tar.gz
# file that is not part of m2-keycloak.tzts archive
- name: Build tar keycloak-quarkus-dist
run: ./mvnw package -pl quarkus/server/,quarkus/dist/

- name: Run tests
run: ./mvnw package -f tests/pom.xml

Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't reviewed changes to this file, perhaps ping someone from the @keycloak/cloud-native team to take a look?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I will make sure we have review from them before merging.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take a look.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Pepo48, there are some failures in CI related to changes in DockerKeycloakDistribution we will look into it.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.exception.NotFoundException;
import io.quarkus.bootstrap.utils.BuildToolHelper;
import io.restassured.RestAssured;
import org.jboss.logging.Logger;
import org.keycloak.common.Version;
Expand All @@ -16,8 +17,10 @@
import org.testcontainers.images.builder.ImageFromDockerfile;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.LazyFuture;
import org.testcontainers.utility.MountableFile;

import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
Expand All @@ -33,9 +36,16 @@ private static class BackupConsumer implements Consumer<OutputFrame> {

final ToStringConsumer stdOut = new ToStringConsumer();
final ToStringConsumer stdErr = new ToStringConsumer();
final Consumer<OutputFrame> customLogConsumer;
public BackupConsumer(Consumer<OutputFrame> customLogConsumer) {
this.customLogConsumer = customLogConsumer;
}

@Override
public void accept(OutputFrame t) {
if (customLogConsumer != null) {
customLogConsumer.accept(t);
}
if (t.getType() == OutputType.STDERR) {
stdErr.accept(t);
} else if (t.getType() == OutputType.STDOUT) {
Expand All @@ -55,58 +65,82 @@ public void accept(OutputFrame t) {

private String stdout = "";
private String stderr = "";
private BackupConsumer backupConsumer = new BackupConsumer();
private final File dockerScriptFile = new File("../../container/ubi-null.sh");

private BackupConsumer backupConsumer;
private Consumer<OutputFrame> customLogConsumer;
private GenericContainer<?> keycloakContainer = null;
private String containerId = null;

private final Executor parallelReaperExecutor = Executors.newSingleThreadExecutor();
private final Map<String, String> envVars = new HashMap<>();
private final LazyFuture<String> image;

private final Map<MountableFile, String> copyToContainer = new HashMap<>();

public DockerKeycloakDistribution(boolean debug, boolean manualStop, int requestPort, int[] exposedPorts) {
this(debug, manualStop, requestPort, exposedPorts, null);
}

public DockerKeycloakDistribution(boolean debug, boolean manualStop, int requestPort, int[] exposedPorts, LazyFuture<String> image) {
this.debug = debug;
this.manualStop = manualStop;
this.requestPort = requestPort;
this.exposedPorts = IntStream.of(exposedPorts).boxed().toArray(Integer[]::new);
this.image = image == null ? createImage(false) : image;
}

@Override
public void setEnvVar(String name, String value) {
this.envVars.put(name, value);
}

public void setCustomLogConsumer(Consumer<OutputFrame> customLogConsumer) {
this.customLogConsumer = customLogConsumer;
}

private GenericContainer<?> getKeycloakContainer() {
File distributionFile = new File("../../dist/" + File.separator + "target" + File.separator + "keycloak-" + Version.VERSION + ".tar.gz");
return new GenericContainer<>(image)
.withEnv(envVars)
.withExposedPorts(exposedPorts)
.withStartupAttempts(1)
.withStartupTimeout(Duration.ofSeconds(120))
.waitingFor(Wait.forListeningPorts(8080));
}

if (!distributionFile.exists()) {
distributionFile = Maven.resolveArtifact("org.keycloak", "keycloak-quarkus-dist").toFile();
}
public static LazyFuture<String> createImage(boolean failIfDockerFileMissing) {
Path quarkusModule = Maven.getKeycloakQuarkusModulePath();
var distributionFile = quarkusModule.resolve(Path.of("dist", "target", "keycloak-" + Version.VERSION + ".tar.gz"))
.toFile();

// In current Dockerfile we support only tar.gz keycloak distribution, this module, however. does not have this
// dependency. Adding the dependency breaks our CI as tar.gz files are not part of CI build archive.
// Adding tar.gz files to archive would double the size of each build archive.
// Therefore, for now, we support only building the image from the target folder of this module.
// if (!distributionFile.exists()) {
// distributionFile = Maven.resolveArtifact("org.keycloak", "keycloak-quarkus-dist").toFile();
// }

if (!distributionFile.exists()) {
throw new RuntimeException("Distribution archive " + distributionFile.getAbsolutePath() +" doesn't exist");
}
LOGGER.infof("Building a new docker image from distribution: %s", distributionFile.getAbsoluteFile());

File dockerFile = new File("../../container/Dockerfile");
LazyFuture<String> image;
var dockerFile = quarkusModule.resolve(Path.of("container", "Dockerfile"))
.toFile();
var ubiNullScript = quarkusModule.resolve(Path.of("container", "ubi-null.sh"))
.toFile();

if (dockerFile.exists()) {
image = new ImageFromDockerfile("keycloak-under-test", false)
return new ImageFromDockerfile("keycloak-under-test", false)
.withFileFromFile("keycloak.tar.gz", distributionFile)
.withFileFromFile("ubi-null.sh", dockerScriptFile)
.withFileFromFile("ubi-null.sh", ubiNullScript)
.withFileFromFile("Dockerfile", dockerFile)
.withBuildArg("KEYCLOAK_DIST", "keycloak.tar.gz");
toString();
} else {
image = new RemoteDockerImage(DockerImageName.parse("quay.io/keycloak/keycloak"));
if (failIfDockerFileMissing) {
throw new RuntimeException("Docker file %s not found".formatted(dockerFile.getAbsolutePath()));
}
return new RemoteDockerImage(DockerImageName.parse("quay.io/keycloak/keycloak"));
}

return new GenericContainer<>(image)
.withEnv(envVars)
.withExposedPorts(exposedPorts)
.withStartupAttempts(1)
.withStartupTimeout(Duration.ofSeconds(120))
.waitingFor(Wait.forListeningPorts(8080));
}

@Override
Expand All @@ -117,10 +151,12 @@ public CLIResult run(List<String> arguments) {
this.stdout = "";
this.stderr = "";
this.containerId = null;
this.backupConsumer = new BackupConsumer();
this.backupConsumer = new BackupConsumer(customLogConsumer);

keycloakContainer = getKeycloakContainer();

copyToContainer.forEach(keycloakContainer::withCopyFileToContainer);

keycloakContainer
.withLogConsumer(backupConsumer)
.withCommand(arguments.toArray(new String[0]))
Expand Down Expand Up @@ -158,6 +194,19 @@ public void setRequestPort(int port) {
}
}

public void copyProvider(String groupId, String artifactId) {
Path providerPath = Maven.resolveArtifact(groupId, artifactId);
if (!Files.isRegularFile(providerPath)) {
throw new RuntimeException("Failed to copy JAR file to 'providers' directory; " + providerPath + " is not a file");
}

copyToContainer.put(MountableFile.forHostPath(providerPath), "/opt/keycloak/providers/" + providerPath.getFileName());
}

public void copyConfigFile(Path configFilePath) {
copyToContainer.put(MountableFile.forHostPath(configFilePath), "/opt/keycloak/conf/" + configFilePath.getFileName());
}

// After the web server is responding we are still producing some logs that got checked in the tests
private void waitForStableOutput() {
int retry = 10;
Expand All @@ -174,7 +223,7 @@ private void waitForStableOutput() {
String newLastLine = splitted[splitted.length - 1];

retry -= 1;
stableOutput = lastLine.equals(newLastLine) | (retry <= 0);
stableOutput = lastLine.equals(newLastLine) || (retry <= 0);
lastLine = newLastLine;
} else {
stableOutput = true;
Expand Down Expand Up @@ -224,7 +273,7 @@ public void run() {
};
parallelReaperExecutor.execute(reaper);
} catch (Exception cause) {
throw new RuntimeException("Failed to schecdule the removal of the container", cause);
throw new RuntimeException("Failed to schedule the removal of the container", cause);
}
}
}
Expand Down Expand Up @@ -292,4 +341,12 @@ public void clearEnv() {
this.envVars.clear();
}

public int getMappedPort(int port) {
if (keycloakContainer == null || !keycloakContainer.isRunning()) {
throw new IllegalStateException("KeycloakContainer is not running.");
}

return keycloakContainer.getMappedPort(port);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to add a null check? I can imagine a scenario, where KeycloakContainer could be null and subsequently getMappedPort could be called.

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package org.keycloak.it.utils;

import java.net.URISyntaxException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
Expand Down Expand Up @@ -46,11 +47,7 @@ public final class Maven {

public static Path resolveArtifact(String groupId, String artifactId) {
try {
Path classPathDir = Paths.get(Thread.currentThread().getContextClassLoader().getResource(".").toURI());
Path projectDir = BuildToolHelper.getProjectDir(classPathDir);
BootstrapMavenContext ctx = new BootstrapMavenContext(
BootstrapMavenContext.config().setPreferPomsFromWorkspace(true).setWorkspaceModuleParentHierarchy(true)
.setCurrentProject(projectDir.toString()));
BootstrapMavenContext ctx = bootstrapCurrentMavenContext();
LocalProject project = ctx.getCurrentProject();
RepositorySystem repositorySystem = ctx.getRepositorySystem();
List<RemoteRepository> remoteRepositories = ctx.getRemoteRepositories();
Expand Down Expand Up @@ -128,4 +125,30 @@ public boolean accept(DependencyNode node, List<DependencyNode> parents) {

return artifactResults.get(0).getArtifact();
}

public static Path getKeycloakQuarkusModulePath() {
// Find keycloak-parent module first
BootstrapMavenContext ctx = null;
try {
ctx = bootstrapCurrentMavenContext();
} catch (BootstrapMavenException | URISyntaxException e) {
throw new RuntimeException("Failed bootstrap maven context", e);
}
for (LocalProject m = ctx.getCurrentProject(); m != null; m = m.getLocalParent()) {
if ("keycloak-parent".equals(m.getArtifactId())) {
// When found, advance to quarkus module
return m.getDir().resolve("quarkus");
}
}

throw new RuntimeException("Failed to find keycloak-parent module.");
}

private static BootstrapMavenContext bootstrapCurrentMavenContext() throws BootstrapMavenException, URISyntaxException {
Path classPathDir = Paths.get(Thread.currentThread().getContextClassLoader().getResource(".").toURI());
Path projectDir = BuildToolHelper.getProjectDir(classPathDir);
return new BootstrapMavenContext(
BootstrapMavenContext.config().setPreferPomsFromWorkspace(true).setWorkspaceModuleParentHierarchy(true)
.setCurrentProject(projectDir.toString()));
}
}
6 changes: 6 additions & 0 deletions test-framework/bom/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
22 changes: 22 additions & 0 deletions test-framework/clustering/pom.xml
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading
点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载