From 88f660b2353372339825cc5b44ae35ded422f672 Mon Sep 17 00:00:00 2001 From: Michal Hajas Date: Tue, 27 May 2025 16:17:42 +0200 Subject: [PATCH] =?UTF-8?q?Add=20experimental=20feature=20rolling-updates:?= =?UTF-8?q?v2=20that=20allows=20rolling=20updat=E2=80=A6=20(#39751)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ...e for patch releases Closes #38882 Signed-off-by: Michal Hajas --- .../java/org/keycloak/common/Profile.java | 12 ++ .../release_notes/topics/26_3_0.adoc | 9 ++ .../tests/src/test/resources/ignored-links | 2 +- docs/guides/server/update-compatibility.adoc | 27 ++++- .../CachingCompatibilityMetadataProvider.java | 6 +- .../cli/command/AbstractUpdatesCommand.java | 8 +- .../it/cli/dist/UpdateCommandDistTest.java | 28 ++++- .../AggregatedCompatibilityResult.java | 44 ++++++++ .../compatibility/CompatibilityResult.java | 3 + .../ProviderIncompatibleResult.java | 6 + .../java/org/keycloak/compatibility/Util.java | 8 +- .../org/keycloak/migration/ModelVersion.java | 4 + ...KeycloakCompatibilityMetadataProvider.java | 58 +++++++++- ...loakCompatibilityMetadataProviderTest.java | 104 ++++++++++++++++++ 14 files changed, 303 insertions(+), 16 deletions(-) create mode 100644 server-spi-private/src/main/java/org/keycloak/compatibility/AggregatedCompatibilityResult.java create mode 100644 services/src/test/java/org/keycloak/compatibility/KeycloakCompatibilityMetadataProviderTest.java 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()); + } +}