diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4831d74deb..059c934265a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/quarkus/tests/junit5/src/main/java/org/keycloak/it/utils/DockerKeycloakDistribution.java b/quarkus/tests/junit5/src/main/java/org/keycloak/it/utils/DockerKeycloakDistribution.java index 72114a3d2e1..1c41c1ac2a0 100644 --- a/quarkus/tests/junit5/src/main/java/org/keycloak/it/utils/DockerKeycloakDistribution.java +++ b/quarkus/tests/junit5/src/main/java/org/keycloak/it/utils/DockerKeycloakDistribution.java @@ -2,6 +2,7 @@ package org.keycloak.it.utils; 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; @@ -16,8 +17,10 @@ import org.testcontainers.images.RemoteDockerImage; 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; @@ -33,9 +36,16 @@ public final class DockerKeycloakDistribution implements KeycloakDistribution { final ToStringConsumer stdOut = new ToStringConsumer(); final ToStringConsumer stdErr = new ToStringConsumer(); + final Consumer customLogConsumer; + public BackupConsumer(Consumer 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) { @@ -55,20 +65,27 @@ public final class DockerKeycloakDistribution implements KeycloakDistribution { 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 customLogConsumer; private GenericContainer keycloakContainer = null; private String containerId = null; private final Executor parallelReaperExecutor = Executors.newSingleThreadExecutor(); private final Map envVars = new HashMap<>(); + private final LazyFuture image; + + private final Map 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 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 @@ -76,31 +93,11 @@ public final class DockerKeycloakDistribution implements KeycloakDistribution { this.envVars.put(name, value); } + public void setCustomLogConsumer(Consumer customLogConsumer) { + this.customLogConsumer = customLogConsumer; + } + private GenericContainer getKeycloakContainer() { - File distributionFile = new File("../../dist/" + File.separator + "target" + File.separator + "keycloak-" + Version.VERSION + ".tar.gz"); - - 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"); - } - - File dockerFile = new File("../../container/Dockerfile"); - LazyFuture image; - - if (dockerFile.exists()) { - image = new ImageFromDockerfile("keycloak-under-test", false) - .withFileFromFile("keycloak.tar.gz", distributionFile) - .withFileFromFile("ubi-null.sh", dockerScriptFile) - .withFileFromFile("Dockerfile", dockerFile) - .withBuildArg("KEYCLOAK_DIST", "keycloak.tar.gz"); - toString(); - } else { - image = new RemoteDockerImage(DockerImageName.parse("quay.io/keycloak/keycloak")); - } - return new GenericContainer<>(image) .withEnv(envVars) .withExposedPorts(exposedPorts) @@ -109,6 +106,43 @@ public final class DockerKeycloakDistribution implements KeycloakDistribution { .waitingFor(Wait.forListeningPorts(8080)); } + public static LazyFuture 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()); + + var dockerFile = quarkusModule.resolve(Path.of("container", "Dockerfile")) + .toFile(); + var ubiNullScript = quarkusModule.resolve(Path.of("container", "ubi-null.sh")) + .toFile(); + + if (dockerFile.exists()) { + return new ImageFromDockerfile("keycloak-under-test", false) + .withFileFromFile("keycloak.tar.gz", distributionFile) + .withFileFromFile("ubi-null.sh", ubiNullScript) + .withFileFromFile("Dockerfile", dockerFile) + .withBuildArg("KEYCLOAK_DIST", "keycloak.tar.gz"); + } else { + if (failIfDockerFileMissing) { + throw new RuntimeException("Docker file %s not found".formatted(dockerFile.getAbsolutePath())); + } + return new RemoteDockerImage(DockerImageName.parse("quay.io/keycloak/keycloak")); + } + } + @Override public CLIResult run(List arguments) { stop(); @@ -117,10 +151,12 @@ public final class DockerKeycloakDistribution implements KeycloakDistribution { 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])) @@ -158,6 +194,19 @@ public final class DockerKeycloakDistribution implements KeycloakDistribution { } } + 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; @@ -174,7 +223,7 @@ public final class DockerKeycloakDistribution implements KeycloakDistribution { 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; @@ -224,7 +273,7 @@ public final class DockerKeycloakDistribution implements KeycloakDistribution { }; 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); } } } @@ -292,4 +341,12 @@ public final class DockerKeycloakDistribution implements KeycloakDistribution { this.envVars.clear(); } + public int getMappedPort(int port) { + if (keycloakContainer == null || !keycloakContainer.isRunning()) { + throw new IllegalStateException("KeycloakContainer is not running."); + } + + return keycloakContainer.getMappedPort(port); + } + } diff --git a/quarkus/tests/junit5/src/main/java/org/keycloak/it/utils/Maven.java b/quarkus/tests/junit5/src/main/java/org/keycloak/it/utils/Maven.java index d166915949f..a5743b75b82 100644 --- a/quarkus/tests/junit5/src/main/java/org/keycloak/it/utils/Maven.java +++ b/quarkus/tests/junit5/src/main/java/org/keycloak/it/utils/Maven.java @@ -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; @@ -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 remoteRepositories = ctx.getRemoteRepositories(); @@ -128,4 +125,30 @@ public final class Maven { 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())); + } } diff --git a/test-framework/bom/pom.xml b/test-framework/bom/pom.xml index 1f1f2d6b664..e26adb3b838 100755 --- a/test-framework/bom/pom.xml +++ b/test-framework/bom/pom.xml @@ -105,6 +105,12 @@ ${project.version} test + + org.keycloak.testframework + keycloak-test-framework-clustering + ${project.version} + test + diff --git a/test-framework/clustering/pom.xml b/test-framework/clustering/pom.xml new file mode 100644 index 00000000000..5b1791d6fd4 --- /dev/null +++ b/test-framework/clustering/pom.xml @@ -0,0 +1,22 @@ + + + + org.keycloak.testframework + keycloak-test-framework-parent + 999.0.0-SNAPSHOT + ../pom.xml + + 4.0.0 + + keycloak-test-framework-clustering + + + + org.keycloak.testframework + keycloak-test-framework-core + ${project.version} + + + \ No newline at end of file diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/ClusteringTestFrameworkExtension.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/ClusteringTestFrameworkExtension.java new file mode 100644 index 00000000000..9e6e2deb856 --- /dev/null +++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/ClusteringTestFrameworkExtension.java @@ -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> suppliers() { + return List.of(new ClusteredKeycloakServerSupplier(), new LoadBalancerSupplier()); + } +} diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/annotations/InjectLoadBalancer.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/annotations/InjectLoadBalancer.java new file mode 100644 index 00000000000..cdcce930843 --- /dev/null +++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/annotations/InjectLoadBalancer.java @@ -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 { +} diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancer.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancer.java new file mode 100644 index 00000000000..1b56bcdec13 --- /dev/null +++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancer.java @@ -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 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(); + } +} diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancerSupplier.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancerSupplier.java new file mode 100644 index 00000000000..de6344c4b0d --- /dev/null +++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancerSupplier.java @@ -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 { + + @Override + public LoadBalancer getValue(InstanceContext 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 a, RequestedInstance b) { + return true; + } +} diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServer.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServer.java new file mode 100644 index 00000000000..c3a7f136cc6 --- /dev/null +++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServer.java @@ -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 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 snapshotImage = null; + for (int i = 0; i < containers.length; ++i) { + LazyFuture 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 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; + } +} diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServerSupplier.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServerSupplier.java new file mode 100644 index 00000000000..b7fbc148237 --- /dev/null +++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServerSupplier.java @@ -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"; + } + + +} diff --git a/test-framework/clustering/src/main/resources/META-INF/services/org.keycloak.testframework.TestFrameworkExtension b/test-framework/clustering/src/main/resources/META-INF/services/org.keycloak.testframework.TestFrameworkExtension new file mode 100644 index 00000000000..f1a49709a05 --- /dev/null +++ b/test-framework/clustering/src/main/resources/META-INF/services/org.keycloak.testframework.TestFrameworkExtension @@ -0,0 +1 @@ +org.keycloak.testframework.ClusteringTestFrameworkExtension \ No newline at end of file diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/database/AbstractContainerTestDatabase.java b/test-framework/core/src/main/java/org/keycloak/testframework/database/AbstractContainerTestDatabase.java index 09e1fa7f622..37024022e6f 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/database/AbstractContainerTestDatabase.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/database/AbstractContainerTestDatabase.java @@ -53,9 +53,13 @@ public abstract class AbstractContainerTestDatabase implements TestDatabase { @Override public Map serverConfig() { + return serverConfig(false); + } + + public Map 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(); diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/database/AbstractDatabaseSupplier.java b/test-framework/core/src/main/java/org/keycloak/testframework/database/AbstractDatabaseSupplier.java index 11b1eb283ed..8e46df194b1 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/database/AbstractDatabaseSupplier.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/database/AbstractDatabaseSupplier.java @@ -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 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 diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/server/AbstractKeycloakServerSupplier.java b/test-framework/core/src/main/java/org/keycloak/testframework/server/AbstractKeycloakServerSupplier.java index f1137b5efc7..54770be428f 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/server/AbstractKeycloakServerSupplier.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/server/AbstractKeycloakServerSupplier.java @@ -21,7 +21,7 @@ public abstract class AbstractKeycloakServerSupplier implements Supplierremote remote-providers ui + clustering diff --git a/tests/clustering/README.md b/tests/clustering/README.md new file mode 100644 index 00000000000..ffcb2275b5a --- /dev/null +++ b/tests/clustering/README.md @@ -0,0 +1,23 @@ +# Running clustering tests + +## Mixed cluster + +KC_TEST_SERVER_IMAGES -> if empty, uses the built distribution archive from quarkus/dist directory in all containers +-> if single value, uses that value in all the containers +-> if comma separated value ("imageA,imageB"), each container will use the image specified from the list. The number of items must match the cluster size. +-> "-" special keyword to use the built distribution archive +KC_TEST_SERVER=cluster -> enables cluster mode (configured by default in clustering module) +KC_TEST_DATABASE_INTERNAL=true -> configure keycloak with the internal database container IP instead of localhost (configured by default in clustering module) + +Example, 2 node cluster, the first using the distribution archive and the second the nightly image +KC_TEST_DATABASE=postgres KC_TEST_SERVER_IMAGES=-,quay.io/keycloak/keycloak:nightly mvn verify -pl tests/clustering/ -Dtest=MixedVersionClusterTest + +Using a mixed cluster with 26.2.3 and 26.2.4 +KC_TEST_DATABASE=postgres KC_TEST_SERVER_IMAGES=quay.io/keycloak/keycloak:26.2.3,quay.io/keycloak/keycloak:26.2.4 mvn verify -pl tests/clustering/ -Dtest=MixedVersionClusterTest + +The test has some println to check the state. Example: + +``` +url0->http://localhost:32889 +url1->http://localhost:32891 +``` \ No newline at end of file diff --git a/tests/clustering/pom.xml b/tests/clustering/pom.xml new file mode 100644 index 00000000000..70e97805ee2 --- /dev/null +++ b/tests/clustering/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + org.keycloak.tests + keycloak-tests-parent + 999.0.0-SNAPSHOT + ../pom.xml + + + keycloak-tests-clustering + + + + + org.keycloak.testframework + keycloak-test-framework-bom + ${project.version} + import + pom + + + + + + + org.keycloak.testframework + keycloak-test-framework-core + + + org.keycloak.testframework + keycloak-test-framework-junit5-config + + + org.keycloak.testframework + keycloak-test-framework-clustering + + + org.keycloak.testframework + keycloak-test-framework-db-postgres + + + \ No newline at end of file diff --git a/tests/clustering/src/test/java/org/keycloak/tests/compatibility/MixedVersionClusterTest.java b/tests/clustering/src/test/java/org/keycloak/tests/compatibility/MixedVersionClusterTest.java new file mode 100644 index 00000000000..1b18cbd8357 --- /dev/null +++ b/tests/clustering/src/test/java/org/keycloak/tests/compatibility/MixedVersionClusterTest.java @@ -0,0 +1,42 @@ +/* + * 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.tests.compatibility; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.keycloak.testframework.annotations.InjectLoadBalancer; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.clustering.LoadBalancer; + +@KeycloakIntegrationTest +public class MixedVersionClusterTest { + + @InjectLoadBalancer + LoadBalancer loadBalancer; + + @Test + public void testUrls() throws InterruptedException { + // TODO annotation based to skip if running in non-clustered mode. + Assumptions.assumeTrue(loadBalancer.clusterSize() == 2); + System.out.println("url0->" + loadBalancer.node(0).getBaseUrl()); + System.out.println("url1->" + loadBalancer.node(1).getBaseUrl()); + Thread.sleep(TimeUnit.MINUTES.toMillis(1)); + } +} diff --git a/tests/clustering/src/test/resources/keycloak-test.properties b/tests/clustering/src/test/resources/keycloak-test.properties new file mode 100644 index 00000000000..f7b5160aaf8 --- /dev/null +++ b/tests/clustering/src/test/resources/keycloak-test.properties @@ -0,0 +1,12 @@ +kc.test.server=cluster + +kc.test.log.level=WARN + +kc.test.log.filter=true + +kc.test.log.category."org.keycloak.tests".level=INFO + +kc.test.log.category."testinfo".level=INFO +kc.test.log.category."org.keycloak".level=WARN +kc.test.log.category."managed.keycloak".level=WARN +kc.test.log.category."managed.db".level=WARN \ No newline at end of file diff --git a/tests/pom.xml b/tests/pom.xml index 71b30bc5afd..f25559d8b51 100755 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -37,6 +37,7 @@ utils utils-shared custom-providers + clustering