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