Added field to the RealmImport spec to replace environment variables within the realm import (#31232)

* Added field to the RealmImport spec to replace environment variables within the realm import

Closes #26470

Signed-off-by: stustison <scott.tustison@gmail.com>

* Added field to the RealmImport spec to replace environment variables within the realm import

Closes #26470

Signed-off-by: stustison <scott.tustison@gmail.com>

* testing refinement for placeholder handling

closes: #26470

Signed-off-by: Steve Hawkins <shawkins@redhat.com>

* changing from placeholdersecret to placeholder

Signed-off-by: Steve Hawkins <shawkins@redhat.com>

* Update docs/guides/operator/realm-import.adoc

Co-authored-by: Martin Bartoš <mabartos@redhat.com>
Signed-off-by: Steven Hawkins <shawkins@redhat.com>

* Update docs/documentation/release_notes/topics/26_0_0.adoc

Co-authored-by: Martin Bartoš <mabartos@redhat.com>
Signed-off-by: Steven Hawkins <shawkins@redhat.com>

---------

Signed-off-by: stustison <scott.tustison@gmail.com>
Signed-off-by: Steve Hawkins <shawkins@redhat.com>
Signed-off-by: Steven Hawkins <shawkins@redhat.com>
Co-authored-by: stustison <scott.tustison@gmail.com>
Co-authored-by: Martin Bartoš <mabartos@redhat.com>
This commit is contained in:
Steven Hawkins 2024-07-29 05:16:09 -04:00 committed by GitHub
parent 28a27c9148
commit 22f8e5cdf0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 203 additions and 5 deletions

View file

@ -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.

View file

@ -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: <name of the keycloak CR>
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.
</@tmpl.guide>

View file

@ -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<KeycloakRealmImport> context) {
StatefulSet existingDeployment = context.managedDependentResourceContext().get(StatefulSet.class, StatefulSet.class).orElseThrow();
Map<String, Placeholder> 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<String, Placeholder> 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);

View file

@ -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<String, Placeholder> placeholders;
public String getKeycloakCRName() {
return keycloakCRName;
}
@ -58,4 +68,12 @@ public class KeycloakRealmImportSpec {
public void setResourceRequirements(ResourceRequirements resourceRequirements) {
this.resourceRequirements = resourceRequirements;
}
public Map<String, Placeholder> getPlaceholders() {
return placeholders;
}
public void setPlaceholders(Map<String, Placeholder> placeholders) {
this.placeholders = placeholders;
}
}

View file

@ -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());
}
}

View file

@ -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<EnvVar> 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

View file

@ -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