From 22f8e5cdf0b703578fe8aa9367cd28beae72cb9f Mon Sep 17 00:00:00 2001 From: Steven Hawkins Date: Mon, 29 Jul 2024 05:16:09 -0400 Subject: [PATCH] Added field to the RealmImport spec to replace environment variables within the realm import (#31232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added field to the RealmImport spec to replace environment variables within the realm import Closes #26470 Signed-off-by: stustison * Added field to the RealmImport spec to replace environment variables within the realm import Closes #26470 Signed-off-by: stustison * testing refinement for placeholder handling closes: #26470 Signed-off-by: Steve Hawkins * changing from placeholdersecret to placeholder Signed-off-by: Steve Hawkins * Update docs/guides/operator/realm-import.adoc Co-authored-by: Martin Bartoš Signed-off-by: Steven Hawkins * Update docs/documentation/release_notes/topics/26_0_0.adoc Co-authored-by: Martin Bartoš Signed-off-by: Steven Hawkins --------- Signed-off-by: stustison Signed-off-by: Steve Hawkins Signed-off-by: Steven Hawkins Co-authored-by: stustison Co-authored-by: Martin Bartoš --- .../release_notes/topics/26_0_0.adoc | 7 +++ docs/guides/operator/realm-import.adoc | 24 ++++++++ ...ycloakRealmImportJobDependentResource.java | 29 +++++++-- .../realmimport/KeycloakRealmImportSpec.java | 18 ++++++ .../v2alpha1/realmimport/Placeholder.java | 61 +++++++++++++++++++ .../integration/RealmImportTest.java | 55 ++++++++++++++++- .../test/resources/example-smtp-secret.yaml | 14 +++++ 7 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 operator/src/main/java/org/keycloak/operator/crds/v2alpha1/realmimport/Placeholder.java create mode 100644 operator/src/test/resources/example-smtp-secret.yaml diff --git a/docs/documentation/release_notes/topics/26_0_0.adoc b/docs/documentation/release_notes/topics/26_0_0.adoc index 63e28c10b1a..205faef72c6 100644 --- a/docs/documentation/release_notes/topics/26_0_0.adoc +++ b/docs/documentation/release_notes/topics/26_0_0.adoc @@ -43,6 +43,13 @@ The Keycloak CR now exposes first class properties for controlling the schedulin For more details, see the https://www.keycloak.org/operator/advanced-configuration[Operator Advanced Configuration]. += KeycloakRealmImport CR supports placeholder replacement + +The KeycloakRealmImport CR now exposes `spec.placeholders` to create environment variables for placeholder replacement in the import. + +For more details, see the +https://www.keycloak.org/operator/realm-import[Operator Realm Import]. + = Configuring the LDAP Connection Pool In this release, the LDAP connection pool configuration relies solely on system properties. diff --git a/docs/guides/operator/realm-import.adoc b/docs/guides/operator/realm-import.adoc index 5ce4bcee2f9..553d1453937 100644 --- a/docs/guides/operator/realm-import.adoc +++ b/docs/guides/operator/realm-import.adoc @@ -94,4 +94,28 @@ CONDITION: HasErrors MESSAGE: ---- +=== Placeholders + +Imports support placeholders referencing environment variables, see <@links.server id="importExport"/> for more. +The `KeycloakRealmImport` CR allows you to leverage this functionality via the `spec.placeholders` stanza, for example: + +[source,yaml] +---- +apiVersion: k8s.keycloak.org/v2alpha1 +kind: KeycloakRealmImport +metadata: + name: my-realm-kc +spec: + keycloakCRName: + placeholders: + ENV_KEY: + secret: + name: SECRET_NAME + key: SECRET_KEY + ... +---- + +In the above example placeholder replacement will be enabled and an environment variable with key `ENV_KEY` will be created from the Secret `SECRET_NAME`'s value for key `SECRET_KEY`. +Currently only Secrets are supported and they must be in the same namespace as the Keycloak CR. + diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakRealmImportJobDependentResource.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakRealmImportJobDependentResource.java index b5ca010804b..476c49a7fc1 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakRealmImportJobDependentResource.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakRealmImportJobDependentResource.java @@ -33,13 +33,14 @@ import io.javaoperatorsdk.operator.processing.dependent.Creator; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfigBuilder; -import jakarta.inject.Inject; import org.keycloak.operator.Config; import org.keycloak.operator.Constants; import org.keycloak.operator.Utils; import org.keycloak.operator.crds.v2alpha1.realmimport.KeycloakRealmImport; +import org.keycloak.operator.crds.v2alpha1.realmimport.Placeholder; import java.util.List; +import java.util.Map; import java.util.Set; import static org.keycloak.operator.Utils.addResources; @@ -60,6 +61,8 @@ public class KeycloakRealmImportJobDependentResource extends KubernetesDependent @Override protected Job desired(KeycloakRealmImport primary, Context context) { StatefulSet existingDeployment = context.managedDependentResourceContext().get(StatefulSet.class, StatefulSet.class).orElseThrow(); + Map placeholders = primary.getSpec().getPlaceholders(); + boolean replacePlaceholders = (placeholders != null && !placeholders.isEmpty()); var keycloakPodTemplate = existingDeployment .getSpec() @@ -68,7 +71,7 @@ public class KeycloakRealmImportJobDependentResource extends KubernetesDependent String secretName = KeycloakRealmImportSecretDependentResource.getSecretName(primary); String volumeName = KubernetesResourceUtil.sanitizeName(secretName + "-volume"); - buildKeycloakJobContainer(keycloakPodTemplate.getSpec().getContainers().get(0), primary, volumeName); + buildKeycloakJobContainer(keycloakPodTemplate.getSpec().getContainers().get(0), primary, volumeName, replacePlaceholders); keycloakPodTemplate.getSpec().getVolumes().add(buildSecretVolume(volumeName, secretName)); var labels = keycloakPodTemplate.getMetadata().getLabels(); @@ -93,6 +96,22 @@ public class KeycloakRealmImportJobDependentResource extends KubernetesDependent // The Job doesn't need health to be enabled envvars.add(new EnvVarBuilder().withName(healthEnvVarName).withValue("false").build()); + if (replacePlaceholders) { + for (Map.Entry secret : primary.getSpec().getPlaceholders().entrySet()) { + envvars.add( + new EnvVarBuilder() + .withName(secret.getKey()) + .withNewValueFrom() + .withNewSecretKeyRef() + .withName(secret.getValue().getSecret().getName()) + .withKey(secret.getValue().getSecret().getKey()) + .withOptional(false) + .endSecretKeyRef() + .endValueFrom() + .build()); + } + } + return buildJob(keycloakPodTemplate, primary); } @@ -121,7 +140,7 @@ public class KeycloakRealmImportJobDependentResource extends KubernetesDependent .build(); } - private void buildKeycloakJobContainer(Container keycloakContainer, KeycloakRealmImport keycloakRealmImport, String volumeName) { + private void buildKeycloakJobContainer(Container keycloakContainer, KeycloakRealmImport keycloakRealmImport, String volumeName, boolean replacePlaceholders) { var importMntPath = "/mnt/realm-import/"; var command = List.of("/bin/bash"); @@ -130,8 +149,10 @@ public class KeycloakRealmImportJobDependentResource extends KubernetesDependent var runBuild = !keycloakContainer.getArgs().contains(KeycloakDeploymentDependentResource.OPTIMIZED_ARG) ? "/opt/keycloak/bin/kc.sh --verbose build && " : ""; + var replaceOption = (replacePlaceholders) ? " -Dkeycloak.migration.replace-placeholders=true": ""; + var commandArgs = List.of("-c", - runBuild + "/opt/keycloak/bin/kc.sh --verbose import --optimized --file='" + importMntPath + keycloakRealmImport.getRealmName() + "-realm.json' " + override); + runBuild + "/opt/keycloak/bin/kc.sh" + replaceOption + " --verbose import --optimized --file='" + importMntPath + keycloakRealmImport.getRealmName() + "-realm.json' " + override); keycloakContainer.setCommand(command); keycloakContainer.setArgs(commandArgs); diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/realmimport/KeycloakRealmImportSpec.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/realmimport/KeycloakRealmImportSpec.java index cd97b26ac05..abd89d043fd 100644 --- a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/realmimport/KeycloakRealmImportSpec.java +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/realmimport/KeycloakRealmImportSpec.java @@ -16,12 +16,19 @@ */ package org.keycloak.operator.crds.v2alpha1.realmimport; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; import io.fabric8.generator.annotation.Required; import io.fabric8.kubernetes.api.model.ResourceRequirements; +import io.sundr.builder.annotations.Buildable; + import org.keycloak.representations.idm.RealmRepresentation; +import java.util.Map; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder") public class KeycloakRealmImportSpec { @Required @@ -35,6 +42,9 @@ public class KeycloakRealmImportSpec { @JsonPropertyDescription("Compute Resources required by Keycloak container. If not specified, the value is inherited from the Keycloak CR.") private ResourceRequirements resourceRequirements; + @JsonPropertyDescription("Optionally set to replace ENV variable placeholders in the realm import.") + private Map placeholders; + public String getKeycloakCRName() { return keycloakCRName; } @@ -58,4 +68,12 @@ public class KeycloakRealmImportSpec { public void setResourceRequirements(ResourceRequirements resourceRequirements) { this.resourceRequirements = resourceRequirements; } + + public Map getPlaceholders() { + return placeholders; + } + + public void setPlaceholders(Map placeholders) { + this.placeholders = placeholders; + } } diff --git a/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/realmimport/Placeholder.java b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/realmimport/Placeholder.java new file mode 100644 index 00000000000..2e143c6f0db --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/crds/v2alpha1/realmimport/Placeholder.java @@ -0,0 +1,61 @@ +/* + * 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.realmimport; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.fabric8.kubernetes.api.model.SecretKeySelector; +import io.sundr.builder.annotations.Buildable; + +import java.util.Objects; + +/** + * @author Scott Tustison + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder") +public class Placeholder { + private SecretKeySelector secret; + + public Placeholder() { + } + + public Placeholder(SecretKeySelector secret) { + this.secret = secret; + } + + public SecretKeySelector getSecret() { + return secret; + } + + public void setSecret(SecretKeySelector secret) { + this.secret = secret; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Placeholder that = (Placeholder) o; + return getSecret().equals(that.getSecret()); + } + + @Override + public int hashCode() { + return Objects.hash(getSecret()); + } +} diff --git a/operator/src/test/java/org/keycloak/operator/testsuite/integration/RealmImportTest.java b/operator/src/test/java/org/keycloak/operator/testsuite/integration/RealmImportTest.java index 62acab55ec7..0d54cae6115 100644 --- a/operator/src/test/java/org/keycloak/operator/testsuite/integration/RealmImportTest.java +++ b/operator/src/test/java/org/keycloak/operator/testsuite/integration/RealmImportTest.java @@ -18,12 +18,15 @@ package org.keycloak.operator.testsuite.integration; import io.fabric8.kubernetes.api.model.Container; +import io.fabric8.kubernetes.api.model.EnvVar; import io.fabric8.kubernetes.api.model.LocalObjectReferenceBuilder; import io.fabric8.kubernetes.api.model.Quantity; import io.fabric8.kubernetes.api.model.ResourceRequirements; +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.SecretKeySelectorBuilder; import io.quarkus.logging.Log; import io.quarkus.test.junit.QuarkusTest; - +import io.quarkus.test.junit.callback.QuarkusTestMethodContext; import jakarta.inject.Inject; import org.awaitility.Awaitility; import org.junit.jupiter.api.BeforeEach; @@ -34,10 +37,13 @@ import org.keycloak.operator.Config; import org.keycloak.operator.controllers.KeycloakServiceDependentResource; import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak; import org.keycloak.operator.crds.v2alpha1.realmimport.KeycloakRealmImport; +import org.keycloak.operator.crds.v2alpha1.realmimport.KeycloakRealmImportBuilder; +import org.keycloak.operator.crds.v2alpha1.realmimport.Placeholder; import org.keycloak.operator.testsuite.utils.CRAssert; import org.keycloak.operator.testsuite.utils.K8sUtils; import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -50,6 +56,7 @@ import static org.keycloak.operator.crds.v2alpha1.realmimport.KeycloakRealmImpor import static org.keycloak.operator.crds.v2alpha1.realmimport.KeycloakRealmImportStatusCondition.HAS_ERRORS; import static org.keycloak.operator.crds.v2alpha1.realmimport.KeycloakRealmImportStatusCondition.STARTED; import static org.keycloak.operator.testsuite.utils.K8sUtils.deployKeycloak; +import static org.keycloak.operator.testsuite.utils.K8sUtils.getResourceFromFile; import static org.keycloak.operator.testsuite.utils.K8sUtils.inClusterCurl; @QuarkusTest @@ -68,6 +75,12 @@ public class RealmImportTest extends BaseOperatorTest { deleteDB(); deployDB(); } + + @Override + public void afterEach(QuarkusTestMethodContext context) { + super.afterEach(context); + k8sclient.resource(getResourceFromFile("example-smtp-secret.yaml", Secret.class)).delete(); + } private String getJobArgs() { return k8sclient @@ -87,10 +100,15 @@ public class RealmImportTest extends BaseOperatorTest { .collect(Collectors.joining()); } + protected static void deploySmtpSecret() { + K8sUtils.set(k8sclient, getResourceFromFile("example-smtp-secret.yaml", Secret.class)); + } + @Test public void testWorkingRealmImport() { // Arrange var kc = getTestKeycloakDeployment(false); + kc.getSpec().setImage(null); // checks the job args for the base, not custom image kc.getSpec().setImagePullSecrets(Arrays.asList(new LocalObjectReferenceBuilder().withName("my-empty-secret").build())); deployKeycloak(k8sclient, kc, false); @@ -99,6 +117,38 @@ public class RealmImportTest extends BaseOperatorTest { K8sUtils.set(k8sclient, getClass().getResourceAsStream("/example-realm.yaml")); // Assert + assertWorkingRealmImport(kc); + } + + @Test + public void testWorkingRealmImportWithReplacement() { + // Arrange + var kc = getTestKeycloakDeployment(false); + + deploySmtpSecret(); + + kc.getSpec().setImage(null); // checks the job args for the base, not custom image + kc.getSpec().setImagePullSecrets(Arrays.asList(new LocalObjectReferenceBuilder().withName("my-empty-secret").build())); + deployKeycloak(k8sclient, kc, false); + + // Act + k8sclient.getKubernetesSerialization().registerKubernetesResource(KeycloakRealmImport.class); + K8sUtils.set(k8sclient, getClass().getResourceAsStream("/example-realm.yaml"), obj -> { + KeycloakRealmImport realmImport = (KeycloakRealmImport) obj; + realmImport.getSpec().getRealm().setSmtpServer(Map.of("port", "${MY_SMTP_PORT}", "host", "${MY_SMTP_SERVER}")); + realmImport.getSpec().setPlaceholders(Map.of("MY_SMTP_PORT", new Placeholder(new SecretKeySelectorBuilder().withName("keycloak-smtp-secret").withKey("SMTP_PORT").build()), + "MY_SMTP_SERVER", new Placeholder(new SecretKeySelectorBuilder().withName("keycloak-smtp-secret").withKey("SMTP_SERVER").build()))); + return realmImport; + }); + + // Assert + var envvars = assertWorkingRealmImport(kc); + + assertThat(envvars.stream().filter(e -> e.getName().equals("MY_SMTP_PORT")).findAny().get().getValueFrom().getSecretKeyRef().getKey()).isEqualTo("SMTP_PORT"); + assertThat(envvars.stream().filter(e -> e.getName().equals("MY_SMTP_SERVER")).findAny().get().getValueFrom().getSecretKeyRef().getKey()).isEqualTo("SMTP_SERVER"); + } + + private List assertWorkingRealmImport(Keycloak kc) { var crSelector = k8sclient .resources(KeycloakRealmImport.class) .inNamespace(namespace) @@ -131,6 +181,7 @@ public class RealmImportTest extends BaseOperatorTest { var envvars = container.getEnv(); assertThat(envvars.stream().filter(e -> e.getName().equals(getKeycloakOptionEnvVarName("cache"))).findAny().get().getValue()).isEqualTo("local"); assertThat(envvars.stream().filter(e -> e.getName().equals(getKeycloakOptionEnvVarName("health-enabled"))).findAny().get().getValue()).isEqualTo("false"); + assertThat(job.getSpec().getTemplate().getSpec().getImagePullSecrets().size()).isEqualTo(1); assertThat(job.getSpec().getTemplate().getSpec().getImagePullSecrets().get(0).getName()).isEqualTo("my-empty-secret"); @@ -148,6 +199,8 @@ public class RealmImportTest extends BaseOperatorTest { }); assertThat(getJobArgs()).contains("build"); + + return envvars; } @Test diff --git a/operator/src/test/resources/example-smtp-secret.yaml b/operator/src/test/resources/example-smtp-secret.yaml new file mode 100644 index 00000000000..1a73d5e4391 --- /dev/null +++ b/operator/src/test/resources/example-smtp-secret.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Secret +metadata: + name: keycloak-smtp-secret +stringData: + SMTP_PORT: "1234" + SMTP_SERVER: "example.com" + SMTP_FROM: "example@example.com" + SMTP_REPLY: "example@example.com" + SMTP_USE_TLS: "true" + SMTP_USERNAME: "example" + SMTP_PASSWORD: "example" + SMTP_AUTH: "true" +type: Opaque \ No newline at end of file