Avoid breaking DB changes during patch releases

Closes #38888

Signed-off-by: Ryan Emerson <remerson@ibm.com>
Signed-off-by: Alexander Schwartz <alexander.schwartz@ibm.com>
Signed-off-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com>
Co-authored-by: Pedro Ruivo <pruivo@users.noreply.github.com>
Co-authored-by: Alexander Schwartz <alexander.schwartz@ibm.com>
This commit is contained in:
Ryan Emerson 2026-02-03 14:26:01 +00:00 committed by GitHub
parent 047230a052
commit 2c6f56acdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1235 additions and 20 deletions

View file

@ -1,7 +1,7 @@
#!/bin/bash -e #!/bin/bash -e
find . -path '**/src/test/java' -type d \ find . -path '**/src/test/java' -type d \
| grep -v -E '\./(docs|distribution|misc|operator|((.+/)?tests)|testsuite|test-framework|quarkus)/' \ | grep -v -E '\./(docs|distribution|operator|((.+/)?tests)|testsuite|test-framework|quarkus)/' \
| sed 's|/src/test/java||' \ | sed 's|/src/test/java||' \
| sed 's|./||' \ | sed 's|./||' \
| sort \ | sort \

View file

@ -0,0 +1,87 @@
# Database Compatibility Verifier Maven Plugin
## Overview
This Maven plugin is used to verify the database compatibility of Keycloak. It ensures that all database schema changes
(ChangeSets) are explicitly marked as either supported or unsupported by the rolling upgrades feature.
## Goals
The plugin provides the following goals:
* `db-compatibility-verifier:snapshot`
* `db-compatibility-verifier:verify`
* `db-compatibility-verifier:supported`
* `db-compatibility-verifier:unsupported`
## Usage
### `snapshot` - Creates a snapshot of the current database ChangeSets.
This goal is used to create an initial snapshot of the database ChangeSets. It creates a supported and unsupported JSON
file, specified via the `db.verify.supportedFile` and `db.verify.unsupportedFile` property, respectively.
```bash
mvn org.keycloak:db-compatibility-verifier-maven-plugin:999.0.0-SNAPSHOT:snapshot \
-Ddb.verify.supportedFile=<relative-path-to-create-json-file> \
-Ddb.verify.unsupportedFile=<relative-path-to-create-json-file>
```
The `supportedFile` will be created with a record of all known ChangeSets and the `unsupportedFile` will be initialized
as an empty JSON array.
### `verify` - Verifies that all detected ChangeSets recorded in either the supported or unsupported JSON files.
```bash
mvn org.keycloak:db-compatibility-verifier-maven-plugin:999.0.0-SNAPSHOT:verify \
-Ddb.verify.supportedFile=<relative-path-to-json-file> \
-Ddb.verify.unsupportedFile=<relative-path-to-json-file>
```
### `supported` - Adds one or all missing ChangeSets to the supported JSON file
This goal is used to mark a ChangeSet as supported for rolling upgrades.
To mark a single ChangeSet as supported:
```bash
mvn org.keycloak:db-compatibility-verifier-maven-plugin:999.0.0-SNAPSHOT:supported \
-Ddb.verify.supportedFile=<relative-path-to-json-file> \
-Ddb.verify.unsupportedFile=<relative-path-to-json-file> \
-Ddb.verify.changset.id=<id> \
-Ddb.verify.changset.author=<author> \
-Ddb.verify.changset.filename=<filename>
```
To mark all missing ChangeSets as supported:
```bash
mvn org.keycloak:db-compatibility-verifier-maven-plugin:999.0.0-SNAPSHOT:supported \
-Ddb.verify.supportedFile=<relative-path-to-json-file> \
-Ddb.verify.unsupportedFile=<relative-path-to-json-file> \
-Ddb.verify.changset.addAll=true
```
### `unsupported` - Adds one or all missing ChangeSets to the unsupported JSON file
This goal is used to mark a ChangeSet as unsupported for rolling upgrades.
To mark a single ChangeSet as unsupported:
```bash
mvn org.keycloak:db-compatibility-verifier-maven-plugin:999.0.0-SNAPSHOT:unsupported \
-Ddb.verify.supportedFile=<relative-path-to-json-file> \
-Ddb.verify.unsupportedFile=<relative-path-to-json-file> \
-Ddb.verify.changset.id=<id> \
-Ddb.verify.changset.author=<author> \
-Ddb.verify.changset.filename=<filename>
```
To mark all missing ChangeSets as unsupported:
```bash
mvn org.keycloak:db-compatibility-verifier-maven-plugin:999.0.0-SNAPSHOT:unsupported \
-Ddb.verify.supportedFile=<relative-path-to-json-file> \
-Ddb.verify.unsupportedFile=<relative-path-to-json-file> \
-Ddb.verify.changset.addAll=true
```

View file

@ -0,0 +1,76 @@
<?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">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-parent</artifactId>
<version>999.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>db-compatibility-verifier-maven-plugin</artifactId>
<name>Database Compatibilility Verifier</name>
<description>Database Compatibility Verifier</description>
<packaging>maven-plugin</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-plugin-api</artifactId>
<version>${maven.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.maven.plugin-tools</groupId>
<artifactId>maven-plugin-annotations</artifactId>
<version>${maven.plugin-tools.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-core</artifactId>
<version>${maven.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-deploy-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
</plugins>
</build>
</project>

View file

@ -0,0 +1,83 @@
package org.keycloak.db.compatibility.verifier;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Parameter;
abstract class AbstractChangeSetMojo extends AbstractMojo {
@Parameter(property = "db.verify.changeset.all", defaultValue = "false")
boolean addAll;
@Parameter(property = "db.verify.changeset.id")
String id;
@Parameter(property = "db.verify.changeset.author")
String author;
@Parameter(property = "db.verify.changeset.filename")
String filename;
void checkFileExist(String ref, File file) throws MojoExecutionException {
if (!file.exists()) {
throw new MojoExecutionException("%s file does not exist".formatted(ref));
}
}
void checkUnknownChangeSet(Set<ChangeSet> knownChangeSets, ChangeSet changeSet) throws MojoExecutionException {
if (!knownChangeSets.contains(changeSet)) {
throw new MojoExecutionException("Unknown ChangeSet: " + changeSet);
}
}
protected void checkValidChangeSetId(String id, String author, String filename) throws MojoExecutionException {
if (id == null || id.isBlank()) {
throw new MojoExecutionException("ChangeSet id not set");
}
if (author == null || author.isBlank()) {
throw new MojoExecutionException("ChangeSet author not set");
}
if (filename == null || filename.isBlank()) {
throw new MojoExecutionException("ChangeSet filename not set");
}
}
void addAll(ClassLoader classLoader, File dest, File exclusions) throws IOException {
// Discover all known ChangeSets
ChangeLogXMLParser xmlParser = new ChangeLogXMLParser(classLoader);
Set<ChangeSet> knownChangeSets = xmlParser.discoverAllChangeSets();
// Load changes to exclude and remove them from the known changesets
Set<ChangeSet> excludedChanges = objectMapper.readValue(exclusions, new TypeReference<>() {});
knownChangeSets.removeAll(excludedChanges);
// Overwrite all content in the destination file
objectMapper.writeValue(dest, knownChangeSets);
}
void addIndividual(ClassLoader classLoader, ChangeSet changeSet, File dest, File alternate) throws IOException, MojoExecutionException {
// Discover all known ChangeSets
ChangeLogXMLParser xmlParser = new ChangeLogXMLParser(classLoader);
Set<ChangeSet> knownChangeSets = xmlParser.discoverAllChangeSets();
// It should not be possible to add an unknown changeset
checkUnknownChangeSet(knownChangeSets, changeSet);
Set<ChangeSet> alternateChangeSets = objectMapper.readValue(alternate, new TypeReference<>() {});
if (alternateChangeSets.contains(changeSet)) {
throw new MojoExecutionException("ChangeSet already defined in the %s file".formatted(alternate.getName()));
}
List<ChangeSet> destChanges = objectMapper.readValue(dest, new TypeReference<>() {});
if (!destChanges.contains(changeSet)) {
// If the ChangeSet is not already known, append to the end of the JSON array and overwrite the existing file
destChanges.add(changeSet);
objectMapper.writeValue(dest, destChanges);
}
}
}

View file

@ -0,0 +1,39 @@
package org.keycloak.db.compatibility.verifier;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.List;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.apache.maven.artifact.DependencyResolutionRequiredException;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
abstract class AbstractMojo extends org.apache.maven.plugin.AbstractMojo {
final ObjectMapper objectMapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
@Parameter(defaultValue = "${project}", readonly = true)
protected MavenProject project;
@Parameter(property = "db.verify.supportedFile", required = true)
protected String supportedFile;
@Parameter(property = "db.verify.unsupportedFile", required = true)
protected String unsupportedFile;
@Parameter(property = "db.verify.skip", defaultValue = "false")
protected boolean skip;
ClassLoader classLoader() throws DependencyResolutionRequiredException, MalformedURLException {
List<String> elements = project.getRuntimeClasspathElements();
URL[] urls = new URL[elements.size()];
for (int i = 0; i < elements.size(); i++) {
urls[i] = new File(elements.get(i)).toURI().toURL();
}
return new URLClassLoader(urls, null);
}
}

View file

@ -0,0 +1,135 @@
package org.keycloak.db.compatibility.verifier;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
record ChangeLogXMLParser(ClassLoader classLoader) {
static final String RESOURCE_DIR = "META-INF";
Set<ChangeSet> discoverAllChangeSets() throws IOException {
var changeSets = changeSetXmlFiles()
.map(this::extractChangeSets)
.flatMap(List::stream)
.toList();
Set<ChangeSet> uniqueSets = new HashSet<>(changeSets.size());
ChangeSet duplicate = changeSets.stream()
.filter(item -> !uniqueSets.add(item))
.findFirst()
.orElse(null);
if (duplicate != null) {
throw new IllegalStateException("Duplicate ChangeSet detected: " + duplicate);
}
return uniqueSets;
}
List<ChangeSet> extractChangeSets(String filename) {
XMLInputFactory factory = XMLInputFactory.newInstance();
// Security: Disable DTDs to prevent XML External Entity (XXE) attacks
factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
List<ChangeSet> ids = new ArrayList<>();
try (InputStream is = classLoader.getResourceAsStream(filename)) {
XMLStreamReader reader = factory.createXMLStreamReader(is);
while (reader.hasNext()) {
int event = reader.next();
if (event == XMLStreamConstants.START_ELEMENT) {
String tagName = reader.getLocalName();
// 1. Handle Root Element
if (tagName.equals("databaseChangeLog")) {
continue;
}
// 2. Process changeSet
if (tagName.equals("changeSet")) {
String id = reader.getAttributeValue(null, "id");
String author = reader.getAttributeValue(null, "author");
ids.add(new ChangeSet(id, author, filename));
// Skip all child elements until we find the closing </changeSet>
skipUnknownElement(reader);
continue;
}
// 3. Skip all other elements
skipUnknownElement(reader);
}
}
return ids;
} catch (IOException | XMLStreamException e) {
throw new IllegalStateException(e);
}
}
// Detect all jpa-changelog*.xml files on the classpath
private Stream<String> changeSetXmlFiles() throws IOException {
List<String> fileNames = new ArrayList<>();
Enumeration<URL> en = classLoader.getResources(RESOURCE_DIR);
while (en.hasMoreElements()) {
URI uri;
try {
uri = en.nextElement().toURI();
} catch (URISyntaxException e) {
// Should never happen
throw new IllegalStateException(e);
}
if (uri.getScheme().equals("jar")) {
// Handle JAR resources
try (FileSystem fs = FileSystems.newFileSystem(uri, Collections.emptyMap())) {
Path path = fs.getPath(RESOURCE_DIR);
fileNames.addAll(listFromPath(path));
}
} else {
// Handle local file system (IDE)
fileNames.addAll(listFromPath(Paths.get(uri)));
}
}
return fileNames.stream()
.filter(s -> s.startsWith("jpa-changelog") && s.endsWith(".xml"))
.map(s -> "%s/%s".formatted(RESOURCE_DIR, s));
}
private List<String> listFromPath(Path path) throws IOException {
try (Stream<Path> walk = Files.walk(path, 1)) {
return walk.filter(Files::isRegularFile)
.map(p -> p.getFileName().toString())
.toList();
}
}
private static void skipUnknownElement(XMLStreamReader reader) throws XMLStreamException {
int level = 1;
while (level > 0 && reader.hasNext()) {
int event = reader.next();
if (event == XMLStreamConstants.START_ELEMENT) {
level++;
} else if (event == XMLStreamConstants.END_ELEMENT) {
level--;
}
}
}
}

View file

@ -0,0 +1,4 @@
package org.keycloak.db.compatibility.verifier;
record ChangeSet(String id, String author, String filename) {
}

View file

@ -0,0 +1,37 @@
package org.keycloak.db.compatibility.verifier;
import java.io.File;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Mojo;
@Mojo(name = "supported")
public class ChangeSetSupportedMojo extends AbstractChangeSetMojo {
@Override
public void execute() throws MojoExecutionException {
if (skip) {
getLog().info("Skipping execution");
return;
}
File root = project.getBasedir();
File sFile = new File(root, supportedFile);
File uFile = new File(root, unsupportedFile);
checkFileExist("supported", sFile);
checkFileExist("unsupported", uFile);
try {
if (addAll) {
addAll(classLoader(), sFile, uFile);
} else {
checkValidChangeSetId(id, author, filename);
ChangeSet changeSet = new ChangeSet(id, author, filename);
addIndividual(classLoader(), changeSet, sFile, uFile);
}
} catch (Exception e) {
throw new MojoExecutionException("Error adding ChangeSet to supported file", e);
}
}
}

View file

@ -0,0 +1,36 @@
package org.keycloak.db.compatibility.verifier;
import java.io.File;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Mojo;
@Mojo(name = "unsupported")
public class ChangeSetUnsupportedMojo extends AbstractChangeSetMojo {
@Override
public void execute() throws MojoExecutionException {
if (skip) {
getLog().info("Skipping execution");
return;
}
File root = project.getBasedir();
File sFile = new File(root, supportedFile);
File uFile = new File(root, unsupportedFile);
checkFileExist("supported", sFile);
checkFileExist("unsupported", uFile);
try {
if (addAll) {
addAll(classLoader(), uFile, sFile);
} else {
checkValidChangeSetId(id, author, filename);
ChangeSet changeSet = new ChangeSet(id, author, filename);
addIndividual(classLoader(), changeSet, uFile, sFile);
}
} catch (Exception e) {
throw new MojoExecutionException("Error adding ChangeSet to unsupported file", e);
}
}
}

View file

@ -0,0 +1,43 @@
package org.keycloak.db.compatibility.verifier;
import java.io.File;
import java.io.IOException;
import java.util.Set;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Mojo;
@Mojo(name = "snapshot")
public class CreateSnapshotMojo extends AbstractMojo {
@Override
public void execute() throws MojoExecutionException {
if (skip) {
getLog().info("Skipping execution");
return;
}
try {
File root = project.getBasedir();
File sFile = new File(root, supportedFile);
File uFile = new File(root, unsupportedFile);
ClassLoader classLoader = classLoader();
createSnapshot(classLoader, sFile, uFile);
} catch (Exception e) {
throw new MojoExecutionException("Error creating ChangeSet snapshot", e);
}
}
void createSnapshot(ClassLoader classLoader, File sFile, File uFile) throws IOException {
// Write all known ChangeSet defined in the jpa-changelog*.xml files to the supported file
ChangeLogXMLParser xmlParser = new ChangeLogXMLParser(classLoader);
Set<ChangeSet> changeSets = xmlParser.discoverAllChangeSets();
objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
objectMapper.writeValue(sFile, changeSets);
// Create an empty JSON array in the unsupported file
objectMapper.writeValue(uFile, Set.of());
}
}

View file

@ -0,0 +1,90 @@
package org.keycloak.db.compatibility.verifier;
import java.io.File;
import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Mojo;
@Mojo(name = "verify")
public class VerifyCompatibilityMojo extends AbstractMojo {
@Override
public void execute() throws MojoExecutionException {
if (skip) {
getLog().info("Skipping execution");
return;
}
try {
File root = project.getBasedir();
File sFile = new File(root, supportedFile);
File uFile = new File(root, unsupportedFile);
verifyCompatibility(classLoader(), sFile, uFile);
} catch (Exception e) {
throw new MojoExecutionException("Error loading project resources", e);
}
}
void verifyCompatibility(ClassLoader classLoader, File sFile, File uFile) throws IOException, MojoExecutionException {
if (!sFile.exists() && !uFile.exists()) {
getLog().info("No JSON ChangeSet files exist to verify");
return;
}
// Parse JSON files to determine all committed ChangeSets
List<ChangeSet> sChanges = objectMapper.readValue(sFile, new TypeReference<>() {});
List<ChangeSet> uChanges = objectMapper.readValue(uFile, new TypeReference<>() {});
Set<ChangeSet> recordedChanges = Stream.of(sChanges, uChanges)
.flatMap(List::stream)
.collect(Collectors.toSet());
if (recordedChanges.isEmpty()) {
getLog().info("No supported or unsupported ChangeSet exist in specified files");
return;
}
checkIntersection(sChanges, uChanges);
// Parse all ChangeSets currently defined in the jpa-changegetLog() files
ChangeLogXMLParser xmlParser = new ChangeLogXMLParser(classLoader);
Set<ChangeSet> currentChanges = xmlParser.discoverAllChangeSets();
checkMissingChangeSet(currentChanges, recordedChanges, sFile, uFile);
}
void checkIntersection(List<ChangeSet> sChanges, List<ChangeSet> uChanges) throws MojoExecutionException {
Set<ChangeSet> intersection = new HashSet<>(sChanges);
intersection.retainAll(uChanges);
if (!intersection.isEmpty()) {
getLog().error("The following ChangeSets should be defined in either the supported or unsupported file, they cannot appear in both:");
intersection.forEach(change -> getLog().error("\t\t" + change.toString()));
getLog().error("The offending ChangeSets should be removed from one of the files");
throw new MojoExecutionException("One or more ChangeSet definitions exist in both the supported and unsupported file");
}
}
void checkMissingChangeSet(Set<ChangeSet> currentChanges, Set<ChangeSet> recordedChanges, File sFile, File uFile) throws MojoExecutionException {
if (recordedChanges.equals(currentChanges)) {
getLog().info("All ChangeSets in the module recorded as expected in the supported and unsupported files");
} else {
getLog().error("The recorded ChangeSet JSON files differ from the current repository state");
getLog().error("The following ChangeSets should be defined in either the supported '%s' or unsupported '%s' file:".formatted(sFile.toString(), uFile.toString()));
currentChanges.removeAll(recordedChanges);
currentChanges.forEach(change -> getLog().error("\t\t" + change.toString()));
getLog().error("You must determine whether the ChangeSet(s) is compatible with rolling upgrades or not");
getLog().error("A ChangeSet that requires locking preventing other cluster members accessing the database or makes schema changes that breaks functionality in earlier Keycloak versions is NOT compatible with rolling upgrades");
getLog().error("Rolling upgrade compatibility must be verified against all supported database vendors before the supported file is updated");
getLog().error("If the change IS compatible, then it should be committed to the repository in the supported file: '%s'".formatted(sFile.toString()));
getLog().error("If the change IS NOT compatible, then it should be committed to the repository in the unsupported file: '%s'".formatted(sFile.toString()));
getLog().error("Adding a ChangeSet to the unsupported file ensures that a rolling upgrade is not attempted when upgrading to the first patch version containing the change");
getLog().error("ChangeSets can be added to the supported or unsupported files using the org.keycloak:db-compatibility-verifier-maven-plugin. See the module README for usage instructions");
throw new MojoExecutionException("One or more ChangeSet definitions are missing from the supported or unsupported files");
}
}
}

View file

@ -0,0 +1,44 @@
package org.keycloak.db.compatibility.verifier;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
abstract class AbstractMojoTest {
protected Path testDir;
protected File supportedFile;
protected File unsupportedFile;
@BeforeEach
void init() throws IOException {
testDir = Files.createTempDirectory(ChangeSetSupportedMojoTest.class.getSimpleName());
supportedFile = testDir.resolve("supported.json").toFile();
unsupportedFile = testDir.resolve("unsupported.json").toFile();
}
@AfterEach
void cleanup() throws IOException {
if (Files.exists(testDir)) {
Files.walkFileTree(testDir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
}
}
}

View file

@ -0,0 +1,125 @@
package org.keycloak.db.compatibility.verifier;
import java.util.List;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.maven.plugin.MojoExecutionException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class ChangeSetSupportedMojoTest extends AbstractMojoTest {
@Test
void testAddAll() throws Exception {
var classLoader = ChangeSetSupportedMojoTest.class.getClassLoader();
var mojo = new ChangeSetSupportedMojo();
var mapper = new ObjectMapper();
// Create unsupported file with a single ChangeSet
List<ChangeSet> unsupportedChanges = new ChangeLogXMLParser(classLoader).extractChangeSets("META-INF/jpa-changelog-2.xml");
assertEquals(1, unsupportedChanges.size());
mapper.writeValue(unsupportedFile, unsupportedChanges);
// Execute add all and expect all ChangeSets from jpa-changelog-1.xml to be present
assertTrue(supportedFile.createNewFile());
mojo.addAll(classLoader, supportedFile, unsupportedFile);
List<ChangeSet> supportedChanges = mapper.readValue(supportedFile, new TypeReference<>() {});
assertEquals(1, supportedChanges.size());
ChangeSet sChange = supportedChanges.get(0);
assertEquals("test", sChange.id());
assertEquals("keycloak", sChange.author());
assertEquals("META-INF/jpa-changelog-1.xml", sChange.filename());
}
@Test
void testAddIndividual() throws Exception {
var classLoader = ChangeSetSupportedMojoTest.class.getClassLoader();
var changeLogParser = new ChangeLogXMLParser(classLoader);
var mojo = new ChangeSetSupportedMojo();
var mapper = new ObjectMapper();
assertTrue(supportedFile.createNewFile());
assertTrue(unsupportedFile.createNewFile());
mapper.writeValue(supportedFile, List.of());
mapper.writeValue(unsupportedFile, List.of());
// Test ChangeSet is added to supported file as expected
ChangeSet changeSet = changeLogParser.extractChangeSets("META-INF/jpa-changelog-1.xml").get(0);
mojo.addIndividual(classLoader, changeSet, supportedFile, unsupportedFile);
List<ChangeSet> supportedChanges = mapper.readValue(supportedFile, new TypeReference<>() {});
assertEquals(1, supportedChanges.size());
ChangeSet sChange = supportedChanges.get(0);
assertEquals(changeSet.id(), sChange.id());
assertEquals(changeSet.author(), sChange.author());
assertEquals(changeSet.filename(), sChange.filename());
// Test subsequent ChangeSets are added to already populated supported file
changeSet = changeLogParser.extractChangeSets("META-INF/jpa-changelog-2.xml").get(0);
mojo.addIndividual(classLoader, changeSet, supportedFile, unsupportedFile);
supportedChanges = mapper.readValue(supportedFile, new TypeReference<>() {});
assertEquals(2, supportedChanges.size());
sChange = supportedChanges.get(1);
assertEquals(changeSet.id(), sChange.id());
assertEquals(changeSet.author(), sChange.author());
assertEquals(changeSet.filename(), sChange.filename());
// Test ChangeSet already exists handled gracefully
mojo.addIndividual(classLoader, changeSet, supportedFile, unsupportedFile);
supportedChanges = mapper.readValue(supportedFile, new TypeReference<>() {});
assertEquals(2, supportedChanges.size());
}
@Test
void testChangeAlreadyUnsupported() throws Exception {
var classLoader = ChangeSetSupportedMojoTest.class.getClassLoader();
var mojo = new ChangeSetSupportedMojo();
var mapper = new ObjectMapper();
assertTrue(supportedFile.createNewFile());
assertTrue(unsupportedFile.createNewFile());
mapper.writeValue(supportedFile, List.of());
// Create unsupported file with a single ChangeSet
List<ChangeSet> unsupportedChanges = new ChangeLogXMLParser(classLoader).extractChangeSets("META-INF/jpa-changelog-1.xml");
assertEquals(1, unsupportedChanges.size());
ChangeSet changeSet = unsupportedChanges.get(0);
mapper.writeValue(unsupportedFile, unsupportedChanges);
Exception e = assertThrows(
MojoExecutionException.class,
() -> mojo.addIndividual(classLoader, changeSet, supportedFile, unsupportedFile)
);
assertEquals("ChangeSet already defined in the %s file".formatted(unsupportedFile.getName()), e.getMessage());
}
@Test
void testAddUnknownChangeSet() throws Exception {
var classLoader = ChangeSetSupportedMojoTest.class.getClassLoader();
var mojo = new ChangeSetSupportedMojo();
var mapper = new ObjectMapper();
assertTrue(supportedFile.createNewFile());
assertTrue(unsupportedFile.createNewFile());
mapper.writeValue(supportedFile, List.of());
ChangeSet unknown = new ChangeSet("asf", "asfgasg", "afasgfas");
Exception e = assertThrows(
MojoExecutionException.class,
() -> mojo.addIndividual(classLoader, unknown, supportedFile, unsupportedFile)
);
assertEquals("Unknown ChangeSet: " + unknown, e.getMessage());
}
}

View file

@ -0,0 +1,125 @@
package org.keycloak.db.compatibility.verifier;
import java.util.List;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.maven.plugin.MojoExecutionException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class ChangeSetUnsupportedMojoTest extends AbstractMojoTest {
@Test
void testAddAll() throws Exception {
var classLoader = ChangeSetUnsupportedMojoTest.class.getClassLoader();
var mojo = new ChangeSetUnsupportedMojo();
var mapper = new ObjectMapper();
// Create supported file with a single ChangeSet
List<ChangeSet> supportedChanges = new ChangeLogXMLParser(classLoader).extractChangeSets("META-INF/jpa-changelog-2.xml");
assertEquals(1, supportedChanges.size());
mapper.writeValue(unsupportedFile, supportedChanges);
// Execute add all and expect all ChangeSets from jpa-changelog-1.xml to be present
assertTrue(supportedFile.createNewFile());
mojo.addAll(classLoader, supportedFile, unsupportedFile);
List<ChangeSet> unsupportedChanges = mapper.readValue(supportedFile, new TypeReference<>() {});
assertEquals(1, unsupportedChanges.size());
ChangeSet sChange = unsupportedChanges.get(0);
assertEquals("test", sChange.id());
assertEquals("keycloak", sChange.author());
assertEquals("META-INF/jpa-changelog-1.xml", sChange.filename());
}
@Test
void testAddIndividual() throws Exception {
var classLoader = ChangeSetUnsupportedMojoTest.class.getClassLoader();
var changeLogParser = new ChangeLogXMLParser(classLoader);
var mojo = new ChangeSetUnsupportedMojo();
var mapper = new ObjectMapper();
assertTrue(supportedFile.createNewFile());
assertTrue(unsupportedFile.createNewFile());
mapper.writeValue(supportedFile, List.of());
mapper.writeValue(unsupportedFile, List.of());
// Test ChangeSet is added to unsupported file as expected
ChangeSet changeSet = changeLogParser.extractChangeSets("META-INF/jpa-changelog-1.xml").get(0);
mojo.addIndividual(classLoader, changeSet, unsupportedFile, supportedFile);
List<ChangeSet> unsupportedChanges = mapper.readValue(unsupportedFile, new TypeReference<>() {});
assertEquals(1, unsupportedChanges.size());
ChangeSet sChange = unsupportedChanges.get(0);
assertEquals(changeSet.id(), sChange.id());
assertEquals(changeSet.author(), sChange.author());
assertEquals(changeSet.filename(), sChange.filename());
// Test subsequent ChangeSets are added to already populated supported file
changeSet = changeLogParser.extractChangeSets("META-INF/jpa-changelog-2.xml").get(0);
mojo.addIndividual(classLoader, changeSet, unsupportedFile, supportedFile);
unsupportedChanges = mapper.readValue(unsupportedFile, new TypeReference<>() {});
assertEquals(2, unsupportedChanges.size());
sChange = unsupportedChanges.get(1);
assertEquals(changeSet.id(), sChange.id());
assertEquals(changeSet.author(), sChange.author());
assertEquals(changeSet.filename(), sChange.filename());
// Test ChangeSet already exists handled gracefully
mojo.addIndividual(classLoader, changeSet, unsupportedFile, supportedFile);
unsupportedChanges = mapper.readValue(unsupportedFile, new TypeReference<>() {});
assertEquals(2, unsupportedChanges.size());
}
@Test
void testChangeAlreadySupported() throws Exception {
var classLoader = ChangeSetUnsupportedMojoTest.class.getClassLoader();
var mojo = new ChangeSetUnsupportedMojo();
var mapper = new ObjectMapper();
assertTrue(supportedFile.createNewFile());
assertTrue(unsupportedFile.createNewFile());
mapper.writeValue(unsupportedFile, List.of());
// Create supported file with a single ChangeSet
List<ChangeSet> unsupportedChanges = new ChangeLogXMLParser(classLoader).extractChangeSets("META-INF/jpa-changelog-1.xml");
assertEquals(1, unsupportedChanges.size());
ChangeSet changeSet = unsupportedChanges.get(0);
mapper.writeValue(supportedFile, unsupportedChanges);
Exception e = assertThrows(
MojoExecutionException.class,
() -> mojo.addIndividual(classLoader, changeSet, unsupportedFile, supportedFile)
);
assertEquals("ChangeSet already defined in the %s file".formatted(supportedFile.getName()), e.getMessage());
}
@Test
void testAddUnknownChangeSet() throws Exception {
var classLoader = ChangeSetSupportedMojoTest.class.getClassLoader();
var mojo = new ChangeSetSupportedMojo();
var mapper = new ObjectMapper();
assertTrue(supportedFile.createNewFile());
assertTrue(unsupportedFile.createNewFile());
mapper.writeValue(unsupportedFile, List.of());
ChangeSet unknown = new ChangeSet("asf", "asfgasg", "afasgfas");
Exception e = assertThrows(
MojoExecutionException.class,
() -> mojo.addIndividual(classLoader, unknown, unsupportedFile, supportedFile)
);
assertEquals("Unknown ChangeSet: " + unknown, e.getMessage());
}
}

View file

@ -0,0 +1,30 @@
package org.keycloak.db.compatibility.verifier;
import java.util.List;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class CreateSnapshotMojoTest extends AbstractMojoTest {
@Test
void testSnapshotFilesCreated() throws Exception {
var classLoader = CreateSnapshotMojoTest.class.getClassLoader();
var mojo = new CreateSnapshotMojo();
mojo.createSnapshot(classLoader, supportedFile, unsupportedFile);
assertTrue(supportedFile.exists());
assertTrue(unsupportedFile.exists());
var mapper = new ObjectMapper();
List<ChangeSet> supportedChanges = mapper.readValue(supportedFile, new TypeReference<>() {});
assertEquals(2, supportedChanges.size());
List<ChangeSet> unsupportedChanges = mapper.readValue(unsupportedFile, new TypeReference<>() {});
assertEquals(0, unsupportedChanges.size());
}
}

View file

@ -0,0 +1,75 @@
package org.keycloak.db.compatibility.verifier;
import java.io.File;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.maven.plugin.MojoExecutionException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class VerifyCompatibilityMojoTest {
final ClassLoader classLoader = VerifyCompatibilityMojoTest.class.getClassLoader();
@Test
void testChangeSetFilesDoNotExist() {
var mojo = new VerifyCompatibilityMojo();
File noneExistingFile = new File("noneExistingFile");
assertFalse(noneExistingFile.exists());
assertDoesNotThrow(() -> mojo.verifyCompatibility(classLoader, noneExistingFile, noneExistingFile));
}
@Test
void testEmptyChangeSetFiles() {
var mojo = new VerifyCompatibilityMojo();
File emptyJson = new File(classLoader.getResource("META-INF/empty-array.json").getFile());
assertDoesNotThrow(() -> mojo.verifyCompatibility(classLoader, emptyJson, emptyJson));
}
@Test
void testChangeSetIncludedInSupportedAndUnsupportedFiles() {
var mojo = new VerifyCompatibilityMojo();
var changeSet = new ChangeSet("1", "keycloak", "example.xml");
Exception e = assertThrows(
MojoExecutionException.class,
() -> mojo.checkIntersection(List.of(changeSet), List.of(changeSet))
);
assertEquals("One or more ChangeSet definitions exist in both the supported and unsupported file", e.getMessage());
}
@Test
void testAllChangeSetsRecorded() {
var mojo = new VerifyCompatibilityMojo();
var changeSets = Set.of(
new ChangeSet("1", "keycloak", "example.xml"),
new ChangeSet("2", "keycloak", "example.xml")
);
assertDoesNotThrow(() -> mojo.checkMissingChangeSet(changeSets, new HashSet<>(changeSets), new File(""), new File("")));
}
@Test
void testMissingChangeSet() {
var mojo = new VerifyCompatibilityMojo();
var currentChanges = new HashSet<ChangeSet>();
currentChanges.add(new ChangeSet("1", "keycloak", "example.xml"));
currentChanges.add(new ChangeSet("2", "keycloak", "example.xml"));
var recordedChanges = Set.of(currentChanges.iterator().next());
Exception e = assertThrows(
MojoExecutionException.class,
() -> mojo.checkMissingChangeSet(currentChanges, recordedChanges, new File(""), new File(""))
);
assertEquals("One or more ChangeSet definitions are missing from the supported or unsupported files", e.getMessage());
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<changeSet author="keycloak" id="test">
<createIndex tableName="BROKER_LINK" indexName="IDX_BROKER_LINK_USER_ID">
<column name="USER_ID" type="VARCHAR(255)" />
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<changeSet author="keycloak" id="test">
<createIndex tableName="BROKER_LINK" indexName="IDX_BROKER_LINK_USER_ID">
<column name="USER_ID" type="VARCHAR(255)" />
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -27,9 +27,7 @@
<relativePath>../../pom.xml</relativePath> <relativePath>../../pom.xml</relativePath>
</parent> </parent>
<groupId>org.keycloak</groupId>
<artifactId>theme-verifier-maven-plugin</artifactId> <artifactId>theme-verifier-maven-plugin</artifactId>
<version>999.0.0-SNAPSHOT</version>
<name>Keycloak Theme verifier</name> <name>Keycloak Theme verifier</name>
<description>Keycloak Theme verifier</description> <description>Keycloak Theme verifier</description>
@ -40,27 +38,25 @@
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven-plugin-tools.version>3.15.1</maven-plugin-tools.version>
<maven-plugin-api.version>3.9.9</maven-plugin-api.version>
</properties> </properties>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>org.apache.maven</groupId> <groupId>org.apache.maven</groupId>
<artifactId>maven-plugin-api</artifactId> <artifactId>maven-plugin-api</artifactId>
<version>${maven-plugin-api.version}</version> <version>${maven.version}</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.apache.maven.plugin-tools</groupId> <groupId>org.apache.maven.plugin-tools</groupId>
<artifactId>maven-plugin-annotations</artifactId> <artifactId>maven-plugin-annotations</artifactId>
<version>${maven-plugin-tools.version}</version> <version>${maven.plugin-tools.version}</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.apache.maven</groupId> <groupId>org.apache.maven</groupId>
<artifactId>maven-core</artifactId> <artifactId>maven-core</artifactId>
<version>${maven-plugin-api.version}</version> <version>${maven.version}</version>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<dependency> <dependency>

71
model/jpa/README.md Normal file
View file

@ -0,0 +1,71 @@
# Rolling updates database compatibility
In order to track database schema changes that are compatible/incompatible with the `rolling-updates` feature, this module
makes use of the `db-compatibility-verifier-maven-plugin`. See `misc/db-compaotibility-verifier/README.md` for detailed
usage instructions.
The rolling-update:v2 feature only supports rolling updates of Keycloak patch releases, therefore database changes
are only tracked in release branches and not `main`.
## Tracking supported database changes
All Liquibase ChangeSets at branch time are considered supported by the `rolling-updates:v2` feature, as this is the
initial database state from the perspective of the current release stream. When creating a new release branch, a "snapshot"
of all known Liquibase ChangeSets in this module are recorded using the `db-compatibility-verifier:snapshot`
maven plugin. This generates two JSON files: a "supported" file with all known ChangeSets and an "unsupported"
file initialized with an empty array. Both of these files must be committed to the repository.
A snapshot can be created by executing:
```
./mvnw clean install -am -pl model/jpa -Pdb-changeset-snapshot -DskipTests
```
## Verifying all database changes are tracked
The `db-compatibility-verifier:verify` plugin is used as part of the `model/jpa` test phase to ensure that
any Liquibase changeset added during the release branches lifecycle are tracked in either the supported or unsupported files.
If one of more unrecorded ChangeSet is detected, contributors need to determine if the ChangeSet is compatible with a
rolling update. If the change is not compatible, then it must be recorded in the unsupported file. Conversely, if it is
compatible it must be recorded in the supported file.
Execution of the `db-compatibility-verifier:verify` plugin can be skipped during the test phase by specifying: `-Ddb.verify.skip=true`.
## Adding a supported database change
To add an individual ChangeSet to the supported file users can execute:
```
./mvnw -pl model/jpa org.keycloak:db-compatibility-verifier-maven-plugin:999.0.0-SNAPSHOT:supported \
-Ddb.verify.changeset.id=<id> \
-Ddb.verify.changeset.author=<author> \
-Ddb.verify.changeset.filename=<filename>
```
If multiple ChangeSets exist, and they are all compatible with rolling updates, the following can be used to add all changes:
```
./mvnw -pl model/jpa org.keycloak:db-compatibility-verifier-maven-plugin:999.0.0-SNAPSHOT:supported \
-Ddb.verify.changeset.addAll=true
```
## Adding an unsupported database change
To add an individual ChangeSet to the supported file users can execute:
```
./mvnw -pl model/jpa org.keycloak:db-compatibility-verifier-maven-plugin:999.0.0-SNAPSHOT:unsupported \
-Ddb.verify.changeset.id=<id> \
-Ddb.verify.changeset.author=<author> \
-Ddb.verify.changeset.filename=<filename>
```
If multiple ChangeSets exist, and they are all compatible with rolling updates, the following can be used to add all changes:
```
./mvnw -pl model/jpa org.keycloak:db-compatibility-verifier-maven-plugin:999.0.0-SNAPSHOT:unsupported \
-Ddb.verify.changeset.addAll=true
```

View file

@ -38,6 +38,8 @@
<jdbc.mvn.groupId>com.h2database</jdbc.mvn.groupId> <jdbc.mvn.groupId>com.h2database</jdbc.mvn.groupId>
<jdbc.mvn.artifactId>h2</jdbc.mvn.artifactId> <jdbc.mvn.artifactId>h2</jdbc.mvn.artifactId>
<jdbc.mvn.version>${h2.version}</jdbc.mvn.version> <jdbc.mvn.version>${h2.version}</jdbc.mvn.version>
<db.verify.supportedFile>src/main/resources/META-INF/rolling-upgrades-supported-changes.json</db.verify.supportedFile>
<db.verify.unsupportedFile>src/main/resources/META-INF/rolling-upgrades-unsupported-changes.json</db.verify.unsupportedFile>
</properties> </properties>
<dependencies> <dependencies>
@ -171,6 +173,44 @@
</execution> </execution>
</executions> </executions>
</plugin> </plugin>
<plugin>
<groupId>org.keycloak</groupId>
<artifactId>db-compatibility-verifier-maven-plugin</artifactId>
<version>${project.version}</version>
<executions>
<execution>
<id>verify</id>
<phase>test</phase>
<goals>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins> </plugins>
</build> </build>
<profiles>
<profile>
<id>db-changeset-snapshot</id>
<build>
<plugins>
<plugin>
<groupId>org.keycloak</groupId>
<artifactId>db-compatibility-verifier-maven-plugin</artifactId>
<version>${project.version}</version>
<executions>
<execution>
<id>snapshot</id>
<phase>process-resources</phase>
<goals>
<goal>snapshot</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project> </project>

View file

@ -40,6 +40,7 @@
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<maven.compiler.release>17</maven.compiler.release> <maven.compiler.release>17</maven.compiler.release>
<maven.plugin-tools.version>3.15.1</maven.plugin-tools.version>
<project.version.npm>999.0.0-SNAPSHOT</project.version.npm> <project.version.npm>999.0.0-SNAPSHOT</project.version.npm>
@ -304,6 +305,7 @@
<module>federation</module> <module>federation</module>
<module>services</module> <module>services</module>
<module>themes</module> <module>themes</module>
<module>misc/db-compatibility-verifier</module>
<module>misc/theme-verifier</module> <module>misc/theme-verifier</module>
<module>model</module> <module>model</module>
<module>util</module> <module>util</module>

View file

@ -1,21 +1,34 @@
package org.keycloak.quarkus.runtime.configuration.compatibility; package org.keycloak.quarkus.runtime.configuration.compatibility;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import org.keycloak.compatibility.CompatibilityMetadataProvider; import org.keycloak.compatibility.CompatibilityMetadataProvider;
import org.keycloak.config.DatabaseOptions; import org.keycloak.config.DatabaseOptions;
import org.keycloak.config.Option; import org.keycloak.config.Option;
import org.keycloak.jose.jws.crypto.HashUtils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.smallrye.config.ConfigValue; import io.smallrye.config.ConfigValue;
import org.jboss.logging.Logger;
import static org.keycloak.quarkus.runtime.configuration.Configuration.getConfigValue; import static org.keycloak.quarkus.runtime.configuration.Configuration.getConfigValue;
import static org.keycloak.quarkus.runtime.configuration.Configuration.getOptionalKcValue; import static org.keycloak.quarkus.runtime.configuration.Configuration.getOptionalKcValue;
public class DatabaseCompatibilityMetadataProvider implements CompatibilityMetadataProvider { public class DatabaseCompatibilityMetadataProvider implements CompatibilityMetadataProvider {
private static final Logger log = Logger.getLogger(DatabaseCompatibilityMetadataProvider.class);
public static final String ID = "database"; public static final String ID = "database";
public static final String UNSUPPORTED_CHANGE_SET_HASH_KEY = "unsupported-changeset-hash";
@Override @Override
public Map<String, String> metadata() { public Map<String, String> metadata() {
@ -30,6 +43,25 @@ public class DatabaseCompatibilityMetadataProvider implements CompatibilityMetad
addOptional(DatabaseOptions.DB_URL_PORT, metadata); addOptional(DatabaseOptions.DB_URL_PORT, metadata);
addOptional(DatabaseOptions.DB_URL_DATABASE, metadata); addOptional(DatabaseOptions.DB_URL_DATABASE, metadata);
} }
ObjectMapper objectMapper = new ObjectMapper();
try (InputStream inputStream = DatabaseCompatibilityMetadataProvider.class.getResourceAsStream("/META-INF/rolling-upgrades-unsupported-changes.json")) {
if (inputStream != null) {
// Load the ChangeSet JSON into memory and write to a JSON String in order to avoid whitespace changes impacting the hash
Set<ChangeSet> changeSets = objectMapper.readValue(inputStream, new TypeReference<>() {});
List<ChangeSet> sortedChanges = changeSets.stream().sorted(
Comparator.comparing(ChangeSet::id)
.thenComparing(ChangeSet::author)
.thenComparing(ChangeSet::filename)
).toList();
String changeSetJson = objectMapper.writeValueAsString(sortedChanges);
String hash = HashUtils.sha256UrlEncodedHash(changeSetJson, StandardCharsets.UTF_8);
metadata.put(UNSUPPORTED_CHANGE_SET_HASH_KEY, hash);
}
} catch (IOException e) {
log.error("Unable to close InputStream when creating database unsupported change hash", e);
}
return metadata; return metadata;
} }
@ -42,4 +74,7 @@ public class DatabaseCompatibilityMetadataProvider implements CompatibilityMetad
public String getId() { public String getId() {
return ID; return ID;
} }
public record ChangeSet(String id, String author, String filename) {
}
} }

View file

@ -18,10 +18,13 @@
package org.keycloak.it.cli.dist; package org.keycloak.it.cli.dist;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Set;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.common.Version; import org.keycloak.common.Version;
@ -35,6 +38,7 @@ import org.keycloak.it.junit5.extension.RawDistOnly;
import org.keycloak.it.utils.KeycloakDistribution; import org.keycloak.it.utils.KeycloakDistribution;
import org.keycloak.it.utils.RawKeycloakDistribution; import org.keycloak.it.utils.RawKeycloakDistribution;
import org.keycloak.jgroups.certificates.DefaultJGroupsCertificateProviderFactory; import org.keycloak.jgroups.certificates.DefaultJGroupsCertificateProviderFactory;
import org.keycloak.jose.jws.crypto.HashUtils;
import org.keycloak.quarkus.runtime.cli.command.UpdateCompatibility; import org.keycloak.quarkus.runtime.cli.command.UpdateCompatibility;
import org.keycloak.quarkus.runtime.cli.command.UpdateCompatibilityCheck; import org.keycloak.quarkus.runtime.cli.command.UpdateCompatibilityCheck;
import org.keycloak.quarkus.runtime.cli.command.UpdateCompatibilityMetadata; import org.keycloak.quarkus.runtime.cli.command.UpdateCompatibilityMetadata;
@ -46,15 +50,19 @@ import org.keycloak.spi.infinispan.impl.embedded.DefaultCacheEmbeddedConfigProvi
import org.keycloak.spi.infinispan.impl.remote.DefaultCacheRemoteConfigProviderFactory; import org.keycloak.spi.infinispan.impl.remote.DefaultCacheRemoteConfigProviderFactory;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.test.junit.main.Launch; import io.quarkus.test.junit.main.Launch;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.keycloak.it.cli.dist.Util.createTempFile; import static org.keycloak.it.cli.dist.Util.createTempFile;
import static org.keycloak.quarkus.runtime.configuration.compatibility.DatabaseCompatibilityMetadataProvider.UNSUPPORTED_CHANGE_SET_HASH_KEY;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@DistributionTest @DistributionTest
@RawDistOnly(reason = "Requires creating JSON file to be available between containers") @RawDistOnly(reason = "Requires creating JSON file to be available between containers")
public class UpdateCommandDistTest { public class UpdateCommandDistTest {
@ -267,9 +275,7 @@ public class UpdateCommandDistTest {
var info = JsonSerialization.mapper.readValue(jsonFile, UpdateCompatibilityCheck.METADATA_TYPE_REF); var info = JsonSerialization.mapper.readValue(jsonFile, UpdateCompatibilityCheck.METADATA_TYPE_REF);
var expectedMeta = defaultMeta(distribution); var expectedMeta = defaultMeta(distribution);
expectedMeta.put(DatabaseCompatibilityMetadataProvider.ID, Map.of( expectedMeta.get(DatabaseCompatibilityMetadataProvider.ID).put(DatabaseOptions.DB.getKey(), "postgres");
DatabaseOptions.DB.getKey(), "postgres"
));
info.remove(FeatureCompatibilityMetadataProvider.ID); info.remove(FeatureCompatibilityMetadataProvider.ID);
assertEquals(expectedMeta, info); assertEquals(expectedMeta, info);
@ -287,8 +293,8 @@ public class UpdateCommandDistTest {
// Assert that expected db-url-* options are written to the metadata when --db-url is not present // Assert that expected db-url-* options are written to the metadata when --db-url is not present
var info = JsonSerialization.mapper.readValue(jsonFile, UpdateCompatibilityCheck.METADATA_TYPE_REF); var info = JsonSerialization.mapper.readValue(jsonFile, UpdateCompatibilityCheck.METADATA_TYPE_REF);
var expectedMeta = defaultMeta(distribution); var expectedMeta = defaultMeta(distribution);
expectedMeta.put(DatabaseCompatibilityMetadataProvider.ID, Map.of( var dbMeta = expectedMeta.get(DatabaseCompatibilityMetadataProvider.ID);
DatabaseOptions.DB.getKey(), DatabaseOptions.DB.getDefaultValue().get(), dbMeta.putAll(Map.of(
DatabaseOptions.DB_URL_DATABASE.getKey(), "keycloak", DatabaseOptions.DB_URL_DATABASE.getKey(), "keycloak",
DatabaseOptions.DB_URL_HOST.getKey(), "localhost", DatabaseOptions.DB_URL_HOST.getKey(), "localhost",
DatabaseOptions.DB_URL_PORT.getKey(), "9999" DatabaseOptions.DB_URL_PORT.getKey(), "9999"
@ -306,9 +312,14 @@ public class UpdateCommandDistTest {
assertEquals(0, result.exitCode()); assertEquals(0, result.exitCode());
info = JsonSerialization.mapper.readValue(jsonFile, UpdateCompatibilityCheck.METADATA_TYPE_REF); info = JsonSerialization.mapper.readValue(jsonFile, UpdateCompatibilityCheck.METADATA_TYPE_REF);
expectedMeta.put(DatabaseCompatibilityMetadataProvider.ID, Map.of( Map<String, String> expectedDbMeta = new HashMap<>();
DatabaseOptions.DB.getKey(), DatabaseOptions.DB.getDefaultValue().get() expectedDbMeta.put(DatabaseOptions.DB.getKey(), DatabaseOptions.DB.getDefaultValue().get());
)); String expectedHash = dbMeta.get(UNSUPPORTED_CHANGE_SET_HASH_KEY);
if (expectedHash != null) {
expectedDbMeta.put(UNSUPPORTED_CHANGE_SET_HASH_KEY, expectedHash);
}
expectedMeta.put(DatabaseCompatibilityMetadataProvider.ID, expectedDbMeta);
info.remove(FeatureCompatibilityMetadataProvider.ID); info.remove(FeatureCompatibilityMetadataProvider.ID);
assertEquals(expectedMeta, info); assertEquals(expectedMeta, info);
@ -317,15 +328,25 @@ public class UpdateCommandDistTest {
result.assertExitCode(CompatibilityResult.ExitCode.ROLLING.value()); result.assertExitCode(CompatibilityResult.ExitCode.ROLLING.value());
} }
private Map<String, Map<String, String>> defaultMeta(KeycloakDistribution distribution) { private Map<String, Map<String, String>> defaultMeta(KeycloakDistribution distribution) throws IOException {
Map<String, String> keycloak = new HashMap<>(1); Map<String, String> keycloak = new HashMap<>(1);
keycloak.put("version", Version.VERSION); keycloak.put("version", Version.VERSION);
Map<String, String> dbMeta = new HashMap<>();
dbMeta.put(DatabaseOptions.DB.getKey(), DatabaseOptions.DB.getDefaultValue().get());
try (InputStream inputStream = UpdateCommandDistTest.class.getResourceAsStream("/META-INF/rolling-upgrades-unsupported-changes.json")) {
if (inputStream != null) {
ObjectMapper objectMapper = new ObjectMapper();
Set<DatabaseCompatibilityMetadataProvider.ChangeSet> changeSets = objectMapper.readValue(inputStream, new TypeReference<>() {});
String changeSetJson = objectMapper.writeValueAsString(changeSets);
String hash = HashUtils.sha256UrlEncodedHash(changeSetJson, StandardCharsets.UTF_8);
dbMeta.put(UNSUPPORTED_CHANGE_SET_HASH_KEY, hash);
}
}
Map<String, Map<String, String>> m = new HashMap<>(); Map<String, Map<String, String>> m = new HashMap<>();
m.put(KeycloakCompatibilityMetadataProvider.ID, keycloak); m.put(KeycloakCompatibilityMetadataProvider.ID, keycloak);
m.put(DatabaseCompatibilityMetadataProvider.ID, Map.of( m.put(DatabaseCompatibilityMetadataProvider.ID, dbMeta);
DatabaseOptions.DB.getKey(), DatabaseOptions.DB.getDefaultValue().get()
));
m.put(CacheEmbeddedConfigProviderSpi.SPI_NAME, embeddedCachingMeta(distribution)); m.put(CacheEmbeddedConfigProviderSpi.SPI_NAME, embeddedCachingMeta(distribution));
m.put(JGroupsCertificateProviderSpi.SPI_NAME, Map.of( m.put(JGroupsCertificateProviderSpi.SPI_NAME, Map.of(
"enabled", "true" "enabled", "true"