Convert watching to polling and adding infinispan config file support (#26510)

Signed-off-by: Steve Hawkins <shawkins@redhat.com>
This commit is contained in:
Steven Hawkins 2024-01-31 07:57:34 -05:00 committed by GitHub
parent 01be4032d8
commit f55e903092
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 325 additions and 269 deletions

View File

@ -171,6 +171,23 @@ link:{upgradingguide_link}[{upgradingguide_name}].
In previous versions of Keycloak when the last member of a User, Group or Client policy was deleted then that policy would also be deleted. Unfortunately this could lead to an escalation of privileges if the policy was used in an aggregate policy. To avoid privilege escalation the effect policies are no longer deleted and an administrator will need to update those policies.
= Keycloak CR cache-config-file option
The Keycloak CR now allows for specifying the `cache-config-file` option via the `cache` spec `configMapFile` field, for example:
----
apiVersion: k8s.keycloak.org/v2alpha1
kind: Keycloak
metadata:
name: example-kc
spec:
...
cache:
configMapFile:
name: my-configmap
key: config.xml
----
= Updates to cookies
Cookie handling code has been refactored and improved, including a new Cookie Provider. This provides better consistency

View File

@ -270,6 +270,12 @@ realm password policy.
* `Hashing Algorithm: pbkdf2-sha256`
* `Hashing Iterations: 27500`
= Operator Referenced Resource Polling
Secrets and ConfigMaps referenced via the Keycloak CR will now be polled for changes, rather than watched via the api server. It may take around 1 minute for changes to be detected.
This was done so to not require label manipulation on those resources. After upgrading if any Secret still has the operator.keycloak.org/component label, it may be removed or ignored.
= Renaming JPA provider configuration options for migration
After removal of the Map Store the following configuration options were renamed:

View File

@ -103,10 +103,11 @@ spec:
Secret References are used by some dedicated options in the Keycloak CR, such as `tlsSecret`, or as a value in `additionalOptions`.
When specifying a Secret Reference, make sure that a Secret containing the referenced keys is present in the same namespace as the CR referencing it.
Along with the {project_name} Server Deployment, the Operator adds special labels to the referenced Secrets to watch for changes.
Similarly ConfigMap References are used by options such as the `configMapFile`.
When a referenced Secret is modified, the Operator performs a rolling restart of the {project_name} Deployment to pick up the changes.
When specifying a Secret or ConfigMap Reference, make sure that a Secret or ConfigMap containing the referenced keys is present in the same namespace as the CR referencing it.
The operator will poll approximately every minute for changes to referenced Secrets or ConfigMaps. When a meaningful change is detected, the Operator performs a rolling restart of the {project_name} Deployment to pick up the changes.
=== Unsupported features

View File

@ -32,6 +32,7 @@ public interface Config {
String image();
String imagePullPolicy();
boolean startOptimized();
int pollIntervalSeconds();
Map<String, String> podLabels();
}

View File

@ -35,10 +35,6 @@ public final class Constants {
public static final String MANAGED_BY_LABEL = "app.kubernetes.io/managed-by";
public static final String MANAGED_BY_VALUE = "keycloak-operator";
public static final String COMPONENT_LABEL = "app.kubernetes.io/component";
public static final String KEYCLOAK_COMPONENT_LABEL = "operator.keycloak.org/component";
public static final String KEYCLOAK_WATCHED_SECRET_HASH_ANNOTATION = "operator.keycloak.org/watched-secret-hash";
public static final String KEYCLOAK_WATCHING_ANNOTATION = "operator.keycloak.org/watching-secrets";
public static final String KEYCLOAK_MISSING_SECRETS_ANNOTATION = "operator.keycloak.org/missing-secrets";
public static final String KEYCLOAK_MIGRATING_ANNOTATION = "operator.keycloak.org/migrating";
public static final String DEFAULT_LABELS_AS_STRING = "app=keycloak,app.kubernetes.io/managed-by=keycloak-operator";
@ -70,6 +66,7 @@ public final class Constants {
public static final String CERTIFICATES_FOLDER = "/mnt/certificates";
public static final String TRUSTSTORES_FOLDER = "/opt/keycloak/conf/truststores";
public static final String CACHE_CONFIG_FOLDER = "/opt/keycloak/conf/cache";
public static String KEYCLOAK_HTTP_RELATIVE_PATH_KEY = "http-relative-path";
public static final String KEYCLOAK_HTTP_RELATIVE_PATH_KEY = "http-relative-path";
}

View File

@ -74,7 +74,7 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
Config config;
@Inject
WatchedSecrets watchedSecrets;
WatchedResources watchedResources;
@Inject
KeycloakDistConfigurator distConfigurator;
@ -94,7 +94,6 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
Map<String, EventSource> sources = new HashMap<>();
sources.put("serviceSource", servicesEvent);
sources.putAll(EventSourceInitializer.nameEventSources(watchedSecrets.getWatchedSecretsEventSource()));
return sources;
}
@ -103,7 +102,7 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
String kcName = kc.getMetadata().getName();
String namespace = kc.getMetadata().getNamespace();
Log.infof("--- Reconciling Keycloak: %s in namespace: %s", kcName, namespace);
Log.debugf("--- Reconciling Keycloak: %s in namespace: %s", kcName, namespace);
boolean modifiedSpec = false;
if (kc.getSpec().getInstances() == null) {
@ -132,7 +131,7 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
updateStatus(kc, context.getSecondaryResource(StatefulSet.class).orElse(null), statusAggregator, context);
var status = statusAggregator.build();
Log.info("--- Reconciliation finished successfully");
Log.debug("--- Reconciliation finished successfully");
UpdateControl<Keycloak> updateControl;
if (status.equals(kc.getStatus())) {
@ -143,10 +142,12 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
updateControl = UpdateControl.updateStatus(kc);
}
if (!status.isReady() || context.getSecondaryResource(StatefulSet.class)
.map(s -> s.getMetadata().getAnnotations().get(Constants.KEYCLOAK_MISSING_SECRETS_ANNOTATION))
.filter(Boolean::valueOf).isPresent()) {
var statefulSet = context.getSecondaryResource(StatefulSet.class);
if (!status.isReady() || statefulSet.filter(watchedResources::hasMissing).isPresent()) {
updateControl.rescheduleAfter(10, TimeUnit.SECONDS);
} else if (statefulSet.filter(watchedResources::isWatching).isPresent()) {
updateControl.rescheduleAfter(config.keycloak().pollIntervalSeconds(), TimeUnit.SECONDS);
}
return updateControl;

View File

@ -16,6 +16,8 @@
*/
package org.keycloak.operator.controllers;
import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.Container;
import io.fabric8.kubernetes.api.model.EnvVar;
import io.fabric8.kubernetes.api.model.EnvVarBuilder;
import io.fabric8.kubernetes.api.model.EnvVarSource;
@ -24,6 +26,7 @@ import io.fabric8.kubernetes.api.model.PodSpec;
import io.fabric8.kubernetes.api.model.PodSpecFluent.ContainersNested;
import io.fabric8.kubernetes.api.model.PodTemplateSpec;
import io.fabric8.kubernetes.api.model.PodTemplateSpecFluent.SpecNested;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.SecretKeySelector;
import io.fabric8.kubernetes.api.model.VolumeBuilder;
import io.fabric8.kubernetes.api.model.VolumeMountBuilder;
@ -42,6 +45,7 @@ import org.keycloak.operator.Utils;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.ValueOrSecret;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.CacheSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.Truststore;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.TruststoreSource;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.UnsupportedSpec;
@ -67,6 +71,8 @@ import static org.keycloak.operator.crds.v2alpha1.CRDUtils.isTlsConfigured;
@KubernetesDependent(labelSelector = Constants.DEFAULT_LABELS_AS_STRING)
public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependentResource<StatefulSet, Keycloak> {
public static final String CACHE_CONFIG_FILE_MOUNT_NAME = "cache-config-file-configmap";
public static final String KC_TRUSTSTORE_PATHS = "KC_TRUSTSTORE_PATHS";
static final String JGROUPS_DNS_QUERY_PARAM = "-Djgroups.dns.query=";
@ -77,7 +83,7 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent
Config operatorConfig;
@Inject
WatchedSecrets watchedSecrets;
WatchedResources watchedResources;
@Inject
KeycloakDistConfigurator distConfigurator;
@ -93,19 +99,22 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent
if (isTlsConfigured(primary)) {
configureTLS(primary, baseDeployment, allSecrets);
}
addTruststores(primary, baseDeployment, allSecrets);
Container kcContainer = baseDeployment.getSpec().getTemplate().getSpec().getContainers().get(0);
addTruststores(primary, baseDeployment, kcContainer, allSecrets);
addEnvVars(baseDeployment, primary, allSecrets);
Optional.ofNullable(primary.getSpec().getCacheSpec())
.ifPresent(c -> configureCache(primary, baseDeployment, kcContainer, c));
if (!allSecrets.isEmpty()) {
watchedSecrets.annotateDeployment(new ArrayList<>(allSecrets), primary, baseDeployment);
watchedResources.annotateDeployment(new ArrayList<>(allSecrets), Secret.class, baseDeployment, this.client);
}
StatefulSet existingDeployment = context.getSecondaryResource(StatefulSet.class).orElse(null);
if (existingDeployment == null) {
Log.info("No existing Deployment found, using the default");
Log.debug("No existing Deployment found, using the default");
}
else {
Log.info("Existing Deployment found, handling migration");
Log.debug("Existing Deployment found, handling migration");
// version 22 changed the match labels, account for older versions
if (!existingDeployment.isMarkedForDeletion() && !hasExpectedMatchLabels(existingDeployment, primary)) {
@ -119,8 +128,34 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent
return baseDeployment;
}
private void addTruststores(Keycloak keycloakCR, StatefulSet deployment, TreeSet<String> allSecrets) {
var kcContainer = deployment.getSpec().getTemplate().getSpec().getContainers().get(0);
private void configureCache(Keycloak keycloakCR, StatefulSet deployment, Container kcContainer, CacheSpec spec) {
Optional.ofNullable(spec.getConfigMapFile()).ifPresent(configFile -> {
if (configFile.getName() == null || configFile.getKey() == null) {
throw new IllegalStateException("Cache file ConfigMap requires both a name and a key");
}
var volume = new VolumeBuilder()
.withName(CACHE_CONFIG_FILE_MOUNT_NAME)
.withNewConfigMap()
.withName(configFile.getName())
.withOptional(configFile.getOptional())
.endConfigMap()
.build();
var volumeMount = new VolumeMountBuilder()
.withName(volume.getName())
.withMountPath(Constants.CACHE_CONFIG_FOLDER)
.build();
deployment.getSpec().getTemplate().getSpec().getVolumes().add(0, volume);
kcContainer.getVolumeMounts().add(0, volumeMount);
// currently the only configmap we're watching
watchedResources.annotateDeployment(List.of(configFile.getName()), ConfigMap.class, deployment, this.client);
});
}
private void addTruststores(Keycloak keycloakCR, StatefulSet deployment, Container kcContainer, TreeSet<String> allSecrets) {
for (Truststore truststore : keycloakCR.getSpec().getTruststores().values()) {
// for now we'll assume only secrets, later we can support configmaps
TruststoreSource source = truststore.getSecret();
@ -165,18 +200,6 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent
allSecrets.add(keycloakCR.getSpec().getHttpSpec().getTlsSecret());
}
@Override
protected void onCreated(Keycloak primary, StatefulSet created, Context<Keycloak> context) {
watchedSecrets.addLabelsToWatchedSecrets(created);
super.onCreated(primary, created, context);
}
@Override
protected void onUpdated(Keycloak primary, StatefulSet updated, StatefulSet actual, Context<Keycloak> context) {
watchedSecrets.addLabelsToWatchedSecrets(updated);
super.onUpdated(primary, updated, actual, context);
}
private boolean hasExpectedMatchLabels(StatefulSet statefulSet, Keycloak keycloak) {
return Optional.ofNullable(statefulSet).map(s -> Utils.allInstanceLabels(keycloak).equals(s.getSpec().getSelector().getMatchLabels())).orElse(true);
}
@ -339,7 +362,7 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent
.map(EnvVarSource::getSecretKeyRef).filter(Objects::nonNull).map(SecretKeySelector::getName)
.filter(n -> !n.equals(adminSecretName)).collect(Collectors.toCollection(TreeSet::new));
Log.infof("Found config secrets names: %s", serverConfigSecretsNames);
Log.debugf("Found config secrets names: %s", serverConfigSecretsNames);
allSecrets.addAll(serverConfigSecretsNames);
}

View File

@ -40,6 +40,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
@ -66,6 +67,7 @@ public class KeycloakDistConfigurator {
configureTransactions();
configureHttp();
configureDatabase();
configureCache();
}
/**
@ -108,6 +110,11 @@ public class KeycloakDistConfigurator {
.mapOption("https-certificate-key-file", http -> (http.getTlsSecret() != null && !http.getTlsSecret().isEmpty()) ? Constants.CERTIFICATES_FOLDER + "/tls.key" : null);
}
void configureCache() {
optionMapper(keycloakCR -> keycloakCR.getSpec().getCacheSpec())
.mapOption("cache-config-file", cache -> Optional.ofNullable(cache.getConfigMapFile()).map(c -> Constants.CACHE_CONFIG_FOLDER + "/" + c.getKey()).orElse(null));
}
void configureDatabase() {
optionMapper(keycloakCR -> keycloakCR.getSpec().getDatabaseSpec())
.mapOption("db", DatabaseSpec::getVendor)

View File

@ -0,0 +1,112 @@
/*
* Copyright 2022 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.operator.controllers;
import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.utils.Serialization;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class WatchedResources {
public static final String KEYCLOAK_WATCHED_HASH_ANNOTATION_PREFIX = "operator.keycloak.org/watched-";
public static final String KEYCLOAK_WATCHING_ANNOTATION_PREFIX = "operator.keycloak.org/watching-";
public static final String KEYCLOAK_MISSING_ANNOTATION_PREFIX = "operator.keycloak.org/missing-";
/**
* @param deployment mutable resource being reconciled, it will be updated with
* annotations
*/
public <T extends HasMetadata> void annotateDeployment(List<String> names, Class<T> type, StatefulSet deployment,
KubernetesClient client) {
List<T> current = fetch(names, type, deployment.getMetadata().getNamespace(), client);
String plural = HasMetadata.getPlural(type);
deployment.getMetadata().getAnnotations().put(WatchedResources.KEYCLOAK_MISSING_ANNOTATION_PREFIX + plural,
Boolean.valueOf(current.size() < names.size()).toString());
deployment.getMetadata().getAnnotations().put(WatchedResources.KEYCLOAK_WATCHING_ANNOTATION_PREFIX + plural,
names.stream().collect(Collectors.joining(";")));
deployment.getSpec().getTemplate().getMetadata().getAnnotations()
.put(WatchedResources.KEYCLOAK_WATCHED_HASH_ANNOTATION_PREFIX + HasMetadata.getKind(type).toLowerCase() + "-hash", getHash(current));
}
static Object getData(Object object) {
if (object instanceof Secret) {
return ((Secret) object).getData();
}
if (object instanceof ConfigMap) {
return ((ConfigMap) object).getData();
}
return object;
}
public boolean hasMissing(StatefulSet deployment) {
return deployment.getMetadata().getAnnotations().entrySet().stream()
.anyMatch(e -> e.getKey().startsWith(WatchedResources.KEYCLOAK_MISSING_ANNOTATION_PREFIX)
&& Boolean.valueOf(e.getValue()));
}
public boolean isWatching(StatefulSet deployment) {
return deployment.getMetadata().getAnnotations().entrySet().stream()
.anyMatch(e -> e.getKey().startsWith(WatchedResources.KEYCLOAK_WATCHING_ANNOTATION_PREFIX)
&& e.getValue() != null && !e.getValue().isEmpty());
}
public <T extends HasMetadata> String getHash(List<T> current) {
try {
// using hashes as it's more robust than resource versions that can change e.g.
// just when adding a label
// Uses a fips compliant hash
var messageDigest = MessageDigest.getInstance("SHA-256");
current.stream().map(s -> Serialization.asYaml(getData(s)).getBytes(StandardCharsets.UTF_8))
.forEachOrdered(s -> messageDigest.update(s));
return new BigInteger(1, messageDigest.digest()).toString(16);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
private <T extends HasMetadata> List<T> fetch(List<String> names, Class<T> type, String namespace,
KubernetesClient client) {
return names.stream().map(n -> client.resources(type).inNamespace(namespace).withName(n).get())
.filter(Objects::nonNull).collect(Collectors.toList());
}
public List<String> getNames(StatefulSet deployment, Class<? extends HasMetadata> type) {
return Optional
.ofNullable(deployment.getMetadata().getAnnotations().get(WatchedResources.KEYCLOAK_WATCHING_ANNOTATION_PREFIX + HasMetadata.getPlural(type)))
.filter(watching -> !watching.isEmpty())
.map(watching -> watching.split(";")).map(Arrays::asList).orElse(List.of());
}
}

View File

@ -1,44 +0,0 @@
/*
* Copyright 2022 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.operator.controllers;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.javaoperatorsdk.operator.processing.event.source.EventSource;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import java.util.List;
/**
* Provides a mechanism to track secrets
*
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public interface WatchedSecrets {
public static final String WATCHED_SECRETS_LABEL_VALUE = "watched-secret";
/**
* @param deployment mutable resource being reconciled, it will be updated with annotations
*/
void annotateDeployment(List<String> desiredWatchedSecretsNames, Keycloak keycloakCR, StatefulSet deployment);
EventSource getWatchedSecretsEventSource();
void addLabelsToWatchedSecrets(StatefulSet deployment);
}

View File

@ -1,156 +0,0 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.operator.controllers;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.SecretBuilder;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.utils.Serialization;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext;
import io.javaoperatorsdk.operator.api.reconciler.EventSourceInitializer;
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
import io.javaoperatorsdk.operator.processing.event.ResourceID;
import io.javaoperatorsdk.operator.processing.event.source.EventSource;
import io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache;
import io.javaoperatorsdk.operator.processing.event.source.inbound.SimpleInboundEventSource;
import io.quarkus.logging.Log;
import org.keycloak.operator.Constants;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
@ControllerConfiguration(labelSelector = Constants.KEYCLOAK_COMPONENT_LABEL + "=" + WatchedSecrets.WATCHED_SECRETS_LABEL_VALUE)
public class WatchedSecretsController implements Reconciler<Secret>, EventSourceInitializer<Secret>, WatchedSecrets {
private volatile KubernetesClient client;
private final SimpleInboundEventSource eventSource = new SimpleInboundEventSource();
private volatile IndexerResourceCache<Secret> secrets;
@Override
public Map<String, EventSource> prepareEventSources(EventSourceContext<Secret> context) {
this.secrets = context.getPrimaryCache();
this.client = context.getClient();
return Map.of();
}
@Override
public UpdateControl<Secret> reconcile(Secret resource, Context<Secret> context) throws Exception {
// find all statefulsets to notify
// - this could detect whether the reconciliation is even necessary if we track individual hashes
var ret = client.apps().statefulSets().inNamespace(resource.getMetadata().getNamespace())
.withLabels(Constants.DEFAULT_LABELS).list().getItems().stream()
.filter(statefulSet -> getSecretNames(statefulSet).contains(resource.getMetadata().getName()))
.map(statefulSet -> new ResourceID(statefulSet.getMetadata().getName(),
resource.getMetadata().getNamespace()))
.collect(Collectors.toSet());
if (ret.isEmpty()) {
Log.infof("Removing label from Secret \"%s\"", resource.getMetadata().getName());
return UpdateControl.updateResource(new SecretBuilder(resource)
.editMetadata()
.removeFromLabels(Constants.KEYCLOAK_COMPONENT_LABEL)
.endMetadata()
.build());
} else {
ret.forEach(eventSource::propagateEvent);
}
return UpdateControl.noUpdate();
}
@Override
public EventSource getWatchedSecretsEventSource() {
return eventSource;
}
@Override
public void annotateDeployment(List<String> desiredWatchedSecretsNames, Keycloak keycloakCR, StatefulSet deployment) {
List<Secret> currentSecrets = fetchSecrets(desiredWatchedSecretsNames, keycloakCR.getMetadata().getNamespace());
deployment.getMetadata().getAnnotations().put(Constants.KEYCLOAK_MISSING_SECRETS_ANNOTATION,
Boolean.valueOf(currentSecrets.size() < desiredWatchedSecretsNames.size()).toString());
deployment.getMetadata().getAnnotations().put(Constants.KEYCLOAK_WATCHING_ANNOTATION, desiredWatchedSecretsNames.stream().collect(Collectors.joining(";")));
deployment.getSpec().getTemplate().getMetadata().getAnnotations().put(Constants.KEYCLOAK_WATCHED_SECRET_HASH_ANNOTATION, getSecretHash(currentSecrets));
}
private List<Secret> fetchSecrets(List<String> secretsNames, String namespace) {
return secretsNames.stream()
.map(n -> Optional.ofNullable(secrets).flatMap(cache -> cache.get(new ResourceID(n, namespace)))
.orElseGet(() -> client.secrets().inNamespace(namespace).withName(n).get()))
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
public String getSecretHash(List<Secret> currentSecrets) {
try {
// using hashes as it's more robust than resource versions that can change e.g. just when adding a label
// Uses a fips compliant hash
var messageDigest = MessageDigest.getInstance("SHA-256");
currentSecrets.stream()
.map(s -> Serialization.asYaml(s.getData()).getBytes(StandardCharsets.UTF_8))
.forEachOrdered(s -> messageDigest.update(s));
return new BigInteger(1, messageDigest.digest()).toString(16);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
@Override
public void addLabelsToWatchedSecrets(StatefulSet deployment) {
for (Secret secret : fetchSecrets(getSecretNames(deployment), deployment.getMetadata().getNamespace())) {
if (!secret.getMetadata().getLabels().containsKey(Constants.KEYCLOAK_COMPONENT_LABEL)) {
Log.infof("Adding label to Secret \"%s\"", secret.getMetadata().getName());
client.resource(secret).accept(s -> {
s.getMetadata().getLabels().put(Constants.KEYCLOAK_COMPONENT_LABEL, WatchedSecrets.WATCHED_SECRETS_LABEL_VALUE);
s.getMetadata().setResourceVersion(null);
});
}
}
}
public List<String> getSecretNames(StatefulSet deployment) {
return Optional
.ofNullable(deployment.getMetadata().getAnnotations().get(Constants.KEYCLOAK_WATCHING_ANNOTATION))
.filter(watching -> !watching.isEmpty())
.map(watching -> watching.split(";")).map(Arrays::asList).orElse(List.of());
}
}

View File

@ -19,6 +19,7 @@ package org.keycloak.operator.crds.v2alpha1.deployment;
import io.fabric8.kubernetes.api.model.LocalObjectReference;
import io.fabric8.kubernetes.model.annotation.SpecReplicas;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.CacheSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.DatabaseSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.FeatureSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HostnameSpec;
@ -90,6 +91,10 @@ public class KeycloakSpec {
@JsonPropertyDescription("In this section you can configure Keycloak truststores.")
private Map<String, Truststore> truststores = new LinkedHashMap<>();
@JsonProperty("cache")
@JsonPropertyDescription("In this section you can configure Keycloak's cache")
private CacheSpec cacheSpec;
public HttpSpec getHttpSpec() {
return httpSpec;
}
@ -200,4 +205,12 @@ public class KeycloakSpec {
this.truststores = truststores;
}
public CacheSpec getCacheSpec() {
return cacheSpec;
}
public void setCacheSpec(CacheSpec cache) {
this.cacheSpec = cache;
}
}

View File

@ -0,0 +1,39 @@
/*
* Copyright 2022 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.operator.crds.v2alpha1.deployment.spec;
import io.fabric8.kubernetes.api.model.ConfigMapKeySelector;
import io.sundr.builder.annotations.Buildable;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder")
public class CacheSpec {
private ConfigMapKeySelector configMapFile;
public ConfigMapKeySelector getConfigMapFile() {
return configMapFile;
}
public void setConfigMapFile(ConfigMapKeySelector configMapFile) {
this.configMapFile = configMapFile;
}
}

View File

@ -8,6 +8,7 @@ quarkus.banner.enabled=false
operator.keycloak.image=${RELATED_IMAGE_KEYCLOAK:quay.io/keycloak/keycloak:nightly}
operator.keycloak.image-pull-policy=Always
operator.keycloak.start-optimized=false
operator.keycloak.poll-interval-seconds=60
# https://quarkus.io/guides/deploying-to-kubernetes#environment-variables-from-keyvalue-pairs
quarkus.kubernetes.env.vars.related-image-keycloak=${operator.keycloak.image}

View File

@ -44,6 +44,7 @@ import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakStatusCondition;
import org.keycloak.operator.crds.v2alpha1.deployment.ValueOrSecret;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HostnameSpecBuilder;
import org.keycloak.operator.testsuite.unit.WatchedResourcesTest;
import org.keycloak.operator.testsuite.utils.CRAssert;
import org.keycloak.operator.testsuite.utils.K8sUtils;
@ -251,7 +252,7 @@ public class KeycloakDeploymentTest extends BaseOperatorTest {
// managed changes
deployment.getSpec().getTemplate().getSpec().getContainers().get(0).setEnv(List.of(flandersEnvVar));
String originalAnnotationValue = deployment.getMetadata().getAnnotations().put(Constants.KEYCLOAK_WATCHING_ANNOTATION, "not-right");
String originalAnnotationValue = deployment.getMetadata().getAnnotations().put(WatchedResourcesTest.KEYCLOAK_WATCHING_ANNOTATION, "not-right");
deployment.getMetadata().setResourceVersion(null);
k8sclient.resource(deployment).update();
@ -266,7 +267,7 @@ public class KeycloakDeploymentTest extends BaseOperatorTest {
assertThat(d.getMetadata().getLabels().entrySet().containsAll(labels.entrySet())).isTrue();
// managed changes should get reverted
assertThat(d.getSpec()).isEqualTo(expectedSpec); // specs should be reconciled expected merged state
assertThat(d.getMetadata().getAnnotations().get(Constants.KEYCLOAK_WATCHING_ANNOTATION)).isEqualTo(originalAnnotationValue);
assertThat(d.getMetadata().getAnnotations().get(WatchedResourcesTest.KEYCLOAK_WATCHING_ANNOTATION)).isEqualTo(originalAnnotationValue);
});
}

View File

@ -24,8 +24,8 @@ import io.quarkus.test.junit.QuarkusTest;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.Test;
import org.keycloak.operator.Constants;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.TruststoreBuilder;
import org.keycloak.operator.testsuite.unit.WatchedResourcesTest;
import org.keycloak.operator.testsuite.utils.K8sUtils;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ -46,8 +46,8 @@ public class KeycloakTruststoresTests extends BaseOperatorTest {
Awaitility.await().ignoreExceptions().untilAsserted(() -> {
StatefulSet statefulSet = stsResource.get();
assertEquals("true",
statefulSet.getMetadata().getAnnotations().get(Constants.KEYCLOAK_MISSING_SECRETS_ANNOTATION));
assertTrue(statefulSet.getMetadata().getAnnotations().get(Constants.KEYCLOAK_WATCHING_ANNOTATION)
statefulSet.getMetadata().getAnnotations().get(WatchedResourcesTest.KEYCLOAK_MISSING_SECRETS_ANNOTATION));
assertTrue(statefulSet.getMetadata().getAnnotations().get(WatchedResourcesTest.KEYCLOAK_WATCHING_ANNOTATION)
.contains("xyz"));
});
}
@ -64,7 +64,7 @@ public class KeycloakTruststoresTests extends BaseOperatorTest {
Resource<StatefulSet> stsResource = k8sclient.resources(StatefulSet.class).withName(deploymentName);
StatefulSet statefulSet = stsResource.get();
assertEquals("false",
statefulSet.getMetadata().getAnnotations().get(Constants.KEYCLOAK_MISSING_SECRETS_ANNOTATION));
statefulSet.getMetadata().getAnnotations().get(WatchedResourcesTest.KEYCLOAK_MISSING_SECRETS_ANNOTATION));
assertTrue(statefulSet.getSpec().getTemplate().getSpec().getContainers().get(0).getVolumeMounts().stream()
.anyMatch(v -> v.getMountPath()
.equals("/opt/keycloak/conf/truststores/secret-example-truststore-secret")));

View File

@ -26,11 +26,10 @@ import io.quarkus.test.junit.QuarkusTest;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.keycloak.operator.Constants;
import org.keycloak.operator.controllers.WatchedSecrets;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.ValueOrSecret;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HostnameSpecBuilder;
import org.keycloak.operator.testsuite.unit.WatchedResourcesTest;
import java.util.Base64;
import java.util.HashSet;
@ -41,6 +40,7 @@ import java.util.function.Function;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.keycloak.operator.testsuite.utils.K8sUtils.deployKeycloak;
/**
@ -56,9 +56,6 @@ public class WatchedSecretsTest extends BaseOperatorTest {
Secret dbSecret = getDbSecret();
Secret tlsSecret = getTlsSecret();
assertThat(dbSecret.getMetadata().getLabels()).containsEntry(Constants.KEYCLOAK_COMPONENT_LABEL, WatchedSecrets.WATCHED_SECRETS_LABEL_VALUE);
assertThat(tlsSecret.getMetadata().getLabels()).containsEntry(Constants.KEYCLOAK_COMPONENT_LABEL, WatchedSecrets.WATCHED_SECRETS_LABEL_VALUE);
Log.info("Updating DB Secret, expecting restart");
testDeploymentRestarted(Set.of(kc), Set.of(), () -> {
dbSecret.getData().put(UUID.randomUUID().toString(), "YmxhaGJsYWg=");
@ -105,22 +102,19 @@ public class WatchedSecretsTest extends BaseOperatorTest {
var kc = getTestKeycloakDeployment(false);
deployKeycloak(k8sclient, kc, true);
Secret dbSecret = getDbSecret();
Log.info("Updating KC to not to rely on DB Secret");
hardcodeDBCredsInCR(kc);
testDeploymentRestarted(Set.of(kc), Set.of(), () -> {
deployKeycloak(k8sclient, kc, false, false);
});
Log.info("Updating DB Secret to trigger clean-up process");
testDeploymentRestarted(Set.of(), Set.of(kc), () -> {
var dbSecret = getDbSecret();
dbSecret.getMetadata().getLabels().put(UUID.randomUUID().toString(), "YmxhaGJsYWg");
k8sclient.resource(dbSecret).update();
});
Awaitility.await().untilAsserted(() -> {
Log.info("Checking labels on DB Secret");
assertThat(getDbSecret().getMetadata().getLabels()).doesNotContainKey(Constants.KEYCLOAK_COMPONENT_LABEL);
Log.info("Checking StatefulSet annotations");
assertFalse(k8sclient.resources(StatefulSet.class).withName(kc.getMetadata().getName()).get().getMetadata()
.getAnnotations().get(WatchedResourcesTest.KEYCLOAK_WATCHING_ANNOTATION)
.contains(dbSecret.getMetadata().getName()));
});
}
@ -223,4 +217,4 @@ public class WatchedSecretsTest extends BaseOperatorTest {
public void restoreDBSecret() {
deployDBSecret();
}
}
}

View File

@ -64,6 +64,11 @@ public class KeycloakDistConfiguratorTest {
testFirstClassCitizen(Map.of("transaction-xa-enabled", "false"));
}
@Test
public void cache() {
testFirstClassCitizen(Map.of("cache-config-file", "/opt/keycloak/conf/cache/file.xml"));
}
@Test
public void http() {
final Map<String, String> expectedValues = Map.of(

View File

@ -23,6 +23,8 @@ import io.fabric8.kubernetes.api.model.IntOrString;
import io.fabric8.kubernetes.api.model.PodTemplateSpec;
import io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder;
import io.fabric8.kubernetes.api.model.ProbeBuilder;
import io.fabric8.kubernetes.api.model.Volume;
import io.fabric8.kubernetes.api.model.VolumeMount;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder;
import io.javaoperatorsdk.operator.api.reconciler.Context;
@ -31,9 +33,10 @@ import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.keycloak.operator.Constants;
import org.keycloak.operator.Utils;
import org.keycloak.operator.controllers.KeycloakDeploymentDependentResource;
import org.keycloak.operator.controllers.WatchedSecretsController;
import org.keycloak.operator.controllers.WatchedResources;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakBuilder;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakSpecBuilder;
@ -58,7 +61,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
public class PodTemplateTest {
@InjectMock
WatchedSecretsController watchedSecrets;
WatchedResources watchedResources;
@Inject
KeycloakDeploymentDependentResource deployment;
@ -419,4 +422,26 @@ public class PodTemplateTest {
assertThat(podTemplate.getSpec().getContainers().get(0).getCommand().contains(KeycloakDeploymentDependentResource.OPTIMIZED_ARG));
}
@Test
public void testCacheConfigFileMount() {
// Arrange
PodTemplateSpec additionalPodTemplate = null;
// Act
var podTemplate = getDeployment(additionalPodTemplate, null,
s -> s.withNewCacheSpec().withNewConfigMapFile("file.xml", "cm", null).endCacheSpec())
.getSpec().getTemplate();
// Assert
VolumeMount volumeMount = podTemplate.getSpec().getContainers().get(0).getVolumeMounts().stream()
.filter(vm -> vm.getName().equals(KeycloakDeploymentDependentResource.CACHE_CONFIG_FILE_MOUNT_NAME))
.findFirst().orElseThrow();
assertThat(volumeMount.getMountPath()).isEqualTo(Constants.CACHE_CONFIG_FOLDER);
Volume volume = podTemplate.getSpec().getVolumes().stream()
.filter(v -> v.getName().equals(KeycloakDeploymentDependentResource.CACHE_CONFIG_FILE_MOUNT_NAME))
.findFirst().orElseThrow();
assertThat(volume.getConfigMap().getName()).isEqualTo("cm");
}
}

View File

@ -17,14 +17,14 @@
package org.keycloak.operator.testsuite.unit;
import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.SecretBuilder;
import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import org.keycloak.operator.Constants;
import org.keycloak.operator.controllers.WatchedSecretsController;
import org.keycloak.operator.controllers.WatchedResources;
import java.util.Arrays;
import java.util.List;
@ -36,22 +36,29 @@ import jakarta.inject.Inject;
import static org.junit.jupiter.api.Assertions.assertEquals;
@QuarkusTest
public class WatchedSecretsControllerTest {
public class WatchedResourcesTest {
public static final String KEYCLOAK_WATCHING_ANNOTATION = "operator.keycloak.org/watching-secrets";
public static final String KEYCLOAK_MISSING_SECRETS_ANNOTATION = "operator.keycloak.org/missing-secrets";
@Inject
WatchedSecretsController watchedSecretsController;
WatchedResources watchedResources;
@Test
public void testSecretHashing() {
assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", watchedSecretsController.getSecretHash(List.of()));
assertEquals("b5655bfe4d4e130f5023a76a5de0906cf84eb5895bda5d44642673f9eb4024bf", watchedSecretsController.getSecretHash(List.of(newSecret(Map.of("a", "b")), newSecret(Map.of("c", "d")))));
public void testHashing() {
assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", watchedResources.getHash(List.of()));
assertEquals("b5655bfe4d4e130f5023a76a5de0906cf84eb5895bda5d44642673f9eb4024bf", watchedResources.getHash(List.of(newSecret(Map.of("a", "b")), newSecret(Map.of("c", "d")))));
assertEquals("d526224334e65c71095be909b2d14c52f1589abb84a3c76fbe79dd75d7132fbb",
watchedResources.getHash(List.of(new ConfigMapBuilder().withNewMetadata().withName("x")
.withAnnotations(Map.of(UUID.randomUUID().toString(), UUID.randomUUID().toString()))
.endMetadata().withData(Map.of("a", "b")).build())));
}
@Test
public void testGetSecretNames() {
assertEquals(List.of(), watchedSecretsController.getSecretNames(new StatefulSetBuilder().withNewMetadata().addToAnnotations(Constants.KEYCLOAK_WATCHING_ANNOTATION, "").endMetadata().build()));
assertEquals(Arrays.asList("something"), watchedSecretsController.getSecretNames(new StatefulSetBuilder().withNewMetadata().addToAnnotations(Constants.KEYCLOAK_WATCHING_ANNOTATION, "something").endMetadata().build()));
assertEquals(Arrays.asList("x", "y"), watchedSecretsController.getSecretNames(new StatefulSetBuilder().withNewMetadata().addToAnnotations(Constants.KEYCLOAK_WATCHING_ANNOTATION, "x;y").endMetadata().build()));
assertEquals(List.of(), watchedResources.getNames(new StatefulSetBuilder().withNewMetadata().addToAnnotations(WatchedResourcesTest.KEYCLOAK_WATCHING_ANNOTATION, "").endMetadata().build(), Secret.class));
assertEquals(Arrays.asList("something"), watchedResources.getNames(new StatefulSetBuilder().withNewMetadata().addToAnnotations(WatchedResourcesTest.KEYCLOAK_WATCHING_ANNOTATION, "something").endMetadata().build(), Secret.class));
assertEquals(Arrays.asList("x", "y"), watchedResources.getNames(new StatefulSetBuilder().withNewMetadata().addToAnnotations(WatchedResourcesTest.KEYCLOAK_WATCHING_ANNOTATION, "x;y").endMetadata().build(), Secret.class));
}
private Secret newSecret(Map<String, String> data) {

View File

@ -6,4 +6,6 @@ quarkus.operator-sdk.start-operator=false
quarkus.log.level=INFO
operator.keycloak.pod-labels."test.label"=foobar
operator.keycloak.pod-labels."testLabelWithExpression"=${OPERATOR_TEST_LABEL_EXPRESSION}
operator.keycloak.pod-labels."testLabelWithExpression"=${OPERATOR_TEST_LABEL_EXPRESSION}
# allow the watching tests to complete more quickly
operator.keycloak.poll-interval-seconds=10

View File

@ -43,6 +43,10 @@ spec:
adminUrl: https://www.my-admin-hostname.org:8448/something
strict: true
strictBackchannel: true
cache:
configMapFile:
name: my-config-map
key: file.xml
features:
enabled:
- docker