8000 Added field to the RealmImport spec to replace environment variables within the realm import by shawkins · Pull Request #31232 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/documentation/release_notes/topics/26_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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.
Expand Down
24 changes: 24 additions & 0 deletions docs/guides/operator/realm-import.adoc
Original file line number Diff line number Diff line change
8000 Expand Up @@ -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>
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@
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;
Expand All @@ -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()
Expand All @@ -68,7 +71,7 @@ protected Job desired(KeycloakRealmImport primary, Context<KeycloakRealmImport>
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();
Expand All @@ -93,6 +96,22 @@ protected Job desired(KeycloakRealmImport primary, Context<KeycloakRealmImport>
// 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()) {
8000 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);
}

Expand Down Expand Up @@ -121,7 +140,7 @@ private Volume buildSecretVolume(String volumeName, String secretName) {
.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");
Expand All @@ -130,8 +149,10 @@ private void buildKeycloakJobContainer(Container keycloakContainer, KeycloakReal

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.")
Comment thread
shawkins marked this conversation as resolved.
Resolved
private Map<String, Placeholder> placeholders;

public String getKeycloakCRName() {
return keycloakCRName;
}
Expand All @@ -58,4 +68,12 @@ public ResourceRequirements getResourceRequirements() {
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;
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,10 +37,13 @@
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;

Expand All @@ -50,6 +56,7 @@
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
Expand All @@ -68,6 +75,12 @@ public void beforeEach(TestInfo testInfo) {
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
Expand All @@ -87,10 +100,15 @@ private String getJobArgs() {
.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);
Expand All @@ -99,6 +117,38 @@ public void testWorkingRealmImport() {
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)
Expand Down Expand Up @@ -131,6 +181,7 @@ public void testWorkingRealmImport() {
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");

Expand All @@ -148,6 +199,8 @@ public void testWorkingRealmImport() {
});

assertThat(getJobArgs()).contains("build");

return envvars;
}

@Test
Expand Down
14 changes: 14 additions & 0 deletions operator/src/test/resources/example-smtp-secret.yaml
Original file line number Diff line number Diff line change
@@ -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
0