From 7311e1206688d092dc2b766ad084d82fb8d0eb23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Barto=C5=A1?= Date: Tue, 11 Oct 2022 11:34:32 +0200 Subject: [PATCH] Add features options to Keycloak CR Closes #14376 --- .../keycloak/common/util/CollectionUtil.java | 14 + .../controllers/KeycloakDeployment.java | 177 ++---------- .../controllers/KeycloakDeploymentConfig.java | 272 ++++++++++++++++++ .../v2alpha1/deployment/KeycloakSpec.java | 26 +- .../v2alpha1/deployment/spec/FeatureSpec.java | 55 ++++ .../testsuite/unit/CRSerializationTest.java | 26 +- .../unit/KeycloakDeploymentConfigTest.java | 124 ++++++++ .../test-serialization-keycloak-cr.yml | 11 +- 8 files changed, 541 insertions(+), 164 deletions(-) create mode 100644 operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeploymentConfig.java create mode 100644 operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/spec/FeatureSpec.java create mode 100644 operator/src/test/java/org/keycloak/operator/testsuite/unit/KeycloakDeploymentConfigTest.java diff --git a/common/src/main/java/org/keycloak/common/util/CollectionUtil.java b/common/src/main/java/org/keycloak/common/util/CollectionUtil.java index a8ab2d465e..9adc13f4b3 100644 --- a/common/src/main/java/org/keycloak/common/util/CollectionUtil.java +++ b/common/src/main/java/org/keycloak/common/util/CollectionUtil.java @@ -18,9 +18,12 @@ package org.keycloak.common.util; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; /** * @author Jeroen Rosenberg @@ -75,4 +78,15 @@ public class CollectionUtil { public static boolean isNotEmpty(Collection collection) { return !isEmpty(collection); } + + public static Set intersection(Collection col1, Collection col2) { + if (isEmpty(col1) || isEmpty(col2)) return Collections.emptySet(); + + final Collection iteratorCollection = col1.size() <= col2.size() ? col1 : col2; + final Collection searchCollection = iteratorCollection.equals(col1) ? col2 : col1; + + return iteratorCollection.stream() + .filter(searchCollection::contains) + .collect(Collectors.toSet()); + } } diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeployment.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeployment.java index cbbb428aa2..6021eddef0 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeployment.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeployment.java @@ -20,12 +20,9 @@ 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.EnvVarSourceBuilder; -import io.fabric8.kubernetes.api.model.ExecActionBuilder; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.PodTemplateSpec; import io.fabric8.kubernetes.api.model.ResourceRequirements; -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.client.KubernetesClient; @@ -37,9 +34,7 @@ import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak; import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakStatusBuilder; import org.keycloak.operator.crds.v2alpha1.deployment.ValueOrSecret; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Base64; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -53,7 +48,9 @@ import static io.smallrye.config.common.utils.StringUtil.replaceNonAlphanumericB public class KeycloakDeployment extends OperatorManagedResource implements StatusUpdater { - private final Config config; + private final Config operatorConfig; + private final KeycloakDeploymentConfig deploymentConfig; + private final Keycloak keycloakCR; private final StatefulSet existingDeployment; private final StatefulSet baseDeployment; @@ -65,20 +62,20 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu public KeycloakDeployment(KubernetesClient client, Config config, Keycloak keycloakCR, StatefulSet existingDeployment, String adminSecretName) { super(client, keycloakCR); - this.config = config; + this.operatorConfig = config; this.keycloakCR = keycloakCR; this.adminSecretName = adminSecretName; if (existingDeployment != null) { Log.info("Existing Deployment provided by controller"); this.existingDeployment = existingDeployment; - } - else { + } else { Log.info("Trying to fetch existing Deployment from the API"); this.existingDeployment = fetchExistingDeployment(); } - baseDeployment = createBaseDeployment(); + this.baseDeployment = createBaseDeployment(); + this.deploymentConfig = createDeploymentConfig(); } @Override @@ -329,149 +326,6 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu } } - private void configureHostname(StatefulSet deployment) { - var kcContainer = deployment.getSpec().getTemplate().getSpec().getContainers().get(0); - var hostname = this.keycloakCR.getSpec().getHostname(); - var envVars = kcContainer.getEnv(); - if (this.keycloakCR.getSpec().isHostnameDisabled()) { - var disableStrictHostname = List.of( - new EnvVarBuilder() - .withName("KC_HOSTNAME_STRICT") - .withValue("false") - .build(), - new EnvVarBuilder() - .withName("KC_HOSTNAME_STRICT_BACKCHANNEL") - .withValue("false") - .build()); - - envVars.addAll(disableStrictHostname); - } else { - var enabledStrictHostname = List.of( - new EnvVarBuilder() - .withName("KC_HOSTNAME") - .withValue(hostname) - .build()); - - envVars.addAll(enabledStrictHostname); - } - } - - private void configureTLS(StatefulSet deployment) { - var kcContainer = deployment.getSpec().getTemplate().getSpec().getContainers().get(0); - var tlsSecret = this.keycloakCR.getSpec().getTlsSecret(); - var envVars = kcContainer.getEnv(); - - if (this.keycloakCR.getSpec().isHttp()) { - var disableTls = List.of( - new EnvVarBuilder() - .withName("KC_HTTP_ENABLED") - .withValue("true") - .build(), - new EnvVarBuilder() - .withName("KC_HOSTNAME_STRICT_HTTPS") - .withValue("false") - .build(), - new EnvVarBuilder() - .withName("KC_PROXY") - .withValue("edge") - .build()); - - envVars.addAll(disableTls); - } else { - var enabledTls = List.of( - new EnvVarBuilder() - .withName("KC_HTTPS_CERTIFICATE_FILE") - .withValue(Constants.CERTIFICATES_FOLDER + "/tls.crt") - .build(), - new EnvVarBuilder() - .withName("KC_HTTPS_CERTIFICATE_KEY_FILE") - .withValue(Constants.CERTIFICATES_FOLDER + "/tls.key") - .build(), - new EnvVarBuilder() - .withName("KC_PROXY") - .withValue("passthrough") - .build()); - - envVars.addAll(enabledTls); - - var volume = new VolumeBuilder() - .withName("keycloak-tls-certificates") - .withNewSecret() - .withSecretName(tlsSecret) - .withOptional(false) - .endSecret() - .build(); - - var volumeMount = new VolumeMountBuilder() - .withName(volume.getName()) - .withMountPath(Constants.CERTIFICATES_FOLDER) - .build(); - - deployment.getSpec().getTemplate().getSpec().getVolumes().add(volume); - kcContainer.getVolumeMounts().add(volumeMount); - } - - var userRelativePath = readConfigurationValue(Constants.KEYCLOAK_HTTP_RELATIVE_PATH_KEY); - var kcRelativePath = (userRelativePath == null) ? "" : userRelativePath; - var protocol = (this.keycloakCR.getSpec().isHttp()) ? "http" : "https"; - var kcPort = (this.keycloakCR.getSpec().isHttp()) ? Constants.KEYCLOAK_HTTP_PORT : Constants.KEYCLOAK_HTTPS_PORT; - - var baseProbe = new ArrayList<>(List.of("curl", "--head", "--fail", "--silent")); - - if (!this.keycloakCR.getSpec().isHttp()) { - baseProbe.add("--insecure"); - } - - var readyProbe = new ArrayList<>(baseProbe); - readyProbe.add(protocol + "://127.0.0.1:" + kcPort + kcRelativePath + "/health/ready"); - var liveProbe = new ArrayList<>(baseProbe); - liveProbe.add(protocol + "://127.0.0.1:" + kcPort + kcRelativePath + "/health/live"); - - kcContainer - .getReadinessProbe() - .setExec(new ExecActionBuilder().withCommand(readyProbe).build()); - kcContainer - .getLivenessProbe() - .setExec(new ExecActionBuilder().withCommand(liveProbe).build()); - } - - public String readConfigurationValue(String key) { - if (this.keycloakCR != null && - this.keycloakCR.getSpec() != null && - this.keycloakCR.getSpec().getServerConfiguration() != null - ) { - var serverConfigValue = this.keycloakCR - .getSpec() - .getServerConfiguration() - .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(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; - } - } - private StatefulSet createBaseDeployment() { StatefulSet baseDeployment = new StatefulSetBuilder() .withNewMetadata() @@ -523,7 +377,7 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu Container container = baseDeployment.getSpec().getTemplate().getSpec().getContainers().get(0); var customImage = Optional.ofNullable(keycloakCR.getSpec().getImage()); - container.setImage(customImage.orElse(config.keycloak().image())); + container.setImage(customImage.orElse(operatorConfig.keycloak().image())); if (customImage.isPresent()) { container.getArgs().add("--optimized"); @@ -533,17 +387,20 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu baseDeployment.getSpec().getTemplate().getSpec().setImagePullSecrets(keycloakCR.getSpec().getImagePullSecrets()); } - container.setImagePullPolicy(config.keycloak().imagePullPolicy()); + container.setImagePullPolicy(operatorConfig.keycloak().imagePullPolicy()); container.setEnv(getEnvVars()); - configureHostname(baseDeployment); - configureTLS(baseDeployment); - mergePodTemplate(baseDeployment.getSpec().getTemplate()); - return baseDeployment; } + private KeycloakDeploymentConfig createDeploymentConfig() { + final KeycloakDeploymentConfig config = new KeycloakDeploymentConfig(keycloakCR, baseDeployment, client); + config.configureProperties(); + mergePodTemplate(baseDeployment.getSpec().getTemplate()); + return config; + } + private List getEnvVars() { // default config values List serverConfig = Constants.DEFAULT_DIST_CONFIG.entrySet().stream() @@ -627,6 +484,8 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu && !existingDeployment.getStatus().getCurrentRevision().equals(existingDeployment.getStatus().getUpdateRevision())) { status.addRollingUpdateMessage("Rolling out deployment update"); } + + deploymentConfig.validateProperties(status); } public Set getConfigSecretsNames() { diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeploymentConfig.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeploymentConfig.java new file mode 100644 index 0000000000..7761e64da2 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeploymentConfig.java @@ -0,0 +1,272 @@ +/* + * 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.EnvVarBuilder; +import io.fabric8.kubernetes.api.model.ExecActionBuilder; +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 org.keycloak.common.util.CollectionUtil; +import org.keycloak.operator.Constants; +import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak; +import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakStatusBuilder; +import org.keycloak.operator.crds.v2alpha1.deployment.ValueOrSecret; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Configuration for the KeycloakDeployment + */ +public class KeycloakDeploymentConfig { + private final Keycloak keycloakCR; + private final StatefulSet deployment; + private final KubernetesClient client; + + public KeycloakDeploymentConfig(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 static List FIRST_CLASS_FIELDS = List.of( + "hostname", + "tlsSecret", + "features", + "features-disabled" + ); + + /** + * Configure configuration properties for the KeycloakDeployment + */ + protected void configureProperties() { + configureHostname(); + configureTLS(); + configureFeatures(); + } + + /** + * Validate all deployment configuration properties and update status of the Keycloak deployment + * + * @param status Keycloak Status builder + */ + protected void validateProperties(KeycloakStatusBuilder status) { + assumeFirstClassCitizens(status); + } + + /* ---------- Configuration of first-class citizen fields ---------- */ + + public void configureHostname() { + var kcContainer = deployment.getSpec().getTemplate().getSpec().getContainers().get(0); + var hostname = keycloakCR.getSpec().getHostname(); + var envVars = kcContainer.getEnv(); + if (keycloakCR.getSpec().isHostnameDisabled()) { + var disableStrictHostname = List.of( + new EnvVarBuilder() + .withName("KC_HOSTNAME_STRICT") + .withValue("false") + .build(), + new EnvVarBuilder() + .withName("KC_HOSTNAME_STRICT_BACKCHANNEL") + .withValue("false") + .build()); + + envVars.addAll(disableStrictHostname); + } else { + var enabledStrictHostname = List.of( + new EnvVarBuilder() + .withName("KC_HOSTNAME") + .withValue(hostname) + .build()); + + envVars.addAll(enabledStrictHostname); + } + } + + public void configureTLS() { + var kcContainer = deployment.getSpec().getTemplate().getSpec().getContainers().get(0); + var tlsSecret = keycloakCR.getSpec().getTlsSecret(); + var envVars = kcContainer.getEnv(); + + if (keycloakCR.getSpec().isHttp()) { + var disableTls = List.of( + new EnvVarBuilder() + .withName("KC_HTTP_ENABLED") + .withValue("true") + .build(), + new EnvVarBuilder() + .withName("KC_HOSTNAME_STRICT_HTTPS") + .withValue("false") + .build(), + new EnvVarBuilder() + .withName("KC_PROXY") + .withValue("edge") + .build()); + + envVars.addAll(disableTls); + } else { + var enabledTls = List.of( + new EnvVarBuilder() + .withName("KC_HTTPS_CERTIFICATE_FILE") + .withValue(Constants.CERTIFICATES_FOLDER + "/tls.crt") + .build(), + new EnvVarBuilder() + .withName("KC_HTTPS_CERTIFICATE_KEY_FILE") + .withValue(Constants.CERTIFICATES_FOLDER + "/tls.key") + .build(), + new EnvVarBuilder() + .withName("KC_PROXY") + .withValue("passthrough") + .build()); + + envVars.addAll(enabledTls); + + var volume = new VolumeBuilder() + .withName("keycloak-tls-certificates") + .withNewSecret() + .withSecretName(tlsSecret) + .withOptional(false) + .endSecret() + .build(); + + var volumeMount = new VolumeMountBuilder() + .withName(volume.getName()) + .withMountPath(Constants.CERTIFICATES_FOLDER) + .build(); + + deployment.getSpec().getTemplate().getSpec().getVolumes().add(volume); + kcContainer.getVolumeMounts().add(volumeMount); + } + + var userRelativePath = readConfigurationValue(Constants.KEYCLOAK_HTTP_RELATIVE_PATH_KEY); + var kcRelativePath = (userRelativePath == null) ? "" : userRelativePath; + var protocol = (keycloakCR.getSpec().isHttp()) ? "http" : "https"; + var kcPort = (keycloakCR.getSpec().isHttp()) ? Constants.KEYCLOAK_HTTP_PORT : Constants.KEYCLOAK_HTTPS_PORT; + + var baseProbe = new ArrayList<>(List.of("curl", "--head", "--fail", "--silent")); + + if (!keycloakCR.getSpec().isHttp()) { + baseProbe.add("--insecure"); + } + + var readyProbe = new ArrayList<>(baseProbe); + readyProbe.add(protocol + "://127.0.0.1:" + kcPort + kcRelativePath + "/health/ready"); + var liveProbe = new ArrayList<>(baseProbe); + liveProbe.add(protocol + "://127.0.0.1:" + kcPort + kcRelativePath + "/health/live"); + + kcContainer + .getReadinessProbe() + .setExec(new ExecActionBuilder().withCommand(readyProbe).build()); + kcContainer + .getLivenessProbe() + .setExec(new ExecActionBuilder().withCommand(liveProbe).build()); + } + + public void configureFeatures() { + var featureSpec = keycloakCR.getSpec().getFeatureSpec(); + if (featureSpec == null) return; + + var kcContainer = deployment.getSpec().getTemplate().getSpec().getContainers().get(0); + var envVars = kcContainer.getEnv(); + var enabledFeatures = featureSpec.getEnabledFeatures(); + var disabledFeatures = featureSpec.getDisabledFeatures(); + + if (CollectionUtil.isNotEmpty(enabledFeatures)) { + envVars.add(new EnvVarBuilder() + .withName("KC_FEATURES") + .withValue(CollectionUtil.join(enabledFeatures, ",")) + .build()); + } + + if (CollectionUtil.isNotEmpty(disabledFeatures)) { + envVars.add(new EnvVarBuilder() + .withName("KC_FEATURES_DISABLED") + .withValue(CollectionUtil.join(disabledFeatures, ",")) + .build()); + } + } + + /* ---------- END of configuration of first-class citizen fields ---------- */ + + protected String readConfigurationValue(String key) { + if (keycloakCR != null && + keycloakCR.getSpec() != null && + keycloakCR.getSpec().getServerConfiguration() != null + ) { + + var serverConfigValue = keycloakCR + .getSpec() + .getServerConfiguration() + .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; + } + } + + /** + * Assume the specified first-class citizens are not included in the general server configuration + * + * @param status Status of the deployment + */ + protected void assumeFirstClassCitizens(KeycloakStatusBuilder status) { + final var serverConfigNames = keycloakCR + .getSpec() + .getServerConfiguration() + .stream() + .map(ValueOrSecret::getName) + .collect(Collectors.toSet()); + + final var sameItems = CollectionUtil.intersection(serverConfigNames, FIRST_CLASS_FIELDS); + if (CollectionUtil.isNotEmpty(sameItems)) { + status.addWarningMessage("You need to specify these fields as the first-class citizen of the CR: " + + CollectionUtil.join(sameItems, ",")); + } + } +} diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/KeycloakSpec.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/KeycloakSpec.java index 7c8be4c668..5320fc9962 100644 --- a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/KeycloakSpec.java +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/KeycloakSpec.java @@ -17,10 +17,12 @@ package org.keycloak.operator.crds.v2alpha1.deployment; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; import io.fabric8.kubernetes.api.model.LocalObjectReference; import org.keycloak.operator.Constants; +import org.keycloak.operator.crds.v2alpha1.deployment.spec.FeatureSpec; import javax.validation.constraints.NotNull; import java.util.List; @@ -29,10 +31,13 @@ public class KeycloakSpec { @JsonPropertyDescription("Number of Keycloak instances in HA mode. Default is 1.") private int instances = 1; + @JsonPropertyDescription("Custom Keycloak image to be used.") private String image; + @JsonPropertyDescription("Secret(s) that might be used when pulling an image from a private container image registry or repository.") private List imagePullSecrets; + @JsonPropertyDescription("Configuration of the Keycloak server.\n" + "expressed as a keys (reference: https://www.keycloak.org/server/all-config) and values that can be either direct values or references to secrets.") private List serverConfiguration; // can't use Set due to a bug in Sundrio https://github.com/sundrio/sundrio/issues/316 @@ -44,17 +49,24 @@ public class KeycloakSpec { @JsonPropertyDescription("Hostname for the Keycloak server.\n" + "The special value `" + Constants.INSECURE_DISABLE + "` disables the hostname strict resolution.") private String hostname; + @NotNull @JsonPropertyDescription("A secret containing the TLS configuration for HTTPS. Reference: https://kubernetes.io/docs/concepts/configuration/secret/#tls-secrets.\n" + "The special value `" + Constants.INSECURE_DISABLE + "` disables https.") private String tlsSecret; + @JsonPropertyDescription("Disable the default ingress.") private boolean disableDefaultIngress; + @JsonPropertyDescription( - "In this section you can configure podTemplate advanced features, not production-ready, and not supported settings.\n" + - "Use at your own risk and open an issue with your use-case if you don't find an alternative way.") + "In this section you can configure podTemplate advanced features, not production-ready, and not supported settings.\n" + + "Use at your own risk and open an issue with your use-case if you don't find an alternative way.") private KeycloakSpecUnsupported unsupported; + @JsonProperty("features") + @JsonPropertyDescription("In this section you can configure Keycloak features, which should be enabled/disabled.") + private FeatureSpec featureSpec; + public String getHostname() { return hostname; } @@ -97,6 +109,14 @@ public class KeycloakSpec { this.unsupported = unsupported; } + public FeatureSpec getFeatureSpec() { + return featureSpec; + } + + public void setFeatureSpec(FeatureSpec featureSpec) { + this.featureSpec = featureSpec; + } + public int getInstances() { return instances; } @@ -128,4 +148,4 @@ public class KeycloakSpec { public void setServerConfiguration(List serverConfiguration) { this.serverConfiguration = serverConfiguration; } -} +} \ No newline at end of file diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/spec/FeatureSpec.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/spec/FeatureSpec.java new file mode 100644 index 0000000000..478ee43cf1 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/deployment/spec/FeatureSpec.java @@ -0,0 +1,55 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import io.sundr.builder.annotations.Buildable; + +import java.io.Serializable; +import java.util.List; + +@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder") +@JsonPropertyOrder({"enabled", "disabled"}) +public class FeatureSpec implements Serializable { + + @JsonProperty("enabled") + @JsonPropertyDescription("Enabled Keycloak features") + private List enabledFeatures; + + @JsonProperty("disabled") + @JsonPropertyDescription("Disabled Keycloak features") + private List disabledFeatures; + + public List getEnabledFeatures() { + return enabledFeatures; + } + + public void setEnabledFeatures(List enabledFeatures) { + this.enabledFeatures = enabledFeatures; + } + + public List getDisabledFeatures() { + return disabledFeatures; + } + + public void setDisabledFeatures(List disabledFeatures) { + this.disabledFeatures = disabledFeatures; + } +} \ No newline at end of file diff --git a/operator/src/test/java/org/keycloak/operator/testsuite/unit/CRSerializationTest.java b/operator/src/test/java/org/keycloak/operator/testsuite/unit/CRSerializationTest.java index 340a43838b..8888ee651f 100644 --- a/operator/src/test/java/org/keycloak/operator/testsuite/unit/CRSerializationTest.java +++ b/operator/src/test/java/org/keycloak/operator/testsuite/unit/CRSerializationTest.java @@ -18,9 +18,15 @@ package org.keycloak.operator.testsuite.unit; import io.fabric8.kubernetes.client.utils.Serialization; +import org.hamcrest.CoreMatchers; import org.junit.jupiter.api.Test; import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak; +import org.keycloak.operator.crds.v2alpha1.deployment.spec.FeatureSpec; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.notNullValue; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -36,4 +42,22 @@ public class CRSerializationTest { assertTrue(keycloak.getSpec().isDisableDefaultIngress()); } -} + @Test + public void featureSpecificationDeserialization(){ + Keycloak keycloak = Serialization.unmarshal(this.getClass().getResourceAsStream("/test-serialization-keycloak-cr.yml"), Keycloak.class); + + final FeatureSpec featureSpec = keycloak.getSpec().getFeatureSpec(); + assertThat(featureSpec, notNullValue()); + + final List enabledFeatures = featureSpec.getEnabledFeatures(); + assertThat(enabledFeatures.size(), CoreMatchers.is(2)); + assertThat(enabledFeatures.get(0), CoreMatchers.is("docker")); + assertThat(enabledFeatures.get(1), CoreMatchers.is("authorization")); + + final List disabledFeatures = featureSpec.getDisabledFeatures(); + assertThat(disabledFeatures.size(), CoreMatchers.is(2)); + assertThat(disabledFeatures.get(0), CoreMatchers.is("admin")); + assertThat(disabledFeatures.get(1), CoreMatchers.is("step-up-authentication")); + } + +} \ No newline at end of file diff --git a/operator/src/test/java/org/keycloak/operator/testsuite/unit/KeycloakDeploymentConfigTest.java b/operator/src/test/java/org/keycloak/operator/testsuite/unit/KeycloakDeploymentConfigTest.java new file mode 100644 index 0000000000..c421c92f9a --- /dev/null +++ b/operator/src/test/java/org/keycloak/operator/testsuite/unit/KeycloakDeploymentConfigTest.java @@ -0,0 +1,124 @@ +/* + * 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.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; +import org.keycloak.common.util.CollectionUtil; +import org.keycloak.common.util.ObjectUtil; +import org.keycloak.operator.controllers.KeycloakDeploymentConfig; +import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak; +import org.keycloak.operator.testsuite.utils.K8sUtils; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@QuarkusTest +public class KeycloakDeploymentConfigTest { + + @Test + public void enabledFeatures() { + testFeatures(true, "docker", "authorization"); + } + + @Test + public void disabledFeatures() { + testFeatures(false, "admin", "step-up-authentication"); + } + + private void testFeatures(boolean enabledFeatures, String... features) { + final String featureEnvVar = enabledFeatures ? "KC_FEATURES" : "KC_FEATURES_DISABLED"; + + final Keycloak keycloak = K8sUtils.getResourceFromFile("/test-serialization-keycloak-cr.yml", Keycloak.class); + final StatefulSet deployment = getBasicKcDeployment(); + final KeycloakDeploymentConfig deploymentConfig = new KeycloakDeploymentConfig(keycloak, deployment, null); + + final Container container = deployment.getSpec().getTemplate().getSpec().getContainers().get(0); + assertThat(container).isNotNull(); + + assertEnvVarNotPresent(container.getEnv(), featureEnvVar); + + deploymentConfig.configureFeatures(); + + assertContainerEnvVar(container.getEnv(), featureEnvVar, features); + } + + /** + * assertContainerEnvVar(container.getEnv(), "KC_FEATURES", "admin,authorization"); + * assertContainerEnvVar(container.getEnv(), "KC_HOSTNAME", "someHostname"); + */ + private void assertContainerEnvVar(List envVars, String varName, String... expectedValue) { + assertThat(envVars).isNotNull(); + assertEnvVarPresent(envVars, varName); + + final List foundValues = getValuesFromEnvVar(envVars, varName); + assertThat(CollectionUtil.isNotEmpty(foundValues)).isTrue(); + for (String val : expectedValue) { + assertThat(foundValues.contains(val)).isTrue(); + } + } + + private void assertEnvVarPresent(List envVars, String varName) { + assertThat(containsEnvironmentVariable(envVars, varName)).isTrue(); + + } + + private void assertEnvVarNotPresent(List envVars, String varName) { + assertThat(containsEnvironmentVariable(envVars, varName)).isFalse(); + } + + private StatefulSet getBasicKcDeployment() { + return new StatefulSetBuilder() + .withNewSpec() + .withNewTemplate() + .withNewSpec() + .addNewContainer() + .withName("keycloak") + .withArgs("start") + .endContainer() + .endSpec() + .endTemplate() + .endSpec() + .build(); + } + + private boolean containsEnvironmentVariable(List envVars, String varName) { + if (CollectionUtil.isEmpty(envVars) || ObjectUtil.isBlank(varName)) return false; + return envVars.stream().anyMatch(f -> varName.equals(f.getName())); + } + + /** + * Returns values of environment variable separated by comma (f.e KC_FEATURES=admin2,ciba) + */ + private List getValuesFromEnvVar(List envVars, String varName) { + if (CollectionUtil.isEmpty(envVars) || ObjectUtil.isBlank(varName)) return Collections.emptyList(); + + return envVars.stream().filter(f -> varName.equals(f.getName())) + .findFirst() + .map(EnvVar::getValue) + .map(f -> f.split(",")) + .map(List::of) + .orElseGet(Collections::emptyList); + } +} diff --git a/operator/src/test/resources/test-serialization-keycloak-cr.yml b/operator/src/test/resources/test-serialization-keycloak-cr.yml index 2daf28c64b..5d20ad26cf 100644 --- a/operator/src/test/resources/test-serialization-keycloak-cr.yml +++ b/operator/src/test/resources/test-serialization-keycloak-cr.yml @@ -8,11 +8,20 @@ spec: serverConfiguration: - name: key1 value: value1 + - name: features + value: docker hostname: my-hostname tlsSecret: my-tls-secret + features: + enabled: + - docker + - authorization + disabled: + - admin + - step-up-authentication disableDefaultIngress: true unsupported: podTemplate: metadata: labels: - my-label: "foo" + my-label: "foo" \ No newline at end of file