diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java
index f4125cff9d4..c3fd567c274 100755
--- a/common/src/main/java/org/keycloak/common/Profile.java
+++ b/common/src/main/java/org/keycloak/common/Profile.java
@@ -132,6 +132,7 @@ public class Profile {
LOGOUT_ALL_SESSIONS_V1("Logout all sessions logs out only regular sessions", Type.DEPRECATED, 1),
ROLLING_UPDATES_V1("Rolling Updates", Type.DEFAULT, 1),
+ ROLLING_UPDATES_V2("Rolling Updates for patch releases", Type.EXPERIMENTAL, 2),
/**
* @see Deprecate for removal the Instagram social broker.
@@ -412,6 +413,13 @@ public class Profile {
return getInstance().features.get(feature);
}
+ public static boolean isAnyVersionOfFeatureEnabled(Feature feature) {
+ return isFeatureEnabled(feature) ||
+ getInstance().getEnabledFeatures()
+ .stream()
+ .anyMatch(f -> Objects.equals(f.getUnversionedKey(), feature.getUnversionedKey()));
+ }
+
public ProfileName getName() {
return profileName;
}
@@ -424,6 +432,10 @@ public class Profile {
return features.entrySet().stream().filter(e -> !e.getValue()).map(Map.Entry::getKey).collect(Collectors.toSet());
}
+ public Set getEnabledFeatures() {
+ return features.entrySet().stream().filter(Map.Entry::getValue).map(Map.Entry::getKey).collect(Collectors.toSet());
+ }
+
/**
* @return all features of type "preview" or "preview_disabled_by_default"
*/
diff --git a/docs/documentation/release_notes/topics/26_3_0.adoc b/docs/documentation/release_notes/topics/26_3_0.adoc
index f2d4750aaaf..44dc94bb2ff 100644
--- a/docs/documentation/release_notes/topics/26_3_0.adoc
+++ b/docs/documentation/release_notes/topics/26_3_0.adoc
@@ -33,3 +33,12 @@ In this release, the *Recovery Codes* two-factor authentication is promoted from
= New AIA action parameter `skip_if_exists` for WebAuthn register
Both WebAuthn Register actions (`webauthn-register` and `webauthn-register-passwordless`) now support a parameter `skip_if_exists` when initiated by the application (AIA). The parameter allows to skip the action if the user already has a credential of that type. For more information, see the link:{adminguide_link}#_webauthn_aia[Registering WebAuthn credentials using AIA] chapter in the {adminguide_name}.
+
+= Experimental support for rolling updates for patch releases
+
+In the previous release, the Keycloak Operator was enhanced to support performing rolling updates of the Keycloak image if both images contain the same version.
+This is useful, for example, when switching to an optimized image, changing a theme or a provider source code.
+
+In this release, we extended this to perform rolling update when the new image contains a future patch release from the same `major.minor` release stream.
+Read more in https://www.keycloak.org/server/update-compatibility#rolling-updates-for-patch-releases[Update Compatibility Tool]
+
diff --git a/docs/documentation/tests/src/test/resources/ignored-links b/docs/documentation/tests/src/test/resources/ignored-links
index eb2a7cf5ca4..b77d682d54a 100644
--- a/docs/documentation/tests/src/test/resources/ignored-links
+++ b/docs/documentation/tests/src/test/resources/ignored-links
@@ -44,4 +44,4 @@ https://www.keycloak.org/server/caching#_securing_transport_stacks
https://www.keycloak.org/observability/grafana-dashboards
https://www.keycloak.org/securing-apps/token-exchange*
https://www.keycloak.org/operator/rolling-updates
-https://www.keycloak.org/server/update-compatibility
\ No newline at end of file
+https://www.keycloak.org/server/update-compatibility#rolling-updates-for-patch-releases
\ No newline at end of file
diff --git a/docs/guides/server/update-compatibility.adoc b/docs/guides/server/update-compatibility.adoc
index c279f848415..696c6734667 100644
--- a/docs/guides/server/update-compatibility.adoc
+++ b/docs/guides/server/update-compatibility.adoc
@@ -13,6 +13,12 @@ The outcome shows whether a rolling update is possible or if a recreate update i
In its current version, it shows that a rolling update is possible when the {project_name} version is the same for the old and the new version.
Future versions of {project_name} might change that behavior to use additional information from the configuration, the image and the version to determine if a rolling update is possible.
+[NOTE]
+====
+In the next iteration of this feature, it is possible to use rolling update strategy also when updating to the following patch release of {project_name}.
+Refer to <> section for more details.
+====
+
This is fully scriptable, so your update procedure can use that information to perform a rolling or recreate strategy depending on the change performed.
It is also GitOps friendly, as it allows storing the metadata of the previous configuration in a file. Use this file in a CI/CD pipeline with the new configuration to determine if a rolling update is possible or if a recreate update is needed.
@@ -74,7 +80,7 @@ Omitting any configuration options results in incomplete metadata, and could lea
=== Checking the Metadata
This command checks the metadata generated by the previous command and compares it with the current configuration and {project_name} version.
-If you are upgrading to a new {project_name} version, this command must be executed with the new version.
+If you are updating to a new {project_name} version, this command must be executed with the new version.
.Check the metadata from a previous deployment.
<@kc.updatecompatibility parameters="check --file=/path/to/file.json"/>
@@ -106,6 +112,12 @@ If no rolling update is possible, the command provides details about the incompa
----
<1> In this example, the Keycloak version `26.2.0` is not compatible with version `26.2.1` and a rolling update is not possible.
+[NOTE]
+====
+In the next iteration of this feature, it is possible to use rolling update strategy also when updating to the following patch release of {project_name}.
+Refer to <> section for more details.
+====
+
*Command exit code*
Use the command's exit code to determine the update type in your automation pipeline:
@@ -131,6 +143,19 @@ m|4
The feature `rolling-updates` is disabled.
|===
+
+[[rolling-updates-for-patch-releases]]
+== Rolling updates for patch releases
+
+WARNING: This behavior is currently in an experimental mode, and it is not recommended for use in production.
+
+It is possible to configure the {project_name} compatibility command to allow rolling updates when updating from a version to a same patch version from the same `major.minor` release stream.
+
+To enable this behavior for compatibility check command enable feature `rolling-updates:v2` as shown in the following example.
+<@kc.updatecompatibility parameters="check --file=/path/to/file.json --features=rolling-updates:v2"/>
+
+Note there is no change needed when generating metadata using `metadata` command.
+
== Further reading
The {project_name} Operator uses the functionality described above to determine if a rolling update is possible. See the <@links.operator id="rolling-updates" /> {section} and the `Auto` strategy for more information.
diff --git a/model/infinispan/src/main/java/org/keycloak/infinispan/compatibility/CachingCompatibilityMetadataProvider.java b/model/infinispan/src/main/java/org/keycloak/infinispan/compatibility/CachingCompatibilityMetadataProvider.java
index 20405f9a77a..ca385e3a25b 100644
--- a/model/infinispan/src/main/java/org/keycloak/infinispan/compatibility/CachingCompatibilityMetadataProvider.java
+++ b/model/infinispan/src/main/java/org/keycloak/infinispan/compatibility/CachingCompatibilityMetadataProvider.java
@@ -30,8 +30,7 @@ public class CachingCompatibilityMetadataProvider implements CompatibilityMetada
private static Map remoteInfinispanMetadata() {
return Map.of(
"mode", "remote",
- "persistence", Boolean.toString(MultiSiteUtils.isPersistentSessionsEnabled()),
- "version", Version.getVersion()
+ "persistence", Boolean.toString(MultiSiteUtils.isPersistentSessionsEnabled())
);
}
@@ -39,7 +38,8 @@ public class CachingCompatibilityMetadataProvider implements CompatibilityMetada
return Map.of(
"mode", "embedded",
"persistence", Boolean.toString(Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS)),
- "version", Version.getVersion()
+ "version", Version.getVersion(),
+ "jgroupsVersion", org.jgroups.Version.printVersion()
);
}
}
diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/AbstractUpdatesCommand.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/AbstractUpdatesCommand.java
index 1918a9aaa70..7aeb782ff1d 100644
--- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/AbstractUpdatesCommand.java
+++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/AbstractUpdatesCommand.java
@@ -54,7 +54,7 @@ public abstract class AbstractUpdatesCommand extends AbstractCommand implements
@Override
public void run() {
Environment.updateProfile(true);
- if (!Profile.isFeatureEnabled(Profile.Feature.ROLLING_UPDATES_V1)) {
+ if (!Profile.isAnyVersionOfFeatureEnabled(Profile.Feature.ROLLING_UPDATES_V1)) {
printFeatureDisabled();
picocli.exit(FEATURE_DISABLED_EXIT_CODE);
return;
@@ -94,13 +94,13 @@ public abstract class AbstractUpdatesCommand extends AbstractCommand implements
}
private void printPreviewWarning() {
- if (Profile.Feature.ROLLING_UPDATES_V1.getType() == Profile.Feature.Type.PREVIEW) {
- printError("Warning! This command is '" + Profile.Feature.ROLLING_UPDATES_V1.getType() + "' and is not recommended for use in production. It may change or be removed at a future release.");
+ if (Profile.isFeatureEnabled(Profile.Feature.ROLLING_UPDATES_V2) && (Profile.Feature.ROLLING_UPDATES_V2.getType() == Profile.Feature.Type.PREVIEW || Profile.Feature.ROLLING_UPDATES_V2.getType() == Profile.Feature.Type.EXPERIMENTAL)) {
+ printError("Warning! This command is '" + Profile.Feature.ROLLING_UPDATES_V2.getType() + "' and is not recommended for use in production. It may change or be removed at a future release.");
}
}
void printFeatureDisabled() {
- printError("Unable to use this command. The feature '" + Profile.Feature.ROLLING_UPDATES_V1.getVersionedKey() + "' is not enabled.");
+ printError("Unable to use this command. None of the versions of the feature '" + Profile.Feature.ROLLING_UPDATES_V1.getUnversionedKey() + "' is enabled.");
}
static Map loadAllProviders() {
diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/UpdateCommandDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/UpdateCommandDistTest.java
index 404b1fb7d10..7e5a20f3c87 100644
--- a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/UpdateCommandDistTest.java
+++ b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/UpdateCommandDistTest.java
@@ -47,7 +47,7 @@ public class UpdateCommandDistTest {
@Test
@Launch({UpdateCompatibility.NAME, UpdateCompatibilityMetadata.NAME, DISABLE_FEATURE})
public void testFeatureNotEnabled(CLIResult cliResult) {
- cliResult.assertError("Unable to use this command. The feature 'rolling-updates:v1' is not enabled.");
+ cliResult.assertError("Unable to use this command. None of the versions of the feature 'rolling-updates' is enabled.");
}
@Test
@@ -78,8 +78,10 @@ public class UpdateCommandDistTest {
var info = JsonSerialization.mapper.readValue(jsonFile, UpdateCompatibilityCheck.METADATA_TYPE_REF);
assertEquals(Version.VERSION, info.get(KeycloakCompatibilityMetadataProvider.ID).get("version"));
assertEquals(org.infinispan.commons.util.Version.getVersion(), info.get(CachingCompatibilityMetadataProvider.ID).get("version"));
+ assertEquals(org.jgroups.Version.printVersion(), info.get(CachingCompatibilityMetadataProvider.ID).get("jgroupsVersion"));
result = distribution.run(UpdateCompatibility.NAME, UpdateCompatibilityCheck.NAME, UpdateCompatibilityCheck.INPUT_OPTION_NAME, jsonFile.getAbsolutePath());
+ result.assertExitCode(CompatibilityResult.ExitCode.ROLLING.value());
result.assertMessage("[OK] Rolling Update is available.");
result.assertNoError("Rolling Update is not available.");
}
@@ -94,7 +96,8 @@ public class UpdateCommandDistTest {
info.put(CachingCompatibilityMetadataProvider.ID, Map.of(
"version", org.infinispan.commons.util.Version.getVersion(),
"persistence", "true",
- "mode", "embedded"
+ "mode", "embedded",
+ "jgroupsVersion", org.jgroups.Version.printVersion()
));
JsonSerialization.mapper.writeValue(jsonFile, info);
@@ -107,12 +110,28 @@ public class UpdateCommandDistTest {
info.put(CachingCompatibilityMetadataProvider.ID, Map.of(
"version", "0.0.0.Final",
"persistence", "true",
- "mode", "embedded"
+ "mode", "embedded",
+ "jgroupsVersion", org.jgroups.Version.printVersion()
+ ));
+ JsonSerialization.mapper.writeValue(jsonFile, info);
+
+ // incompatible jgroups version
+ result = distribution.run(UpdateCompatibility.NAME, UpdateCompatibilityCheck.NAME, UpdateCompatibilityCheck.INPUT_OPTION_NAME, jsonFile.getAbsolutePath());
+ result.assertExitCode(CompatibilityResult.ExitCode.RECREATE.value());
+ result.assertError("[%s] Rolling Update is not available. '%s.version' is incompatible: 0.0.0.Final -> %s.".formatted(CachingCompatibilityMetadataProvider.ID, CachingCompatibilityMetadataProvider.ID, org.infinispan.commons.util.Version.getVersion())); // incompatible infinispan version
+
+ info.put(KeycloakCompatibilityMetadataProvider.ID, Map.of("version", Version.VERSION));
+ info.put(CachingCompatibilityMetadataProvider.ID, Map.of(
+ "version", org.infinispan.commons.util.Version.getVersion(),
+ "persistence", "true",
+ "mode", "embedded",
+ "jgroupsVersion", "0.0.0.Final"
));
JsonSerialization.mapper.writeValue(jsonFile, info);
result = distribution.run(UpdateCompatibility.NAME, UpdateCompatibilityCheck.NAME, UpdateCompatibilityCheck.INPUT_OPTION_NAME, jsonFile.getAbsolutePath());
- result.assertError("[%s] Rolling Update is not available. '%s.version' is incompatible: 0.0.0.Final -> %s.".formatted(CachingCompatibilityMetadataProvider.ID, CachingCompatibilityMetadataProvider.ID, org.infinispan.commons.util.Version.getVersion()));
+ result.assertExitCode(CompatibilityResult.ExitCode.RECREATE.value());
+ result.assertError("[%s] Rolling Update is not available. '%s.jgroupsVersion' is incompatible: 0.0.0.Final -> %s.".formatted(CachingCompatibilityMetadataProvider.ID, CachingCompatibilityMetadataProvider.ID, org.jgroups.Version.printVersion()));
}
private static File createTempFile(String prefix) throws IOException {
@@ -122,3 +141,4 @@ public class UpdateCommandDistTest {
}
}
+
diff --git a/server-spi-private/src/main/java/org/keycloak/compatibility/AggregatedCompatibilityResult.java b/server-spi-private/src/main/java/org/keycloak/compatibility/AggregatedCompatibilityResult.java
new file mode 100644
index 00000000000..ef55cd33d09
--- /dev/null
+++ b/server-spi-private/src/main/java/org/keycloak/compatibility/AggregatedCompatibilityResult.java
@@ -0,0 +1,44 @@
+package org.keycloak.compatibility;
+
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+record AggregatedCompatibilityResult(Set compatibilityResults) implements CompatibilityResult {
+
+ public AggregatedCompatibilityResult(CompatibilityResult compatibilityResult) {
+ this(new HashSet<>());
+ this.compatibilityResults.add(compatibilityResult);
+ }
+
+ public AggregatedCompatibilityResult add(CompatibilityResult a) {
+ compatibilityResults.add(a);
+ return this;
+ }
+
+ @Override
+ public int exitCode() {
+ return compatibilityResults.stream()
+ .anyMatch(r -> r.exitCode() == ExitCode.RECREATE.value())
+ ? ExitCode.RECREATE.value() : ExitCode.ROLLING.value();
+ }
+
+ @Override
+ public Optional errorMessage() {
+ StringBuilder sb = new StringBuilder("Aggregated incompatible results:\n");
+ for (CompatibilityResult result : compatibilityResults) {
+ sb.append(result.errorMessage()).append("\n");
+ }
+ return Optional.of(sb.toString());
+ }
+
+ @Override
+ public Optional> incompatibleAttributes() {
+ return Optional.of(compatibilityResults.stream()
+ .filter(r -> ProviderIncompatibleResult.class.isAssignableFrom(r.getClass()))
+ .map(ProviderIncompatibleResult.class::cast)
+ .flatMap(r -> r.incompatibleAttributes().orElse(Set.of()).stream())
+ .collect(Collectors.toSet()));
+ }
+}
diff --git a/server-spi-private/src/main/java/org/keycloak/compatibility/CompatibilityResult.java b/server-spi-private/src/main/java/org/keycloak/compatibility/CompatibilityResult.java
index 58007a2ca81..b660b635363 100644
--- a/server-spi-private/src/main/java/org/keycloak/compatibility/CompatibilityResult.java
+++ b/server-spi-private/src/main/java/org/keycloak/compatibility/CompatibilityResult.java
@@ -20,6 +20,7 @@ package org.keycloak.compatibility;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
/**
* The result of {@link CompatibilityMetadataProvider#isCompatible(Map)}.
@@ -48,6 +49,8 @@ public interface CompatibilityResult {
return Optional.empty();
}
+ default Optional> incompatibleAttributes() {return Optional.empty();}
+
static CompatibilityResult providerCompatible(String providerId) {
return new ProviderCompatibleResult(Objects.requireNonNull(providerId));
}
diff --git a/server-spi-private/src/main/java/org/keycloak/compatibility/ProviderIncompatibleResult.java b/server-spi-private/src/main/java/org/keycloak/compatibility/ProviderIncompatibleResult.java
index cebb67a0992..1ead1e7899d 100644
--- a/server-spi-private/src/main/java/org/keycloak/compatibility/ProviderIncompatibleResult.java
+++ b/server-spi-private/src/main/java/org/keycloak/compatibility/ProviderIncompatibleResult.java
@@ -1,6 +1,7 @@
package org.keycloak.compatibility;
import java.util.Optional;
+import java.util.Set;
/**
* Internal class to signal that the provider is not compatible with the previous metadata.
@@ -18,4 +19,9 @@ record ProviderIncompatibleResult(String providerId, String attribute, String pr
public Optional errorMessage() {
return Optional.of("[%s] Rolling Update is not available. '%s.%s' is incompatible: %s -> %s.".formatted(providerId, providerId, attribute, previousValue, currentValue));
}
+
+ @Override
+ public Optional> incompatibleAttributes() {
+ return Optional.of(Set.of(attribute));
+ }
}
diff --git a/server-spi-private/src/main/java/org/keycloak/compatibility/Util.java b/server-spi-private/src/main/java/org/keycloak/compatibility/Util.java
index c6f066ea61c..95dfcb094a5 100644
--- a/server-spi-private/src/main/java/org/keycloak/compatibility/Util.java
+++ b/server-spi-private/src/main/java/org/keycloak/compatibility/Util.java
@@ -21,7 +21,13 @@ public final class Util {
.sorted()
.map(key -> compare(provider, key, old.get(key), current.get(key)))
.filter(Util::isNotCompatible)
- .findFirst()
+ .reduce((a, b) -> {
+ if (! (a instanceof AggregatedCompatibilityResult)) {
+ a = new AggregatedCompatibilityResult(a);
+ }
+
+ return ((AggregatedCompatibilityResult) a).add(b);
+ })
.orElse(CompatibilityResult.providerCompatible(provider));
}
diff --git a/server-spi-private/src/main/java/org/keycloak/migration/ModelVersion.java b/server-spi-private/src/main/java/org/keycloak/migration/ModelVersion.java
index 1eaa1ba0afe..47e070e5325 100755
--- a/server-spi-private/src/main/java/org/keycloak/migration/ModelVersion.java
+++ b/server-spi-private/src/main/java/org/keycloak/migration/ModelVersion.java
@@ -111,6 +111,10 @@ public class ModelVersion {
return false;
}
+ public boolean hasSameMajorMinor(ModelVersion v2) {
+ return major == v2.major && minor == v2.minor;
+ }
+
@Override
public boolean equals(Object obj) {
if (!(obj instanceof ModelVersion)) {
diff --git a/services/src/main/java/org/keycloak/compatibility/KeycloakCompatibilityMetadataProvider.java b/services/src/main/java/org/keycloak/compatibility/KeycloakCompatibilityMetadataProvider.java
index 7f16dac20f4..ecf032b42d2 100644
--- a/services/src/main/java/org/keycloak/compatibility/KeycloakCompatibilityMetadataProvider.java
+++ b/services/src/main/java/org/keycloak/compatibility/KeycloakCompatibilityMetadataProvider.java
@@ -1,7 +1,10 @@
package org.keycloak.compatibility;
import java.util.Map;
+
+import org.keycloak.common.Profile;
import org.keycloak.common.Version;
+import org.keycloak.migration.ModelVersion;
/**
* A {@link CompatibilityMetadataProvider} implementation to provide the Keycloak version.
@@ -9,14 +12,65 @@ import org.keycloak.common.Version;
public class KeycloakCompatibilityMetadataProvider implements CompatibilityMetadataProvider {
public static final String ID = "keycloak";
+ public static final String VERSION_KEY = "version";
+ private final String version;
+
+ public KeycloakCompatibilityMetadataProvider() {
+ this(Version.VERSION);
+ }
+
+ public KeycloakCompatibilityMetadataProvider(String version) {
+ this.version = version;
+ }
@Override
public Map metadata() {
- return Map.of("version", Version.VERSION);
+ return Map.of(VERSION_KEY, version);
+ }
+
+ @Override
+ public CompatibilityResult isCompatible(Map other) {
+ CompatibilityResult equalComparison = CompatibilityMetadataProvider.super.isCompatible(other);
+
+ // If V2 feature is enabled, we consider versions upgradable in a rolling way if the other is a previous micro release
+ if (!Util.isNotCompatible(equalComparison) || !Profile.isFeatureEnabled(Profile.Feature.ROLLING_UPDATES_V2)) {
+ return equalComparison;
+ }
+
+
+ // We need to make sure the previous version is not null
+ String otherVersion = other.get(VERSION_KEY);
+ if (otherVersion == null)
+ return equalComparison;
+
+ // Check if only version attribute is incompatible we don't want to allow rolling update if some other metadata didn't match
+ boolean versionMismatch = equalComparison.incompatibleAttributes()
+ .map(erroredAttributes -> erroredAttributes.size() == 1 && erroredAttributes.iterator().next().equals(VERSION_KEY))
+ .orElse(false);
+
+ if (!versionMismatch) {
+ return equalComparison;
+ }
+
+ ModelVersion otherModelVersion = new ModelVersion(otherVersion);
+ ModelVersion currentModelVersion = new ModelVersion(version);
+
+ // Check we are in the same major.minor release stream
+ if (!currentModelVersion.hasSameMajorMinor(otherModelVersion)) {
+ return equalComparison;
+ }
+
+ int otherMicro = otherModelVersion.getMicro();
+ int currentMicro = currentModelVersion.getMicro();
+
+ // Make sure we are updating to a newer or the same micro release and do not allow rolling rollback
+ return currentMicro < otherMicro ?
+ equalComparison :
+ CompatibilityResult.providerCompatible(ID);
}
@Override
public String getId() {
- return "keycloak";
+ return ID;
}
}
diff --git a/services/src/test/java/org/keycloak/compatibility/KeycloakCompatibilityMetadataProviderTest.java b/services/src/test/java/org/keycloak/compatibility/KeycloakCompatibilityMetadataProviderTest.java
new file mode 100644
index 00000000000..2c80484b579
--- /dev/null
+++ b/services/src/test/java/org/keycloak/compatibility/KeycloakCompatibilityMetadataProviderTest.java
@@ -0,0 +1,104 @@
+package org.keycloak.compatibility;
+
+import org.junit.Test;
+import org.keycloak.common.Profile;
+import org.keycloak.common.profile.ProfileConfigResolver;
+
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.keycloak.compatibility.KeycloakCompatibilityMetadataProvider.VERSION_KEY;
+
+public class KeycloakCompatibilityMetadataProviderTest {
+
+ @Test
+ public void testMicroVersionUpgradeWorksWithRollingUpdateV2() {
+ // Enable V2 feature
+ Profile.configure(new ProfileConfigResolver() {
+ @Override
+ public Profile.ProfileName getProfileName() {
+ return null;
+ }
+
+ @Override
+ public FeatureConfig getFeatureConfig(String feature) {
+ return Profile.Feature.ROLLING_UPDATES_V2.getVersionedKey().equals(feature) ? FeatureConfig.ENABLED : FeatureConfig.UNCONFIGURED;
+ }
+ });
+
+ // Make compatibility provider return hardcoded version as we are not able to test this in integration tests with micro versions equal to 0
+ KeycloakCompatibilityMetadataProvider compatibilityProvider = new KeycloakCompatibilityMetadataProvider("999.999.999-Final");
+
+ // Test compatible
+ assertCompatibility(CompatibilityResult.ExitCode.ROLLING, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.999.999-Final")));
+ assertCompatibility(CompatibilityResult.ExitCode.ROLLING, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.999.998-Final")));
+ assertCompatibility(CompatibilityResult.ExitCode.ROLLING, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.999.999-Final1")));
+ assertCompatibility(CompatibilityResult.ExitCode.ROLLING, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.999.1-Final")));
+
+ // Test incompatible
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.999.1000-Final")));
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.998.999-Final")));
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "998.999.999-Final")));
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.998.998-Final")));
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "998.999.998-Final")));
+
+ Profile.reset();
+ }
+
+ @Test
+ public void testRollingUpgradesV1() {
+ Profile.configure();
+
+ // Make compatibility provider return hardcoded version so we can subtract and add to any of major.minor.micro number
+ KeycloakCompatibilityMetadataProvider compatibilityProvider = new KeycloakCompatibilityMetadataProvider("999.999.999-Final") ;
+
+ // Test compatible
+ assertCompatibility(CompatibilityResult.ExitCode.ROLLING, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.999.999-Final")));
+
+ // Test incompatible
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.999.998-Final")));
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.999.999-Final1")));
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.999.997-Final")));
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.999.1000-Final")));
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.998.999-Final")));
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "998.999.999-Final")));
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.998.998-Final")));
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "998.999.998-Final")));
+ }
+
+ @Test
+ public void testRollingUpgradeRefusedWithOtherMetadataNotEquals() {
+ // Enable V2 feature
+ Profile.configure(new ProfileConfigResolver() {
+ @Override
+ public Profile.ProfileName getProfileName() {
+ return null;
+ }
+
+ @Override
+ public FeatureConfig getFeatureConfig(String feature) {
+ return Profile.Feature.ROLLING_UPDATES_V2.getVersionedKey().equals(feature) ? FeatureConfig.ENABLED : FeatureConfig.UNCONFIGURED;
+ }
+ });
+
+ // Make compatibility provider return hardcoded version as we are not able to test this in integration tests with micro versions equal to 0
+ KeycloakCompatibilityMetadataProvider compatibilityProvider = new KeycloakCompatibilityMetadataProvider("999.999.999-Final") {
+ @Override
+ public Map metadata() {
+ return Map.of(VERSION_KEY, "999.999.999-Final",
+ "key2", "value2");
+ }
+ };
+
+ // Test compatible
+ assertCompatibility(CompatibilityResult.ExitCode.ROLLING, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.999.998-Final", "key2", "value2")));
+
+ // Test incompatible
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.999.998-Final", "key2", "different-value")));
+ assertCompatibility(CompatibilityResult.ExitCode.RECREATE, compatibilityProvider.isCompatible(Map.of(VERSION_KEY, "999.999.998-Final")));
+ }
+
+ private void assertCompatibility(CompatibilityResult.ExitCode expected, CompatibilityResult actual) {
+ assertEquals("Expected compatibility result was " + expected, expected.exitCode, actual.exitCode());
+ }
+}