Add experimental feature rolling-updates:v2 that allows rolling updat… (#39751)

...e for patch releases
Closes #38882
Signed-off-by: Michal Hajas <mhajas@redhat.com>
This commit is contained in:
Michal Hajas 2025-05-27 16:17:42 +02:00 committed by GitHub
parent 077173d24f
commit 88f660b235
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 303 additions and 16 deletions

View File

@ -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 <a href="https://github.com/keycloak/keycloak/issues/37967">Deprecate for removal the Instagram social broker</a>.
@ -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<Feature> 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"
*/

View File

@ -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]

View File

@ -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
https://www.keycloak.org/server/update-compatibility#rolling-updates-for-patch-releases

View File

@ -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 <<rolling-updates-for-patch-releases>> 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 <<rolling-updates-for-patch-releases>> 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.

View File

@ -30,8 +30,7 @@ public class CachingCompatibilityMetadataProvider implements CompatibilityMetada
private static Map<String, String> 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()
);
}
}

View File

@ -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<String, CompatibilityMetadataProvider> loadAllProviders() {

View File

@ -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 {
}
}

View File

@ -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<CompatibilityResult> 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<String> 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<Set<String>> 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()));
}
}

View File

@ -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<Set<String>> incompatibleAttributes() {return Optional.empty();}
static CompatibilityResult providerCompatible(String providerId) {
return new ProviderCompatibleResult(Objects.requireNonNull(providerId));
}

View File

@ -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<String> 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<Set<String>> incompatibleAttributes() {
return Optional.of(Set.of(attribute));
}
}

View File

@ -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));
}

View File

@ -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)) {

View File

@ -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<String, String> metadata() {
return Map.of("version", Version.VERSION);
return Map.of(VERSION_KEY, version);
}
@Override
public CompatibilityResult isCompatible(Map<String, String> 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;
}
}

View File

@ -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<String, String> 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());
}
}