Converts keycloakdeployment to a dependent resource (#22591)

Closes #22225
This commit is contained in:
Steven Hawkins 2023-10-06 13:52:50 -04:00 committed by GitHub
parent dd37e02140
commit a65af2d254
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 698 additions and 959 deletions

View file

@ -129,6 +129,11 @@
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5-mockito</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>

View file

@ -39,6 +39,7 @@ public final class Constants {
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";

View file

@ -17,6 +17,7 @@
package org.keycloak.operator;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.client.KubernetesClient;
import java.nio.charset.StandardCharsets;
@ -24,6 +25,7 @@ import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
@ -55,4 +57,10 @@ public final class Utils {
.collect(Collectors.joining(","));
}
public static Map<String, String> allInstanceLabels(HasMetadata primary) {
var labels = new LinkedHashMap<>(Constants.DEFAULT_LABELS);
labels.put(Constants.INSTANCE_LABEL, primary.getMetadata().getName());
return labels;
}
}

View file

@ -27,7 +27,7 @@ public class KeycloakAdminSecretDependentResource extends KubernetesDependentRes
return new SecretBuilder()
.withNewMetadata()
.withName(getName(primary))
.addToLabels(OperatorManagedResource.allInstanceLabels(primary))
.addToLabels(Utils.allInstanceLabels(primary))
.withNamespace(primary.getMetadata().getNamespace())
.endMetadata()
.withType("kubernetes.io/basic-auth")

View file

@ -16,10 +16,15 @@
*/
package org.keycloak.operator.controllers;
import io.fabric8.kubernetes.api.model.ContainerState;
import io.fabric8.kubernetes.api.model.ContainerStateWaiting;
import io.fabric8.kubernetes.api.model.PodSpec;
import io.fabric8.kubernetes.api.model.PodStatus;
import io.fabric8.kubernetes.api.model.Service;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.readiness.Readiness;
import io.fabric8.kubernetes.client.utils.KubernetesResourceUtil;
import io.fabric8.kubernetes.client.utils.Serialization;
import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
@ -35,8 +40,10 @@ import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEven
import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers;
import io.quarkus.logging.Log;
import org.keycloak.common.util.CollectionUtil;
import org.keycloak.operator.Config;
import org.keycloak.operator.Constants;
import org.keycloak.operator.Utils;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakStatus;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakStatusAggregator;
@ -44,6 +51,7 @@ import org.keycloak.operator.crds.v2alpha1.deployment.spec.HostnameSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HostnameSpecBuilder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@ -52,6 +60,7 @@ import jakarta.inject.Inject;
@ControllerConfiguration(
dependents = {
@Dependent(type = KeycloakDeploymentDependentResource.class),
@Dependent(type = KeycloakAdminSecretDependentResource.class),
@Dependent(type = KeycloakIngressDependentResource.class, reconcilePrecondition = KeycloakIngressDependentResource.EnabledCondition.class),
@Dependent(type = KeycloakServiceDependentResource.class, useEventSourceWithName = "serviceSource"),
@ -61,42 +70,31 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
public static final String OPENSHIFT_DEFAULT = "openshift-default";
@Inject
KubernetesClient client;
@Inject
Config config;
@Inject
WatchedSecrets watchedSecrets;
@Inject
KeycloakDistConfigurator distConfigurator;
@Override
public Map<String, EventSource> prepareEventSources(EventSourceContext<Keycloak> context) {
var namespaces = context.getControllerConfiguration().getNamespaces();
InformerConfiguration<StatefulSet> statefulSetIC = InformerConfiguration
.from(StatefulSet.class)
.withLabelSelector(Constants.DEFAULT_LABELS_AS_STRING)
.withNamespaces(namespaces)
.withSecondaryToPrimaryMapper(Mappers.fromOwnerReference())
.withOnUpdateFilter(new MetadataAwareOnUpdateFilter<>())
.build();
InformerConfiguration<Service> servicesIC = InformerConfiguration
.from(Service.class)
.withLabelSelector(Constants.DEFAULT_LABELS_AS_STRING)
.withNamespaces(namespaces)
.withSecondaryToPrimaryMapper(Mappers.fromOwnerReference())
.withOnUpdateFilter(new MetadataAwareOnUpdateFilter<>())
.build();
EventSource statefulSetEvent = new InformerEventSource<>(statefulSetIC, context);
EventSource servicesEvent = new InformerEventSource<>(servicesIC, context);
Map<String, EventSource> sources = new HashMap<>();
sources.put("serviceSource", servicesEvent);
sources.putAll(EventSourceInitializer.nameEventSources(statefulSetEvent,
watchedSecrets.getWatchedSecretsEventSource()));
sources.putAll(EventSourceInitializer.nameEventSources(watchedSecrets.getWatchedSecretsEventSource()));
return sources;
}
@ -131,11 +129,7 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
var statusAggregator = new KeycloakStatusAggregator(kc.getStatus(), kc.getMetadata().getGeneration());
var kcDeployment = new KeycloakDeployment(context.getClient(), config, kc, context.getSecondaryResource(StatefulSet.class).orElse(null), KeycloakAdminSecretDependentResource.getName(kc));
kcDeployment.setWatchedSecrets(watchedSecrets);
kcDeployment.createOrUpdateReconciled();
kcDeployment.updateStatus(statusAggregator);
updateStatus(kc, context.getSecondaryResource(StatefulSet.class).orElse(null), statusAggregator, context);
var status = statusAggregator.build();
Log.info("--- Reconciliation finished successfully");
@ -181,4 +175,97 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
.withName("cluster").get())
.map(i -> Optional.ofNullable(i.getSpec().getAppsDomain()).orElse(i.getSpec().getDomain()));
}
public void updateStatus(Keycloak keycloakCR, StatefulSet existingDeployment, KeycloakStatusAggregator status, Context<Keycloak> context) {
status.apply(b -> b.withSelector(Utils.toSelectorString(Utils.allInstanceLabels(keycloakCR))));
validatePodTemplate(keycloakCR, status);
if (existingDeployment == null) {
status.addNotReadyMessage("No existing StatefulSet found, waiting for creating a new one");
return;
}
if (existingDeployment.getStatus() == null) {
status.addNotReadyMessage("Waiting for deployment status");
} else {
status.apply(b -> b.withInstances(existingDeployment.getStatus().getReadyReplicas()));
if (Optional.ofNullable(existingDeployment.getStatus().getReadyReplicas()).orElse(0) < keycloakCR.getSpec().getInstances()) {
checkForPodErrors(status, keycloakCR, existingDeployment, context);
status.addNotReadyMessage("Waiting for more replicas");
}
}
if (Optional
.ofNullable(existingDeployment.getMetadata().getAnnotations().get(Constants.KEYCLOAK_MIGRATING_ANNOTATION))
.map(Boolean::valueOf).orElse(false)) {
status.addNotReadyMessage("Performing Keycloak upgrade, scaling down the deployment");
} else if (existingDeployment.getStatus() != null
&& existingDeployment.getStatus().getCurrentRevision() != null
&& existingDeployment.getStatus().getUpdateRevision() != null
&& !existingDeployment.getStatus().getCurrentRevision().equals(existingDeployment.getStatus().getUpdateRevision())) {
status.addRollingUpdateMessage("Rolling out deployment update");
}
distConfigurator.validateOptions(keycloakCR, status);
}
public void validatePodTemplate(Keycloak keycloakCR, KeycloakStatusAggregator status) {
var spec = KeycloakDeploymentDependentResource.getPodTemplateSpec(keycloakCR);
if (spec.isEmpty()) {
return;
}
var overlayTemplate = spec.orElseThrow();
if (overlayTemplate.getMetadata() != null) {
if (overlayTemplate.getMetadata().getName() != null) {
status.addWarningMessage("The name of the podTemplate cannot be modified");
}
if (overlayTemplate.getMetadata().getNamespace() != null) {
status.addWarningMessage("The namespace of the podTemplate cannot be modified");
}
}
Optional.ofNullable(overlayTemplate.getSpec()).map(PodSpec::getContainers).flatMap(l -> l.stream().findFirst())
.ifPresent(container -> {
if (container.getName() != null) {
status.addWarningMessage("The name of the keycloak container cannot be modified");
}
if (container.getImage() != null) {
status.addWarningMessage(
"The image of the keycloak container cannot be modified using podTemplate");
}
});
if (overlayTemplate.getSpec() != null &&
CollectionUtil.isNotEmpty(overlayTemplate.getSpec().getImagePullSecrets())) {
status.addWarningMessage("The imagePullSecrets of the keycloak container cannot be modified using podTemplate");
}
}
private void checkForPodErrors(KeycloakStatusAggregator status, Keycloak keycloak, StatefulSet existingDeployment, Context<Keycloak> context) {
context.getClient().pods().inNamespace(existingDeployment.getMetadata().getNamespace())
.withLabel("controller-revision-hash", existingDeployment.getStatus().getUpdateRevision())
.withLabels(Utils.allInstanceLabels(keycloak))
.list().getItems().stream()
.filter(p -> !Readiness.isPodReady(p)
&& Optional.ofNullable(p.getStatus()).map(PodStatus::getContainerStatuses).isPresent())
.sorted((p1, p2) -> p1.getMetadata().getName().compareTo(p2.getMetadata().getName()))
.forEachOrdered(p -> {
Optional.of(p.getStatus()).map(s -> s.getContainerStatuses()).stream().flatMap(List::stream)
.filter(cs -> !Boolean.TRUE.equals(cs.getReady()))
.sorted((cs1, cs2) -> cs1.getName().compareTo(cs2.getName())).forEachOrdered(cs -> {
if (Optional.ofNullable(cs.getState()).map(ContainerState::getWaiting)
.map(ContainerStateWaiting::getReason).map(String::toLowerCase)
.filter(s -> s.contains("err") || s.equals("crashloopbackoff")).isPresent()) {
Log.infof("Found unhealthy container on pod %s/%s: %s",
p.getMetadata().getNamespace(), p.getMetadata().getName(),
Serialization.asYaml(cs));
status.addErrorMessage(
String.format("Waiting for %s/%s due to %s: %s", p.getMetadata().getNamespace(),
p.getMetadata().getName(), cs.getState().getWaiting().getReason(),
cs.getState().getWaiting().getMessage()));
}
});
});
}
}

View file

@ -1,504 +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.ContainerState;
import io.fabric8.kubernetes.api.model.ContainerStateWaiting;
import io.fabric8.kubernetes.api.model.EnvVar;
import io.fabric8.kubernetes.api.model.EnvVarBuilder;
import io.fabric8.kubernetes.api.model.EnvVarSourceBuilder;
import io.fabric8.kubernetes.api.model.PodSpec;
import io.fabric8.kubernetes.api.model.PodSpecFluent.ContainersNested;
import io.fabric8.kubernetes.api.model.PodStatus;
import io.fabric8.kubernetes.api.model.PodTemplateSpec;
import io.fabric8.kubernetes.api.model.PodTemplateSpecFluent.SpecNested;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder;
import io.fabric8.kubernetes.api.model.apps.StatefulSetSpecFluent.TemplateNested;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.readiness.Readiness;
import io.fabric8.kubernetes.client.utils.Serialization;
import io.quarkus.logging.Log;
import org.keycloak.common.util.CollectionUtil;
import org.keycloak.operator.Config;
import org.keycloak.operator.Constants;
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.KeycloakStatusAggregator;
import org.keycloak.operator.crds.v2alpha1.deployment.ValueOrSecret;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.UnsupportedSpec;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.keycloak.operator.crds.v2alpha1.CRDUtils.isTlsConfigured;
public class KeycloakDeployment extends OperatorManagedResource<StatefulSet> {
public static final String OPTIMIZED_ARG = "--optimized";
private final Config operatorConfig;
private final KeycloakDistConfigurator distConfigurator;
private final Keycloak keycloakCR;
private final StatefulSet existingDeployment;
private final StatefulSet baseDeployment;
private final String adminSecretName;
private Set<String> serverConfigSecretsNames;
private WatchedSecrets watchedSecrets;
private boolean migrationInProgress;
public KeycloakDeployment(KubernetesClient client, Config config, Keycloak keycloakCR, StatefulSet existingDeployment, String adminSecretName) {
super(client, keycloakCR);
this.operatorConfig = config;
this.keycloakCR = keycloakCR;
this.adminSecretName = adminSecretName;
this.existingDeployment = existingDeployment;
this.baseDeployment = createBaseDeployment();
this.distConfigurator = new KeycloakDistConfigurator(keycloakCR, baseDeployment, client);
this.distConfigurator.configureDistOptions();
// after the distConfiguration, we can add the remaining default / additionalConfig
addRemainingEnvVars();
}
public void setWatchedSecrets(WatchedSecrets watchedSecrets) {
this.watchedSecrets = watchedSecrets;
}
@Override
public Optional<StatefulSet> getReconciledResource() {
StatefulSet baseDeployment = new StatefulSetBuilder(this.baseDeployment).build(); // clone not to change the base template
if (existingDeployment == null) {
Log.info("No existing Deployment found, using the default");
}
else {
Log.info("Existing Deployment found, handling migration");
if (!existingDeployment.isMarkedForDeletion() && !hasExpectedMatchLabels(existingDeployment)) {
client.resource(existingDeployment).lockResourceVersion().delete();
Log.info("Existing Deployment found with old label selector, it will be recreated");
}
migrateDeployment(existingDeployment, baseDeployment);
}
var configSecretsNames = getConfigSecretsNames();
if (!configSecretsNames.isEmpty() && watchedSecrets != null) {
watchedSecrets.annotateDeployment(configSecretsNames, keycloakCR, baseDeployment);
}
return Optional.of(baseDeployment);
}
private boolean hasExpectedMatchLabels(StatefulSet statefulSet) {
return Optional.ofNullable(statefulSet).map(s -> getInstanceLabels().equals(s.getSpec().getSelector().getMatchLabels())).orElse(true);
}
@Override
public Optional<StatefulSet> createOrUpdateReconciled() {
var ret = super.createOrUpdateReconciled();
// after the change to the statefulset has been "committed", start watching
if (watchedSecrets != null) {
ret.map(StatefulSet.class::cast).ifPresent(watchedSecrets::addLabelsToWatchedSecrets);
}
return ret;
}
public void validatePodTemplate(KeycloakStatusAggregator status) {
var spec = getPodTemplateSpec();
if (spec.isEmpty()) {
return;
}
var overlayTemplate = spec.orElseThrow();
if (overlayTemplate.getMetadata() != null) {
if (overlayTemplate.getMetadata().getName() != null) {
status.addWarningMessage("The name of the podTemplate cannot be modified");
}
if (overlayTemplate.getMetadata().getNamespace() != null) {
status.addWarningMessage("The namespace of the podTemplate cannot be modified");
}
}
Optional.ofNullable(overlayTemplate.getSpec()).map(PodSpec::getContainers).flatMap(l -> l.stream().findFirst())
.ifPresent(container -> {
if (container.getName() != null) {
status.addWarningMessage("The name of the keycloak container cannot be modified");
}
if (container.getImage() != null) {
status.addWarningMessage(
"The image of the keycloak container cannot be modified using podTemplate");
}
});
if (overlayTemplate.getSpec() != null &&
CollectionUtil.isNotEmpty(overlayTemplate.getSpec().getImagePullSecrets())) {
status.addWarningMessage("The imagePullSecrets of the keycloak container cannot be modified using podTemplate");
}
}
private Optional<PodTemplateSpec> getPodTemplateSpec() {
return Optional.ofNullable(keycloakCR.getSpec()).map(KeycloakSpec::getUnsupported).map(UnsupportedSpec::getPodTemplate);
}
private StatefulSet createBaseDeployment() {
Map<String, String> labels = getInstanceLabels();
if (operatorConfig.keycloak().podLabels() != null) {
labels.putAll(operatorConfig.keycloak().podLabels());
}
/* Create a builder for the statefulset, note that the pod template spec is used as the basis
* over that some values are forced, others will let the template override, others merge
*/
StatefulSetBuilder baseDeploymentBuilder = new StatefulSetBuilder()
.withNewMetadata()
.withName(getName())
.withNamespace(getNamespace())
.endMetadata()
.withNewSpec()
.withNewSelector()
.withMatchLabels(getInstanceLabels())
.endSelector()
.withNewTemplateLike(getPodTemplateSpec().orElseGet(PodTemplateSpec::new))
.editOrNewMetadata().addToLabels(labels).endMetadata()
.editOrNewSpec().withImagePullSecrets(keycloakCR.getSpec().getImagePullSecrets()).endSpec()
.endTemplate()
.withReplicas(keycloakCR.getSpec().getInstances())
.endSpec();
var specBuilder = baseDeploymentBuilder.editSpec().editTemplate().editOrNewSpec();
if (!specBuilder.hasRestartPolicy()) {
specBuilder.withRestartPolicy("Always");
}
if (!specBuilder.hasTerminationGracePeriodSeconds()) {
specBuilder.withTerminationGracePeriodSeconds(30L);
}
if (!specBuilder.hasDnsPolicy()) {
specBuilder.withDnsPolicy("ClusterFirst");
}
// there isn't currently an editOrNewFirstContainer, so we need to do this manually
ContainersNested<SpecNested<TemplateNested<io.fabric8.kubernetes.api.model.apps.StatefulSetFluent.SpecNested<StatefulSetBuilder>>>> containerBuilder = null;
if (specBuilder.buildContainers().isEmpty()) {
containerBuilder = specBuilder.addNewContainer();
} else {
containerBuilder = specBuilder.editFirstContainer();
}
containerBuilder.withName("keycloak");
var customImage = Optional.ofNullable(keycloakCR.getSpec().getImage());
containerBuilder.withImage(customImage.orElse(operatorConfig.keycloak().image()));
if (!containerBuilder.hasImagePullPolicy()) {
containerBuilder.withImagePullPolicy(operatorConfig.keycloak().imagePullPolicy());
}
if (Optional.ofNullable(containerBuilder.getArgs()).orElse(List.of()).isEmpty()) {
containerBuilder.withArgs("--verbose", "start");
}
if (customImage.isPresent()) {
containerBuilder.addToArgs(OPTIMIZED_ARG);
}
// probes
var tlsConfigured = isTlsConfigured(keycloakCR);
var protocol = !tlsConfigured ? "HTTP" : "HTTPS";
var kcPort = KeycloakServiceDependentResource.getServicePort(tlsConfigured, keycloakCR);
// Relative path ends with '/'
var kcRelativePath = Optional.ofNullable(readConfigurationValue(Constants.KEYCLOAK_HTTP_RELATIVE_PATH_KEY))
.map(path -> !path.endsWith("/") ? path + "/" : path)
.orElse("/");
if (!containerBuilder.hasReadinessProbe()) {
containerBuilder.withNewReadinessProbe()
.withPeriodSeconds(10)
.withFailureThreshold(3)
.withNewHttpGet()
.withScheme(protocol)
.withNewPort(kcPort)
.withPath(kcRelativePath + "health/ready")
.endHttpGet()
.endReadinessProbe();
}
if (!containerBuilder.hasLivenessProbe()) {
containerBuilder.withNewLivenessProbe()
.withPeriodSeconds(10)
.withFailureThreshold(3)
.withNewHttpGet()
.withScheme(protocol)
.withNewPort(kcPort)
.withPath(kcRelativePath + "health/live")
.endHttpGet()
.endLivenessProbe();
}
if (!containerBuilder.hasStartupProbe()) {
containerBuilder.withNewStartupProbe()
.withPeriodSeconds(1)
.withFailureThreshold(600)
.withNewHttpGet()
.withScheme(protocol)
.withNewPort(kcPort)
.withPath(kcRelativePath + "health/started")
.endHttpGet()
.endStartupProbe();
}
// add in ports - there's no merging being done here
StatefulSet baseDeployment = containerBuilder
.addNewPort()
.withName(Constants.KEYCLOAK_HTTPS_PORT_NAME)
.withContainerPort(Constants.KEYCLOAK_HTTPS_PORT)
.withProtocol(Constants.KEYCLOAK_SERVICE_PROTOCOL)
.endPort()
.addNewPort()
.withName(Constants.KEYCLOAK_HTTP_PORT_NAME)
.withContainerPort(Constants.KEYCLOAK_HTTP_PORT)
.withProtocol(Constants.KEYCLOAK_SERVICE_PROTOCOL)
.endPort()
.endContainer().endSpec().endTemplate().endSpec().build();
return baseDeployment;
}
private void addRemainingEnvVars() {
// add in the remaining envVars, but only if they are not already set
var envVars = getEnvVars();
var env = baseDeployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv();
if (env != null && !env.isEmpty()) {
// this is also a final rationalization of whatever was added first by any previous manipulation wins
envVars = new ArrayList<>(Stream.concat(env.stream(), envVars.stream())
.collect(Collectors.toMap(EnvVar::getName, Function.identity(), (e1, e2) -> e1, LinkedHashMap::new))
.values());
}
baseDeployment.getSpec().getTemplate().getSpec().getContainers().get(0).setEnv(envVars);
}
private List<EnvVar> getEnvVars() {
// default config values
List<ValueOrSecret> serverConfigsList = new ArrayList<>(Constants.DEFAULT_DIST_CONFIG_LIST);
// merge with the CR; the values in CR take precedence
if (keycloakCR.getSpec().getAdditionalOptions() != null) {
Set<String> inCr = keycloakCR.getSpec().getAdditionalOptions().stream().map(v -> v.getName()).collect(Collectors.toSet());
serverConfigsList.removeIf(v -> inCr.contains(v.getName()));
serverConfigsList.addAll(keycloakCR.getSpec().getAdditionalOptions());
}
// set env vars
serverConfigSecretsNames = new HashSet<>();
List<EnvVar> envVars = serverConfigsList.stream()
.map(v -> {
var envBuilder = new EnvVarBuilder().withName(KeycloakDistConfigurator.getKeycloakOptionEnvVarName(v.getName()));
var secret = v.getSecret();
if (secret != null) {
envBuilder.withValueFrom(
new EnvVarSourceBuilder().withSecretKeyRef(secret).build());
serverConfigSecretsNames.add(secret.getName()); // for watching it later
} else {
envBuilder.withValue(v.getValue());
}
return envBuilder.build();
})
.collect(Collectors.toList());
Log.infof("Found config secrets names: %s", serverConfigSecretsNames);
envVars.add(
new EnvVarBuilder()
.withName("KEYCLOAK_ADMIN")
.withNewValueFrom()
.withNewSecretKeyRef()
.withName(adminSecretName)
.withKey("username")
.withOptional(false)
.endSecretKeyRef()
.endValueFrom()
.build());
envVars.add(
new EnvVarBuilder()
.withName("KEYCLOAK_ADMIN_PASSWORD")
.withNewValueFrom()
.withNewSecretKeyRef()
.withName(adminSecretName)
.withKey("password")
.withOptional(false)
.endSecretKeyRef()
.endValueFrom()
.build());
envVars.add(
new EnvVarBuilder()
.withName("jgroups.dns.query")
.withValue(getName() + Constants.KEYCLOAK_DISCOVERY_SERVICE_SUFFIX +"." + getNamespace())
.build());
return envVars;
}
public void updateStatus(KeycloakStatusAggregator status) {
status.apply(b -> b.withSelector(Utils.toSelectorString(getInstanceLabels())));
validatePodTemplate(status);
if (existingDeployment == null) {
status.addNotReadyMessage("No existing StatefulSet found, waiting for creating a new one");
return;
}
if (existingDeployment.getStatus() == null) {
status.addNotReadyMessage("Waiting for deployment status");
} else {
status.apply(b -> b.withInstances(existingDeployment.getStatus().getReadyReplicas()));
if (Optional.ofNullable(existingDeployment.getStatus().getReadyReplicas()).orElse(0) < keycloakCR.getSpec().getInstances()) {
checkForPodErrors(status);
status.addNotReadyMessage("Waiting for more replicas");
}
}
if (migrationInProgress) {
status.addNotReadyMessage("Performing Keycloak upgrade, scaling down the deployment");
} else if (existingDeployment.getStatus() != null
&& existingDeployment.getStatus().getCurrentRevision() != null
&& existingDeployment.getStatus().getUpdateRevision() != null
&& !existingDeployment.getStatus().getCurrentRevision().equals(existingDeployment.getStatus().getUpdateRevision())) {
status.addRollingUpdateMessage("Rolling out deployment update");
}
distConfigurator.validateOptions(status);
}
private void checkForPodErrors(KeycloakStatusAggregator status) {
client.pods().inNamespace(existingDeployment.getMetadata().getNamespace())
.withLabel("controller-revision-hash", existingDeployment.getStatus().getUpdateRevision())
.withLabels(getInstanceLabels())
.list().getItems().stream()
.filter(p -> !Readiness.isPodReady(p)
&& Optional.ofNullable(p.getStatus()).map(PodStatus::getContainerStatuses).isPresent())
.sorted((p1, p2) -> p1.getMetadata().getName().compareTo(p2.getMetadata().getName()))
.forEachOrdered(p -> {
Optional.of(p.getStatus()).map(s -> s.getContainerStatuses()).stream().flatMap(List::stream)
.filter(cs -> !Boolean.TRUE.equals(cs.getReady()))
.sorted((cs1, cs2) -> cs1.getName().compareTo(cs2.getName())).forEachOrdered(cs -> {
if (Optional.ofNullable(cs.getState()).map(ContainerState::getWaiting)
.map(ContainerStateWaiting::getReason).map(String::toLowerCase)
.filter(s -> s.contains("err") || s.equals("crashloopbackoff")).isPresent()) {
Log.infof("Found unhealthy container on pod %s/%s: %s",
p.getMetadata().getNamespace(), p.getMetadata().getName(),
Serialization.asYaml(cs));
status.addErrorMessage(
String.format("Waiting for %s/%s due to %s: %s", p.getMetadata().getNamespace(),
p.getMetadata().getName(), cs.getState().getWaiting().getReason(),
cs.getState().getWaiting().getMessage()));
}
});
});
}
public List<String> getConfigSecretsNames() {
TreeSet<String> ret = new TreeSet<>(serverConfigSecretsNames);
ret.addAll(distConfigurator.getSecretNames());
return new ArrayList<>(ret);
}
public static String getName(Keycloak keycloak) {
return keycloak.getMetadata().getName();
}
@Override
public String getName() {
return getName(keycloakCR);
}
public void migrateDeployment(StatefulSet previousDeployment, StatefulSet reconciledDeployment) {
if (previousDeployment == null
|| previousDeployment.getSpec() == null
|| previousDeployment.getSpec().getTemplate() == null
|| previousDeployment.getSpec().getTemplate().getSpec() == null
|| previousDeployment.getSpec().getTemplate().getSpec().getContainers() == null
|| previousDeployment.getSpec().getTemplate().getSpec().getContainers().get(0) == null)
{
return;
}
var previousContainer = previousDeployment.getSpec().getTemplate().getSpec().getContainers().get(0);
var reconciledContainer = reconciledDeployment.getSpec().getTemplate().getSpec().getContainers().get(0);
if (!previousContainer.getImage().equals(reconciledContainer.getImage())
&& previousDeployment.getStatus().getReplicas() > 1) {
// TODO Check if migration is really needed (e.g. based on actual KC version); https://github.com/keycloak/keycloak/issues/10441
Log.info("Detected changed Keycloak image, assuming Keycloak upgrade. Scaling down the deployment to one instance to perform a safe database migration");
Log.infof("original image: %s; new image: %s", previousContainer.getImage(), reconciledContainer.getImage());
reconciledContainer.setImage(previousContainer.getImage());
reconciledDeployment.getSpec().setReplicas(1);
migrationInProgress = true;
}
}
protected String readConfigurationValue(String key) {
if (keycloakCR != null &&
keycloakCR.getSpec() != null &&
keycloakCR.getSpec().getAdditionalOptions() != null
) {
var serverConfigValue = keycloakCR
.getSpec()
.getAdditionalOptions()
.stream()
.filter(sc -> sc.getName().equals(key))
.findFirst();
if (serverConfigValue.isPresent()) {
if (serverConfigValue.get().getValue() != null) {
return serverConfigValue.get().getValue();
} else {
var secretSelector = serverConfigValue.get().getSecret();
if (secretSelector == null) {
throw new IllegalStateException("Secret " + serverConfigValue.get().getName() + " not defined");
}
var secret = client.secrets().inNamespace(keycloakCR.getMetadata().getNamespace()).withName(secretSelector.getName()).get();
if (secret == null) {
throw new IllegalStateException("Secret " + secretSelector.getName() + " not found in cluster");
}
if (secret.getData().containsKey(secretSelector.getKey())) {
return new String(Base64.getDecoder().decode(secret.getData().get(secretSelector.getKey())), StandardCharsets.UTF_8);
} else {
throw new IllegalStateException("Secret " + secretSelector.getName() + " doesn't contain the expected key " + secretSelector.getKey());
}
}
} else {
return null;
}
} else {
return null;
}
}
}

View file

@ -0,0 +1,410 @@
/*
* 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.EnvVar;
import io.fabric8.kubernetes.api.model.EnvVarBuilder;
import io.fabric8.kubernetes.api.model.EnvVarSource;
import io.fabric8.kubernetes.api.model.EnvVarSourceBuilder;
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.SecretKeySelector;
import io.fabric8.kubernetes.api.model.VolumeBuilder;
import io.fabric8.kubernetes.api.model.VolumeMountBuilder;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder;
import io.fabric8.kubernetes.api.model.apps.StatefulSetSpec;
import io.fabric8.kubernetes.api.model.apps.StatefulSetSpecFluent.TemplateNested;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
import io.quarkus.logging.Log;
import org.keycloak.operator.Config;
import org.keycloak.operator.Constants;
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.UnsupportedSpec;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import jakarta.inject.Inject;
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 OPTIMIZED_ARG = "--optimized";
@Inject
Config operatorConfig;
@Inject
WatchedSecrets watchedSecrets;
@Inject
KeycloakDistConfigurator distConfigurator;
public KeycloakDeploymentDependentResource() {
super(StatefulSet.class);
}
@Override
public StatefulSet desired(Keycloak primary, Context<Keycloak> context) {
StatefulSet baseDeployment = createBaseDeployment(primary, context);
if (isTlsConfigured(primary)) {
configureTLS(primary, baseDeployment);
}
addEnvVarsAndWatchSecrets(baseDeployment, primary);
StatefulSet existingDeployment = context.getSecondaryResource(StatefulSet.class).orElse(null);
if (existingDeployment == null) {
Log.info("No existing Deployment found, using the default");
}
else {
Log.info("Existing Deployment found, handling migration");
// version 22 changed the match labels, account for older versions
if (!existingDeployment.isMarkedForDeletion() && !hasExpectedMatchLabels(existingDeployment, primary)) {
context.getClient().resource(existingDeployment).lockResourceVersion().delete();
Log.info("Existing Deployment found with old label selector, it will be recreated");
}
migrateDeployment(existingDeployment, baseDeployment, context);
}
return baseDeployment;
}
void configureTLS(Keycloak keycloakCR, StatefulSet deployment) {
var kcContainer = deployment.getSpec().getTemplate().getSpec().getContainers().get(0);
var volume = new VolumeBuilder()
.withName("keycloak-tls-certificates")
.withNewSecret()
.withSecretName(keycloakCR.getSpec().getHttpSpec().getTlsSecret())
.withOptional(false)
.endSecret()
.build();
var volumeMount = new VolumeMountBuilder()
.withName(volume.getName())
.withMountPath(Constants.CERTIFICATES_FOLDER)
.build();
deployment.getSpec().getTemplate().getSpec().getVolumes().add(0, volume);
kcContainer.getVolumeMounts().add(0, volumeMount);
}
@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);
}
static Optional<PodTemplateSpec> getPodTemplateSpec(Keycloak keycloakCR) {
return Optional.ofNullable(keycloakCR.getSpec()).map(KeycloakSpec::getUnsupported).map(UnsupportedSpec::getPodTemplate);
}
private StatefulSet createBaseDeployment(Keycloak keycloakCR, Context<Keycloak> context) {
Map<String, String> labels = Utils.allInstanceLabels(keycloakCR);
if (operatorConfig.keycloak().podLabels() != null) {
labels.putAll(operatorConfig.keycloak().podLabels());
}
/* Create a builder for the statefulset, note that the pod template spec is used as the basis
* over that some values are forced, others will let the template override, others merge
*/
StatefulSetBuilder baseDeploymentBuilder = new StatefulSetBuilder()
.withNewMetadata()
.withName(getName(keycloakCR))
.withNamespace(keycloakCR.getMetadata().getNamespace())
.withLabels(Utils.allInstanceLabels(keycloakCR))
.addToAnnotations(Constants.KEYCLOAK_MIGRATING_ANNOTATION, Boolean.FALSE.toString())
.endMetadata()
.withNewSpec()
.withNewSelector()
.withMatchLabels(Utils.allInstanceLabels(keycloakCR))
.endSelector()
.withNewTemplateLike(getPodTemplateSpec(keycloakCR).orElseGet(PodTemplateSpec::new))
.editOrNewMetadata().addToLabels(labels).endMetadata()
.editOrNewSpec().withImagePullSecrets(keycloakCR.getSpec().getImagePullSecrets()).endSpec()
.endTemplate()
.withReplicas(keycloakCR.getSpec().getInstances())
.endSpec();
var specBuilder = baseDeploymentBuilder.editSpec().editTemplate().editOrNewSpec();
if (!specBuilder.hasRestartPolicy()) {
specBuilder.withRestartPolicy("Always");
}
if (!specBuilder.hasTerminationGracePeriodSeconds()) {
specBuilder.withTerminationGracePeriodSeconds(30L);
}
if (!specBuilder.hasDnsPolicy()) {
specBuilder.withDnsPolicy("ClusterFirst");
}
// there isn't currently an editOrNewFirstContainer, so we need to do this manually
ContainersNested<SpecNested<TemplateNested<io.fabric8.kubernetes.api.model.apps.StatefulSetFluent.SpecNested<StatefulSetBuilder>>>> containerBuilder = null;
if (specBuilder.buildContainers().isEmpty()) {
containerBuilder = specBuilder.addNewContainer();
} else {
containerBuilder = specBuilder.editFirstContainer();
}
containerBuilder.withName("keycloak");
var customImage = Optional.ofNullable(keycloakCR.getSpec().getImage());
containerBuilder.withImage(customImage.orElse(operatorConfig.keycloak().image()));
if (!containerBuilder.hasImagePullPolicy()) {
containerBuilder.withImagePullPolicy(operatorConfig.keycloak().imagePullPolicy());
}
if (Optional.ofNullable(containerBuilder.getArgs()).orElse(List.of()).isEmpty()) {
containerBuilder.withArgs("--verbose", "start");
}
if (customImage.isPresent()) {
containerBuilder.addToArgs(OPTIMIZED_ARG);
}
// probes
var tlsConfigured = isTlsConfigured(keycloakCR);
var protocol = !tlsConfigured ? "HTTP" : "HTTPS";
var kcPort = KeycloakServiceDependentResource.getServicePort(tlsConfigured, keycloakCR);
// Relative path ends with '/'
var kcRelativePath = readConfigurationValue(Constants.KEYCLOAK_HTTP_RELATIVE_PATH_KEY, keycloakCR, context)
.map(path -> !path.endsWith("/") ? path + "/" : path)
.orElse("/");
if (!containerBuilder.hasReadinessProbe()) {
containerBuilder.withNewReadinessProbe()
.withPeriodSeconds(10)
.withFailureThreshold(3)
.withNewHttpGet()
.withScheme(protocol)
.withNewPort(kcPort)
.withPath(kcRelativePath + "health/ready")
.endHttpGet()
.endReadinessProbe();
}
if (!containerBuilder.hasLivenessProbe()) {
containerBuilder.withNewLivenessProbe()
.withPeriodSeconds(10)
.withFailureThreshold(3)
.withNewHttpGet()
.withScheme(protocol)
.withNewPort(kcPort)
.withPath(kcRelativePath + "health/live")
.endHttpGet()
.endLivenessProbe();
}
if (!containerBuilder.hasStartupProbe()) {
containerBuilder.withNewStartupProbe()
.withPeriodSeconds(1)
.withFailureThreshold(600)
.withNewHttpGet()
.withScheme(protocol)
.withNewPort(kcPort)
.withPath(kcRelativePath + "health/started")
.endHttpGet()
.endStartupProbe();
}
// add in ports - there's no merging being done here
StatefulSet baseDeployment = containerBuilder
.addNewPort()
.withName(Constants.KEYCLOAK_HTTPS_PORT_NAME)
.withContainerPort(Constants.KEYCLOAK_HTTPS_PORT)
.withProtocol(Constants.KEYCLOAK_SERVICE_PROTOCOL)
.endPort()
.addNewPort()
.withName(Constants.KEYCLOAK_HTTP_PORT_NAME)
.withContainerPort(Constants.KEYCLOAK_HTTP_PORT)
.withProtocol(Constants.KEYCLOAK_SERVICE_PROTOCOL)
.endPort()
.endContainer().endSpec().endTemplate().endSpec().build();
return baseDeployment;
}
private void addEnvVarsAndWatchSecrets(StatefulSet baseDeployment, Keycloak keycloakCR) {
var firstClasssEnvVars = distConfigurator.configureDistOptions(keycloakCR);
String adminSecretName = KeycloakAdminSecretDependentResource.getName(keycloakCR);
var additionalEnvVars = getDefaultAndAdditionalEnvVars(keycloakCR, adminSecretName);
var env = Optional.ofNullable(baseDeployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv()).orElse(List.of());
// accumulate the env vars in priority order - unsupported, first class, additional
var envVars = new ArrayList<>(Stream.concat(Stream.concat(env.stream(), firstClasssEnvVars.stream()), additionalEnvVars.stream())
.collect(Collectors.toMap(EnvVar::getName, Function.identity(), (e1, e2) -> e1, LinkedHashMap::new))
.values());
baseDeployment.getSpec().getTemplate().getSpec().getContainers().get(0).setEnv(envVars);
// watch the secrets used by secret key - we don't currently expect configmaps, optional refs, or watch the initial-admin
TreeSet<String> serverConfigSecretsNames = envVars.stream().map(EnvVar::getValueFrom).filter(Objects::nonNull)
.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);
// add secrets from volume mounts (currently just the tls secret)
if (isTlsConfigured(keycloakCR)) {
serverConfigSecretsNames.add(keycloakCR.getSpec().getHttpSpec().getTlsSecret());
}
if (!serverConfigSecretsNames.isEmpty()) {
watchedSecrets.annotateDeployment(new ArrayList<>(serverConfigSecretsNames), keycloakCR, baseDeployment);
}
}
private List<EnvVar> getDefaultAndAdditionalEnvVars(Keycloak keycloakCR, String adminSecretName) {
// default config values
List<ValueOrSecret> serverConfigsList = new ArrayList<>(Constants.DEFAULT_DIST_CONFIG_LIST);
// merge with the CR; the values in CR take precedence
if (keycloakCR.getSpec().getAdditionalOptions() != null) {
Set<String> inCr = keycloakCR.getSpec().getAdditionalOptions().stream().map(v -> v.getName()).collect(Collectors.toSet());
serverConfigsList.removeIf(v -> inCr.contains(v.getName()));
serverConfigsList.addAll(keycloakCR.getSpec().getAdditionalOptions());
}
// set env vars
List<EnvVar> envVars = serverConfigsList.stream()
.map(v -> {
var envBuilder = new EnvVarBuilder().withName(KeycloakDistConfigurator.getKeycloakOptionEnvVarName(v.getName()));
var secret = v.getSecret();
if (secret != null) {
envBuilder.withValueFrom(
new EnvVarSourceBuilder().withSecretKeyRef(secret).build());
} else {
envBuilder.withValue(v.getValue());
}
return envBuilder.build();
})
.collect(Collectors.toList());
envVars.add(
new EnvVarBuilder()
.withName("KEYCLOAK_ADMIN")
.withNewValueFrom()
.withNewSecretKeyRef()
.withName(adminSecretName)
.withKey("username")
.withOptional(false)
.endSecretKeyRef()
.endValueFrom()
.build());
envVars.add(
new EnvVarBuilder()
.withName("KEYCLOAK_ADMIN_PASSWORD")
.withNewValueFrom()
.withNewSecretKeyRef()
.withName(adminSecretName)
.withKey("password")
.withOptional(false)
.endSecretKeyRef()
.endValueFrom()
.build());
envVars.add(
new EnvVarBuilder()
.withName("jgroups.dns.query")
.withValue(getName(keycloakCR) + Constants.KEYCLOAK_DISCOVERY_SERVICE_SUFFIX +"." + keycloakCR.getMetadata().getNamespace())
.build());
return envVars;
}
public static String getName(Keycloak keycloak) {
return keycloak.getMetadata().getName();
}
public void migrateDeployment(StatefulSet previousDeployment, StatefulSet reconciledDeployment, Context<Keycloak> context) {
var previousContainer = Optional.ofNullable(previousDeployment).map(StatefulSet::getSpec)
.map(StatefulSetSpec::getTemplate).map(PodTemplateSpec::getSpec).map(PodSpec::getContainers)
.flatMap(c -> c.stream().findFirst()).orElse(null);
if (previousContainer == null) {
return;
}
var reconciledContainer = reconciledDeployment.getSpec().getTemplate().getSpec().getContainers().get(0);
if (!previousContainer.getImage().equals(reconciledContainer.getImage())
&& previousDeployment.getStatus().getReplicas() > 1) {
// TODO Check if migration is really needed (e.g. based on actual KC version); https://github.com/keycloak/keycloak/issues/10441
Log.info("Detected changed Keycloak image, assuming Keycloak upgrade. Scaling down the deployment to one instance to perform a safe database migration");
Log.infof("original image: %s; new image: %s", previousContainer.getImage(), reconciledContainer.getImage());
reconciledContainer.setImage(previousContainer.getImage());
reconciledDeployment.getSpec().setReplicas(1);
reconciledDeployment.getMetadata().getAnnotations().put(Constants.KEYCLOAK_MIGRATING_ANNOTATION, Boolean.TRUE.toString());
}
}
protected Optional<String> readConfigurationValue(String key, Keycloak keycloakCR, Context<Keycloak> context) {
return Optional.ofNullable(keycloakCR.getSpec()).map(KeycloakSpec::getAdditionalOptions)
.flatMap(l -> l.stream().filter(sc -> sc.getName().equals(key)).findFirst().map(serverConfigValue -> {
if (serverConfigValue.getValue() != null) {
return serverConfigValue.getValue();
}
var secretSelector = serverConfigValue.getSecret();
if (secretSelector == null) {
throw new IllegalStateException("Secret " + serverConfigValue.getName() + " not defined");
}
var secret = context.getClient().secrets().inNamespace(keycloakCR.getMetadata().getNamespace()).withName(secretSelector.getName()).get();
if (secret == null) {
throw new IllegalStateException("Secret " + secretSelector.getName() + " not found in cluster");
}
if (secret.getData().containsKey(secretSelector.getKey())) {
return new String(Base64.getDecoder().decode(secret.getData().get(secretSelector.getKey())), StandardCharsets.UTF_8);
}
throw new IllegalStateException("Secret " + secretSelector.getName() + " doesn't contain the expected key " + secretSelector.getKey());
}));
}
}

View file

@ -26,6 +26,7 @@ import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernete
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
import org.keycloak.operator.Constants;
import org.keycloak.operator.Utils;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import java.util.Optional;
@ -50,7 +51,7 @@ public class KeycloakDiscoveryServiceDependentResource extends CRUDKubernetesDep
.withProtocol("TCP")
.withPort(Constants.KEYCLOAK_DISCOVERY_SERVICE_PORT)
.endPort()
.withSelector(OperatorManagedResource.allInstanceLabels(keycloak))
.withSelector(Utils.allInstanceLabels(keycloak))
.withClusterIP("None")
.withPublishNotReadyAddresses(Boolean.TRUE)
.build();
@ -62,7 +63,7 @@ public class KeycloakDiscoveryServiceDependentResource extends CRUDKubernetesDep
.withNewMetadata()
.withName(getName(primary))
.withNamespace(primary.getMetadata().getNamespace())
.addToLabels(OperatorManagedResource.allInstanceLabels(primary))
.addToLabels(Utils.allInstanceLabels(primary))
.endMetadata()
.withSpec(getServiceSpec(primary))
.build();

View file

@ -21,11 +21,8 @@ import io.fabric8.kubernetes.api.model.EnvVar;
import io.fabric8.kubernetes.api.model.EnvVarBuilder;
import io.fabric8.kubernetes.api.model.EnvVarSourceBuilder;
import io.fabric8.kubernetes.api.model.SecretKeySelector;
import io.fabric8.kubernetes.api.model.VolumeBuilder;
import io.fabric8.kubernetes.api.model.VolumeMountBuilder;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.quarkus.logging.Log;
import org.keycloak.common.util.CollectionUtil;
import org.keycloak.operator.Constants;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
@ -39,40 +36,31 @@ import org.keycloak.operator.crds.v2alpha1.deployment.spec.TransactionsSpec;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import jakarta.enterprise.context.ApplicationScoped;
import static io.smallrye.config.common.utils.StringUtil.replaceNonAlphanumericByUnderscores;
import static org.keycloak.operator.crds.v2alpha1.CRDUtils.isTlsConfigured;
/**
* Configuration for the KeycloakDeployment
* Configuration for the Keycloak Statefulset
*/
@ApplicationScoped
public class KeycloakDistConfigurator {
private final Keycloak keycloakCR;
private final StatefulSet deployment;
private final KubernetesClient client;
public KeycloakDistConfigurator(Keycloak keycloakCR, StatefulSet deployment, KubernetesClient client) {
this.keycloakCR = keycloakCR;
this.deployment = deployment;
this.client = client;
}
/**
* Specify first-class citizens fields which should not be added as general server configuration property
*/
private final Set<String> firstClassConfigOptions = new HashSet<>();
@SuppressWarnings("rawtypes")
private final Map<String, org.keycloak.operator.controllers.KeycloakDistConfigurator.OptionMapper.Mapper> firstClassConfigOptions = new LinkedHashMap<>();
/**
* Configure configuration properties for the KeycloakDeployment
*/
public void configureDistOptions() {
public KeycloakDistConfigurator() {
// register the configuration mappers for the various parts of the keycloak cr
configureHostname();
configureFeatures();
configureTransactions();
@ -85,14 +73,14 @@ public class KeycloakDistConfigurator {
*
* @param status Keycloak Status builder
*/
public void validateOptions(KeycloakStatusAggregator status) {
assumeFirstClassCitizens(status);
public void validateOptions(Keycloak keycloakCR, KeycloakStatusAggregator status) {
assumeFirstClassCitizens(keycloakCR, status);
}
/* ---------- Configuration of first-class citizen fields ---------- */
public void configureHostname() {
optionMapper(keycloakCR.getSpec().getHostnameSpec())
void configureHostname() {
optionMapper(keycloakCR -> keycloakCR.getSpec().getHostnameSpec())
.mapOption("hostname", HostnameSpec::getHostname)
.mapOption("hostname-admin", HostnameSpec::getAdmin)
.mapOption("hostname-admin-url", HostnameSpec::getAdminUrl)
@ -100,61 +88,28 @@ public class KeycloakDistConfigurator {
.mapOption("hostname-strict-backchannel", HostnameSpec::isStrictBackchannel);
}
public void configureFeatures() {
optionMapper(keycloakCR.getSpec().getFeatureSpec())
void configureFeatures() {
optionMapper(keycloakCR -> keycloakCR.getSpec().getFeatureSpec())
.mapOptionFromCollection("features", FeatureSpec::getEnabledFeatures)
.mapOptionFromCollection("features-disabled", FeatureSpec::getDisabledFeatures);
}
public void configureTransactions() {
optionMapper(keycloakCR.getSpec().getTransactionsSpec())
void configureTransactions() {
optionMapper(keycloakCR -> keycloakCR.getSpec().getTransactionsSpec())
.mapOption("transaction-xa-enabled", TransactionsSpec::isXaEnabled);
}
public void configureHttp() {
var optionMapper = optionMapper(keycloakCR.getSpec().getHttpSpec())
void configureHttp() {
optionMapper(keycloakCR -> keycloakCR.getSpec().getHttpSpec())
.mapOption("http-enabled", HttpSpec::getHttpEnabled)
.mapOption("http-port", HttpSpec::getHttpPort)
.mapOption("https-port", HttpSpec::getHttpsPort);
configureTLS(optionMapper);
.mapOption("https-port", HttpSpec::getHttpsPort)
.mapOption("https-certificate-file", http -> (http.getTlsSecret() != null && !http.getTlsSecret().isEmpty()) ? Constants.CERTIFICATES_FOLDER + "/tls.crt" : null)
.mapOption("https-certificate-key-file", http -> (http.getTlsSecret() != null && !http.getTlsSecret().isEmpty()) ? Constants.CERTIFICATES_FOLDER + "/tls.key" : null);
}
public void configureTLS(OptionMapper<HttpSpec> optionMapper) {
final String certFileOptionName = "https-certificate-file";
final String keyFileOptionName = "https-certificate-key-file";
if (!isTlsConfigured(keycloakCR)) {
// for mapping and triggering warning in status if someone uses the fields directly
optionMapper.mapOption(certFileOptionName);
optionMapper.mapOption(keyFileOptionName);
return;
}
optionMapper.mapOption(certFileOptionName, Constants.CERTIFICATES_FOLDER + "/tls.crt");
optionMapper.mapOption(keyFileOptionName, Constants.CERTIFICATES_FOLDER + "/tls.key");
var kcContainer = deployment.getSpec().getTemplate().getSpec().getContainers().get(0);
var volume = new VolumeBuilder()
.withName("keycloak-tls-certificates")
.withNewSecret()
.withSecretName(keycloakCR.getSpec().getHttpSpec().getTlsSecret())
.withOptional(false)
.endSecret()
.build();
var volumeMount = new VolumeMountBuilder()
.withName(volume.getName())
.withMountPath(Constants.CERTIFICATES_FOLDER)
.build();
deployment.getSpec().getTemplate().getSpec().getVolumes().add(0, volume);
kcContainer.getVolumeMounts().add(0, volumeMount);
}
public void configureDatabase() {
optionMapper(keycloakCR.getSpec().getDatabaseSpec())
void configureDatabase() {
optionMapper(keycloakCR -> keycloakCR.getSpec().getDatabaseSpec())
.mapOption("db", DatabaseSpec::getVendor)
.mapOption("db-username", DatabaseSpec::getUsernameSecret)
.mapOption("db-password", DatabaseSpec::getPasswordSecret)
@ -175,7 +130,7 @@ public class KeycloakDistConfigurator {
*
* @param status Status of the deployment
*/
protected void assumeFirstClassCitizens(KeycloakStatusAggregator status) {
protected void assumeFirstClassCitizens(Keycloak keycloakCR, KeycloakStatusAggregator status) {
final var serverConfigNames = keycloakCR
.getSpec()
.getAdditionalOptions()
@ -183,7 +138,7 @@ public class KeycloakDistConfigurator {
.map(ValueOrSecret::getName)
.collect(Collectors.toSet());
final var sameItems = CollectionUtil.intersection(serverConfigNames, firstClassConfigOptions);
final var sameItems = CollectionUtil.intersection(serverConfigNames, firstClassConfigOptions.keySet());
if (CollectionUtil.isNotEmpty(sameItems)) {
status.addWarningMessage("You need to specify these fields as the first-class citizen of the CR: "
+ CollectionUtil.join(sameItems, ","));
@ -195,79 +150,58 @@ public class KeycloakDistConfigurator {
return "KC_" + replaceNonAlphanumericByUnderscores(kcConfigName).toUpperCase();
}
private <T> OptionMapper<T> optionMapper(T optionSpec) {
private <T> OptionMapper<T> optionMapper(Function<Keycloak, T> optionSpec) {
return new OptionMapper<>(optionSpec);
}
public Collection<String> getSecretNames() {
Set<String> names = new HashSet<>();
private class OptionMapper<T> {
if (isTlsConfigured(keycloakCR)) {
names.add(keycloakCR.getSpec().getHttpSpec().getTlsSecret());
private class Mapper<R> {
Function<T, R> optionValueSupplier;
public Mapper(Function<T, R> optionValueSupplier) {
this.optionValueSupplier = optionValueSupplier;
}
void map(String optionName, Keycloak keycloak, List<EnvVar> variables) {
var categorySpec = optionSpec.apply(keycloak);
if (categorySpec == null) {
Log.debugf("No category spec provided for %s", optionName);
return;
}
R value = optionValueSupplier.apply(categorySpec);
if (value == null || value.toString().trim().isEmpty()) {
Log.debugf("No value provided for %s", optionName);
return;
}
EnvVarBuilder envVarBuilder = new EnvVarBuilder()
.withName(getKeycloakOptionEnvVarName(optionName));
if (value instanceof SecretKeySelector) {
envVarBuilder.withValueFrom(new EnvVarSourceBuilder().withSecretKeyRef((SecretKeySelector) value).build());
} else {
envVarBuilder.withValue(String.valueOf(value));
}
variables.add(envVarBuilder.build());
}
}
Optional.ofNullable(keycloakCR.getSpec().getDatabaseSpec()).map(DatabaseSpec::getUsernameSecret).map(SecretKeySelector::getName).ifPresent(names::add);
Optional.ofNullable(keycloakCR.getSpec().getDatabaseSpec()).map(DatabaseSpec::getPasswordSecret).map(SecretKeySelector::getName).ifPresent(names::add);
private final Function<Keycloak, T> optionSpec;
return names;
}
private class OptionMapper<T> {
private final T categorySpec;
private final List<EnvVar> envVars;
public OptionMapper(T optionSpec) {
this.categorySpec = optionSpec;
var kcContainer = deployment.getSpec().getTemplate().getSpec().getContainers().get(0);
var envVars = kcContainer.getEnv();
if (envVars == null) {
envVars = new ArrayList<>();
kcContainer.setEnv(envVars);
}
this.envVars = envVars;
public OptionMapper(Function<Keycloak, T> optionSpec) {
this.optionSpec = optionSpec;
}
public <R> OptionMapper<T> mapOption(String optionName, Function<T, R> optionValueSupplier) {
firstClassConfigOptions.add(optionName);
if (categorySpec == null) {
Log.debugf("No category spec provided for %s", optionName);
return this;
}
R value = optionValueSupplier.apply(categorySpec);
if (value == null || value.toString().trim().isEmpty()) {
Log.debugf("No value provided for %s", optionName);
return this;
}
EnvVarBuilder envVarBuilder = new EnvVarBuilder()
.withName(getKeycloakOptionEnvVarName(optionName));
if (value instanceof SecretKeySelector) {
envVarBuilder.withValueFrom(new EnvVarSourceBuilder().withSecretKeyRef((SecretKeySelector) value).build());
} else {
envVarBuilder.withValue(String.valueOf(value));
}
var toAdd = envVarBuilder.build();
if (!envVars.stream().anyMatch(envVar -> envVar.getName().equals(toAdd.getName()))) {
envVars.add(toAdd);
}
firstClassConfigOptions.put(optionName, new Mapper<>(optionValueSupplier));
return this;
}
public <R> OptionMapper<T> mapOption(String optionName) {
return mapOption(optionName, s -> null);
}
public <R> OptionMapper<T> mapOption(String optionName, R optionValue) {
return mapOption(optionName, s -> optionValue);
}
protected <R extends Collection<?>> OptionMapper<T> mapOptionFromCollection(String optionName, Function<T, R> optionValueSupplier) {
return mapOption(optionName, s -> {
var value = optionValueSupplier.apply(s);
@ -276,4 +210,11 @@ public class KeycloakDistConfigurator {
});
}
}
@SuppressWarnings("unchecked")
public List<EnvVar> configureDistOptions(Keycloak keycloakCR) {
List<EnvVar> result = new ArrayList<>();
firstClassConfigOptions.entrySet().forEach(e -> e.getValue().map(e.getKey(), keycloakCR, result));
return result;
}
}

View file

@ -25,6 +25,7 @@ import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDep
import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition;
import org.keycloak.operator.Constants;
import org.keycloak.operator.Utils;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.IngressSpec;
@ -73,8 +74,7 @@ public class KeycloakIngressDependentResource extends CRUDKubernetesDependentRes
.withNewMetadata()
.withName(getName(keycloak))
.withNamespace(keycloak.getMetadata().getNamespace())
.addToLabels(Constants.DEFAULT_LABELS)
.addToLabels(OperatorManagedResource.updateWithInstanceLabels(null, keycloak.getMetadata().getName()))
.addToLabels(Utils.allInstanceLabels(keycloak))
.addToAnnotations(annotations)
.endMetadata()
.withNewSpec()

View file

@ -34,6 +34,7 @@ import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDep
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfig;
import org.keycloak.operator.Constants;
import org.keycloak.operator.Utils;
import org.keycloak.operator.crds.v2alpha1.realmimport.KeycloakRealmImport;
import java.util.List;
@ -93,7 +94,7 @@ public class KeycloakRealmImportJobDependentResource extends KubernetesDependent
.withName(primary.getMetadata().getName())
.withNamespace(primary.getMetadata().getNamespace())
// this is labeling the instance as the realm import, not the keycloak
.withLabels(OperatorManagedResource.allInstanceLabels(primary))
.withLabels(Utils.allInstanceLabels(primary))
.endMetadata()
.withNewSpec()
.withTemplate(keycloakPodTemplate)
@ -117,7 +118,7 @@ public class KeycloakRealmImportJobDependentResource extends KubernetesDependent
var override = "--override=false";
var runBuild = !keycloakContainer.getArgs().contains(KeycloakDeployment.OPTIMIZED_ARG) ? "/opt/keycloak/bin/kc.sh --verbose build && " : "";
var runBuild = !keycloakContainer.getArgs().contains(KeycloakDeploymentDependentResource.OPTIMIZED_ARG) ? "/opt/keycloak/bin/kc.sh --verbose build && " : "";
var commandArgs = List.of("-c",
runBuild + "/opt/keycloak/bin/kc.sh --verbose import --optimized --file='" + importMntPath + realmName + "-realm.json' " + override);

View file

@ -28,7 +28,7 @@ public class KeycloakRealmImportSecretDependentResource extends CRUDKubernetesDe
.withName(getSecretName(primary))
.withNamespace(primary.getMetadata().getNamespace())
// this is labeling the instance as the realm import, not the keycloak
.addToLabels(OperatorManagedResource.allInstanceLabels(primary))
.addToLabels(Utils.allInstanceLabels(primary))
.endMetadata()
.addToData(fileName, Utils.asBase64(content))
.build();

View file

@ -29,6 +29,7 @@ import io.javaoperatorsdk.operator.processing.event.ResourceID;
import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
import org.keycloak.operator.Constants;
import org.keycloak.operator.Utils;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HttpSpec;
@ -59,7 +60,7 @@ public class KeycloakServiceDependentResource extends CRUDKubernetesDependentRes
}
private ServiceSpec getServiceSpec(Keycloak keycloak) {
var builder = new ServiceSpecBuilder().withSelector(OperatorManagedResource.allInstanceLabels(keycloak));
var builder = new ServiceSpecBuilder().withSelector(Utils.allInstanceLabels(keycloak));
boolean tlsConfigured = isTlsConfigured(keycloak);
Optional<HttpSpec> httpSpec = Optional.ofNullable(keycloak.getSpec().getHttpSpec());
@ -81,7 +82,7 @@ public class KeycloakServiceDependentResource extends CRUDKubernetesDependentRes
.withNewMetadata()
.withName(getServiceName(primary))
.withNamespace(primary.getMetadata().getNamespace())
.addToLabels(OperatorManagedResource.allInstanceLabels(primary))
.addToLabels(Utils.allInstanceLabels(primary))
.endMetadata()
.withSpec(getServiceSpec(primary))
.build();

View file

@ -1,64 +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.HasMetadata;
import io.fabric8.kubernetes.api.model.ObjectMeta;
import io.fabric8.kubernetes.client.utils.Serialization;
import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter;
import java.util.Objects;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* addresses the additional events noted on https://github.com/fabric8io/kubernetes-client/issues/5215
* <p>
* - usage of secret stringData has been removed,
* - but ingress usage of empty strings is still problematic
* it seems best to leave this in place for all resources to make sure we don't get in a reconciliation loop.
* <p>
* This should be removable after switching to dependent resources
*
*/
public class MetadataAwareOnUpdateFilter<T extends HasMetadata> implements OnUpdateFilter<T> {
private static final ObjectMapper mapper = new ObjectMapper();
@Override
public boolean accept(HasMetadata newResource, HasMetadata oldResource) {
ObjectMeta newMetadata = newResource.getMetadata();
ObjectMeta oldMetadata = oldResource.getMetadata();
// quick check if anything meaningful has changed
if (!Objects.equals(newMetadata.getAnnotations(), oldMetadata.getAnnotations())
|| !Objects.equals(newMetadata.getLabels(), oldMetadata.getLabels())
|| !Objects.equals(newMetadata.getGeneration(), oldMetadata.getGeneration())) {
return true;
}
// check everything else besides the metadata
// since the hierarchy of model the does not implement hasCode/equals, we'll convert to a generic form
// that should be less expensive than full serialization
var newMap = (ObjectNode)mapper.valueToTree(newResource);
newMap.remove("metadata");
var oldMap = (ObjectNode)mapper.valueToTree(oldResource);
oldMap.remove("metadata");
return !oldMap.equals(newMap);
}
}

View file

@ -1,134 +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.HasMetadata;
import io.fabric8.kubernetes.api.model.OwnerReference;
import io.fabric8.kubernetes.api.model.OwnerReferenceBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.KubernetesClientException;
import io.fabric8.kubernetes.client.dsl.base.PatchContext;
import io.fabric8.kubernetes.client.dsl.base.PatchType;
import io.quarkus.logging.Log;
import org.keycloak.operator.Constants;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
/**
* Represents a single K8s resource that is managed by this operator (e.g. Deployment, Service, Ingress, etc.)
*
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public abstract class OperatorManagedResource<T extends HasMetadata> {
private static final String KEYCLOAK_OPERATOR_FIELD_MANAGER = "keycloak-operator";
protected KubernetesClient client;
protected HasMetadata cr;
public OperatorManagedResource(KubernetesClient client, HasMetadata cr) {
this.client = client;
this.cr = cr;
}
protected abstract Optional<T> getReconciledResource();
public Optional<T> createOrUpdateReconciled() {
return getReconciledResource().map(resource -> {
try {
setInstanceLabels(resource);
setOwnerReferences(resource);
Log.debugf("Creating or updating resource: %s", resource);
try {
resource = client.resource(resource).inNamespace(getNamespace()).forceConflicts().fieldManager(KEYCLOAK_OPERATOR_FIELD_MANAGER).serverSideApply();
} catch (KubernetesClientException e) {
if (e.getCode() != 422) {
throw e;
}
Log.infof("Could not apply changes to resource %s %s/%s will try strategic merge instead",
resource.getKind(), resource.getMetadata().getNamespace(),
resource.getMetadata().getName(), e.getMessage());
try {
client.resource(resource).patch(PatchContext.of(PatchType.STRATEGIC_MERGE));
} catch (KubernetesClientException ex) {
if (ex.getCode() == 422) {
Log.warnf("Could not apply changes to resource %s %s/%s if you have modified the resource please revert it or delete the resource so that the operator may regain control",
resource.getKind(), resource.getMetadata().getNamespace(),
resource.getMetadata().getName());
}
throw ex;
}
}
Log.debugf("Successfully created or updated resource: %s %s/%s", resource.getKind(), resource.getMetadata().getNamespace(),
resource.getMetadata().getName());
return resource;
} catch (Exception e) {
Log.errorf("Failed to create or update resource %s %s/%s", resource.getKind(), resource.getMetadata().getNamespace(),
resource.getMetadata().getName());
throw KubernetesClientException.launderThrowable(e);
}
});
}
protected void setInstanceLabels(HasMetadata resource) {
resource.getMetadata().setLabels(updateWithInstanceLabels(resource.getMetadata().getLabels(), cr.getMetadata().getName()));
}
protected Map<String, String> getInstanceLabels() {
return updateWithInstanceLabels(null, cr.getMetadata().getName());
}
public static Map<String, String> updateWithInstanceLabels(Map<String, String> labels, String instanceName) {
labels = Optional.ofNullable(labels).orElse(new LinkedHashMap<>());
labels.putAll(Constants.DEFAULT_LABELS);
labels.put(Constants.INSTANCE_LABEL, instanceName);
return labels;
}
public static Map<String, String> allInstanceLabels(HasMetadata primary) {
var labels = new LinkedHashMap<>(Constants.DEFAULT_LABELS);
labels.put(Constants.INSTANCE_LABEL, primary.getMetadata().getName());
return labels;
}
protected void setOwnerReferences(HasMetadata resource) {
if (!cr.getMetadata().getNamespace().equals(resource.getMetadata().getNamespace())) {
return;
}
OwnerReference owner = new OwnerReferenceBuilder()
.withApiVersion(cr.getApiVersion())
.withKind(cr.getKind())
.withName(cr.getMetadata().getName())
.withUid(cr.getMetadata().getUid())
.withBlockOwnerDeletion(true)
.withController(true)
.build();
resource.getMetadata().setOwnerReferences(Collections.singletonList(owner));
}
protected String getNamespace() {
return cr.getMetadata().getNamespace();
}
protected abstract String getName();
}

View file

@ -31,12 +31,19 @@ import io.fabric8.kubernetes.client.ConfigBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.KubernetesClientBuilder;
import io.fabric8.kubernetes.client.KubernetesClientException;
import io.fabric8.kubernetes.client.NamespacedKubernetesClient;
import io.fabric8.kubernetes.client.dsl.Loggable;
import io.fabric8.kubernetes.client.dsl.Resource;
import io.fabric8.kubernetes.client.utils.Serialization;
import io.javaoperatorsdk.operator.Operator;
import io.javaoperatorsdk.operator.api.config.BaseConfigurationService;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.config.Utils;
import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceConfigurationResolver;
import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec;
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
import io.quarkiverse.operatorsdk.runtime.QuarkusConfigurationService;
import io.quarkus.logging.Log;
import io.quarkus.test.junit.callback.QuarkusTestAfterEachCallback;
@ -49,7 +56,7 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestInfo;
import org.keycloak.operator.Constants;
import org.keycloak.operator.controllers.KeycloakDeployment;
import org.keycloak.operator.controllers.KeycloakDeploymentDependentResource;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakSpecBuilder;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakSpecFluent.UnsupportedNested;
@ -96,7 +103,6 @@ public class BaseOperatorTest implements QuarkusTestAfterEachCallback {
public enum OperatorDeployment {local,remote}
protected static OperatorDeployment operatorDeployment;
protected static Instance<Reconciler<? extends HasMetadata>> reconcilers;
protected static QuarkusConfigurationService configuration;
protected static KubernetesClient k8sclient;
protected static String namespace;
@ -109,7 +115,6 @@ public class BaseOperatorTest implements QuarkusTestAfterEachCallback {
@BeforeAll
public static void before() throws FileNotFoundException {
configuration = CDI.current().select(QuarkusConfigurationService.class).get();
reconcilers = CDI.current().select(new TypeLiteral<>() {});
operatorDeployment = ConfigProvider.getConfig().getOptionalValue(OPERATOR_DEPLOYMENT_PROP, OperatorDeployment.class).orElse(OperatorDeployment.local);
deploymentTarget = ConfigProvider.getConfig().getOptionalValue(QUARKUS_KUBERNETES_DEPLOYMENT_TARGET, String.class).orElse("kubernetes");
customImage = ConfigProvider.getConfig().getOptionalValue(OPERATOR_CUSTOM_IMAGE, String.class).orElse(null);
@ -179,6 +184,8 @@ public class BaseOperatorTest implements QuarkusTestAfterEachCallback {
private static void registerReconcilers() {
Log.info("Registering reconcilers for operator : " + operator + " [" + operatorDeployment + "]");
Instance<Reconciler<? extends HasMetadata>> reconcilers = CDI.current().select(new TypeLiteral<>() {});
for (Reconciler<?> reconciler : reconcilers) {
Log.info("Register and apply : " + reconciler.getClass().getName());
operator.register(reconciler, overrider -> overrider.settingNamespace(namespace));
@ -186,14 +193,37 @@ public class BaseOperatorTest implements QuarkusTestAfterEachCallback {
}
private static void createOperator() {
operator = new Operator(overrider -> overrider.withKubernetesClient(k8sclient));
// create the operator to use the current client / namespace and injected dependent resources
// to be replaced later with full cdi construction or test mechanics from quarkus operator sdk
operator = new Operator(new BaseConfigurationService() {
@Override
public KubernetesClient getKubernetesClient() {
return k8sclient;
}
@Override
public DependentResourceFactory dependentResourceFactory() {
return new DependentResourceFactory<ControllerConfiguration<?>>() {
@Override
public DependentResource createFrom(DependentResourceSpec spec,
ControllerConfiguration<?> configuration) {
final var dependentResourceClass = spec.getDependentResourceClass();
// workaround for https://github.com/operator-framework/java-operator-sdk/issues/2010
// create a fresh instance of the dependentresource
CDI.current().destroy(CDI.current().select(dependentResourceClass).get());
DependentResource instance = (DependentResource) CDI.current().select(dependentResourceClass).get();
var context = Utils.contextFor(configuration, dependentResourceClass, Dependent.class);
DependentResourceConfigurationResolver.configure(instance, spec, configuration);
return instance;
}
};
}
});
}
private static void createNamespace() {
Log.info("Creating Namespace " + namespace);
k8sclient.resource(new NamespaceBuilder().withNewMetadata().addToLabels("app","keycloak-test").withName(namespace).endMetadata().build()).create();
// ensure that the client defaults to the namespace - eventually most of the test code usage of inNamespace can be removed
k8sclient = k8sclient.adapt(NamespacedKubernetesClient.class).inNamespace(namespace);
}
private static void calculateNamespace() {
@ -314,7 +344,7 @@ public class BaseOperatorTest implements QuarkusTestAfterEachCallback {
.filter(kc -> !Optional.ofNullable(kc.getStatus()).map(KeycloakStatus::isReady).orElse(false))
.forEach(kc -> {
Log.warnf("Keycloak failed to become ready \"%s\" %s", kc.getMetadata().getName(), Serialization.asYaml(kc.getStatus()));
var statefulSet = k8sclient.apps().statefulSets().withName(KeycloakDeployment.getName(kc)).get();
var statefulSet = k8sclient.apps().statefulSets().withName(KeycloakDeploymentDependentResource.getName(kc)).get();
if (statefulSet != null) {
Log.warnf("Keycloak \"%s\" StatefulSet status %s", kc.getMetadata().getName(), Serialization.asYaml(statefulSet.getStatus()));
k8sclient.pods().withLabels(statefulSet.getSpec().getSelector().getMatchLabels()).list()

View file

@ -251,7 +251,7 @@ public class KeycloakDeploymentTest extends BaseOperatorTest {
// managed changes
deployment.getSpec().getTemplate().getSpec().getContainers().get(0).setEnv(List.of(flandersEnvVar));
String originalLabelValue = deployment.getMetadata().getLabels().put(Constants.MANAGED_BY_LABEL, "not-right");
String originalAnnotationValue = deployment.getMetadata().getAnnotations().put(Constants.KEYCLOAK_WATCHING_ANNOTATION, "not-right");
deployment.getMetadata().setResourceVersion(null);
k8sclient.resource(deployment).update();
@ -266,7 +266,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().getLabels().get(Constants.MANAGED_BY_LABEL)).isEqualTo(originalLabelValue);
assertThat(d.getMetadata().getAnnotations().get(Constants.KEYCLOAK_WATCHING_ANNOTATION)).isEqualTo(originalAnnotationValue);
});
}

View file

@ -17,10 +17,7 @@
package org.keycloak.operator.testsuite.unit;
import io.fabric8.kubernetes.api.model.Container;
import io.fabric8.kubernetes.api.model.EnvVar;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
@ -39,7 +36,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@ -51,19 +47,21 @@ import static org.keycloak.operator.testsuite.utils.CRAssert.assertKeycloakStatu
@QuarkusTest
public class KeycloakDistConfiguratorTest {
final KeycloakDistConfigurator distConfig = new KeycloakDistConfigurator();
@Test
public void enabledFeatures() {
testFirstClassCitizen(Map.of("features", "docker,authorization"), KeycloakDistConfigurator::configureFeatures);
testFirstClassCitizen(Map.of("features", "docker,authorization"));
}
@Test
public void disabledFeatures() {
testFirstClassCitizen(Map.of("features-disabled", "admin,step-up-authentication"), KeycloakDistConfigurator::configureFeatures);
testFirstClassCitizen(Map.of("features-disabled", "admin,step-up-authentication"));
}
@Test
public void transactions() {
testFirstClassCitizen(Map.of("transaction-xa-enabled", "false"), KeycloakDistConfigurator::configureTransactions);
testFirstClassCitizen(Map.of("transaction-xa-enabled", "false"));
}
@Test
@ -76,17 +74,13 @@ public class KeycloakDistConfiguratorTest {
"https-certificate-key-file", Constants.CERTIFICATES_FOLDER + "/tls.key"
);
testFirstClassCitizen(expectedValues, KeycloakDistConfigurator::configureHttp);
testFirstClassCitizen(expectedValues);
}
@Test
public void featuresEmptyLists() {
final Keycloak keycloak = K8sUtils.getResourceFromFile("test-serialization-keycloak-cr-with-empty-list.yml", Keycloak.class);
final StatefulSet deployment = getBasicKcDeployment();
final KeycloakDistConfigurator distConfig = new KeycloakDistConfigurator(keycloak, deployment, null);
final List<EnvVar> envVars = deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv();
distConfig.configureFeatures();
var envVars = distConfig.configureDistOptions(keycloak);
assertEnvVarNotPresent(envVars, "KC_FEATURES");
assertEnvVarNotPresent(envVars, "KC_FEATURES_DISABLED");
}
@ -107,7 +101,7 @@ public class KeycloakDistConfiguratorTest {
));
expectedValues.put("db-url", "url");
testFirstClassCitizen(expectedValues, KeycloakDistConfigurator::configureDatabase);
testFirstClassCitizen(expectedValues);
}
@Test
@ -120,18 +114,14 @@ public class KeycloakDistConfiguratorTest {
"hostname-admin", "my-admin-hostname"
);
testFirstClassCitizen(expectedValues, KeycloakDistConfigurator::configureHostname);
testFirstClassCitizen(expectedValues);
}
@Test
public void missingHostname() {
final Keycloak keycloak = K8sUtils.getResourceFromFile("test-serialization-keycloak-cr-with-empty-list.yml", Keycloak.class);
final StatefulSet deployment = getBasicKcDeployment();
final KeycloakDistConfigurator distConfig = new KeycloakDistConfigurator(keycloak, deployment, null);
final List<EnvVar> envVars = deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv();
distConfig.configureHostname();
var envVars = distConfig.configureDistOptions(keycloak);
assertEnvVarNotPresent(envVars, "KC_HOSTNAME");
assertEnvVarNotPresent(envVars, "KC_HOSTNAME_ADMIN");
@ -142,17 +132,12 @@ public class KeycloakDistConfiguratorTest {
/* UTILS */
private void testFirstClassCitizen(Map<String, String> expectedValues, Consumer<KeycloakDistConfigurator> config) {
testFirstClassCitizen("/test-serialization-keycloak-cr.yml", expectedValues, config);
private void testFirstClassCitizen(Map<String, String> expectedValues) {
testFirstClassCitizen("/test-serialization-keycloak-cr.yml", expectedValues);
}
private void testFirstClassCitizen(String crName, Map<String, String> expectedValues, Consumer<KeycloakDistConfigurator> config) {
private void testFirstClassCitizen(String crName, Map<String, String> expectedValues) {
final Keycloak keycloak = K8sUtils.getResourceFromFile(crName, Keycloak.class);
final StatefulSet deployment = getBasicKcDeployment();
final KeycloakDistConfigurator distConfig = new KeycloakDistConfigurator(keycloak, deployment, null);
final Container container = deployment.getSpec().getTemplate().getSpec().getContainers().get(0);
assertThat(container).isNotNull();
final List<ValueOrSecret> serverConfig = expectedValues.keySet()
.stream()
@ -163,16 +148,10 @@ public class KeycloakDistConfiguratorTest {
final var expectedFields = expectedValues.keySet();
assertWarningStatusFirstClassFields(distConfig, false, expectedFields);
expectedValues.forEach((k, v) -> assertEnvVarNotPresent(container.getEnv(), getKeycloakOptionEnvVarName(k)));
assertWarningStatusFirstClassFields(keycloak, distConfig, true, expectedFields);
// mimic what KeycloakDeployment does and set all additionalOptions as env first
expectedValues.forEach((k, v) -> container.getEnv().add(new EnvVar(getKeycloakOptionEnvVarName(k), v, null)));
config.accept(distConfig);
assertWarningStatusFirstClassFields(distConfig, true, expectedFields);
expectedValues.forEach((k, v) -> assertContainerEnvVar(container.getEnv(), getKeycloakOptionEnvVarName(k), v));
var envVars = distConfig.configureDistOptions(keycloak);
expectedValues.forEach((k, v) -> assertContainerEnvVar(envVars, getKeycloakOptionEnvVarName(k), v));
}
/**
@ -210,10 +189,10 @@ public class KeycloakDistConfiguratorTest {
assertThat(containsEnvironmentVariable(envVars, varName)).isFalse();
}
private void assertWarningStatusFirstClassFields(KeycloakDistConfigurator distConfig, boolean expectWarning, Collection<String> firstClassFields) {
private void assertWarningStatusFirstClassFields(Keycloak keycloak, KeycloakDistConfigurator distConfig, boolean expectWarning, Collection<String> firstClassFields) {
final String message = "warning: You need to specify these fields as the first-class citizen of the CR: ";
final KeycloakStatusAggregator statusBuilder = new KeycloakStatusAggregator(1L);
distConfig.validateOptions(statusBuilder);
distConfig.validateOptions(keycloak, statusBuilder);
final KeycloakStatus status = statusBuilder.build();
if (expectWarning) {
@ -240,21 +219,6 @@ public class KeycloakDistConfiguratorTest {
.map(KeycloakStatusCondition::getMessage);
}
private StatefulSet getBasicKcDeployment() {
return new StatefulSetBuilder()
.withNewSpec()
.withNewTemplate()
.withNewSpec()
.addNewContainer()
.withName("keycloak")
.withArgs("start")
.endContainer()
.endSpec()
.endTemplate()
.endSpec()
.build();
}
private boolean containsEnvironmentVariable(List<EnvVar> envVars, String varName) {
if (CollectionUtil.isEmpty(envVars) || isBlank(varName)) return false;
return envVars.stream().anyMatch(f -> varName.equals(f.getName()));

View file

@ -25,57 +25,48 @@ import io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder;
import io.fabric8.kubernetes.api.model.ProbeBuilder;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.keycloak.operator.Config;
import org.keycloak.operator.controllers.KeycloakDeployment;
import org.keycloak.operator.controllers.OperatorManagedResource;
import org.keycloak.operator.Utils;
import org.keycloak.operator.controllers.KeycloakDeploymentDependentResource;
import org.keycloak.operator.controllers.WatchedSecretsController;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakBuilder;
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakSpecBuilder;
import org.keycloak.operator.crds.v2alpha1.deployment.ValueOrSecret;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HostnameSpecBuilder;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.HttpSpecBuilder;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.UnsupportedSpec;
import org.mockito.Mockito;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
import jakarta.inject.Inject;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@QuarkusTest
public class PodTemplateTest {
private StatefulSet getDeployment(PodTemplateSpec podTemplate, StatefulSet existingDeployment, Consumer<KeycloakSpecBuilder> additionalSpec) {
var config = new Config() {
@Override
public Keycloak keycloak() {
return new Keycloak() {
@Override
public String image() {
return "dummy-image";
}
@Override
public String imagePullPolicy() {
return "Never";
}
@Override
public Map<String, String> podLabels() {
return Collections.emptyMap();
}
};
}
};
@InjectMock
WatchedSecretsController watchedSecrets;
@Inject
KeycloakDeploymentDependentResource deployment;
private StatefulSet getDeployment(PodTemplateSpec podTemplate, StatefulSet existingDeployment, Consumer<KeycloakSpecBuilder> additionalSpec) {
var kc = new KeycloakBuilder().withNewMetadata().withName("instance").endMetadata().build();
existingDeployment = new StatefulSetBuilder(existingDeployment).editOrNewSpec().editOrNewSelector()
.addToMatchLabels(OperatorManagedResource.updateWithInstanceLabels(null, kc.getMetadata().getName()))
.withMatchLabels(Utils.allInstanceLabels(kc))
.endSelector().endSpec().build();
var httpSpec = new HttpSpecBuilder().withTlsSecret("example-tls-secret").build();
@ -92,9 +83,10 @@ public class PodTemplateTest {
kc.setSpec(keycloakSpecBuilder.build());
var deployment = new KeycloakDeployment(null, config, kc, existingDeployment, "dummy-admin");
Context<Keycloak> context = Mockito.mock(Context.class);
Mockito.when(context.getSecondaryResource(StatefulSet.class)).thenReturn(Optional.ofNullable(existingDeployment));
return deployment.getReconciledResource().get();
return deployment.desired(kc, context);
}
private StatefulSet getDeployment(PodTemplateSpec podTemplate, StatefulSet existingDeployment) {