enhance: adding the ability to set truststores via configmaps (#41796)

closes: #34114

Signed-off-by: Steve Hawkins <shawkins@redhat.com>
This commit is contained in:
Steven Hawkins 2025-08-28 10:55:52 -04:00 committed by GitHub
parent 856df9ea3d
commit 183a96d6a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 86 additions and 30 deletions

View File

@ -323,7 +323,7 @@ For more details, see <@links.server id="management-interface" />.
If you need to provide trusted certificates, the Keycloak CR provides a top level feature for configuring the server's truststore as discussed in <@links.server id="keycloak-truststore"/>.
Use the truststores stanza of the Keycloak spec to specify Secrets containing PEM encoded files, or PKCS12 files with extension `.p12`, `.pfx`, or `.pkcs12`, for example:
Use the truststores stanza of the Keycloak spec to specify Secrets or ConfigMaps containing PEM encoded files, or PKCS12 files with extension `.p12`, `.pfx`, or `.pkcs12`, for example:
[source,yaml]
----

View File

@ -30,7 +30,6 @@ 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;
import io.javaoperatorsdk.operator.api.config.informer.Informer;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
@ -134,20 +133,25 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent
StatefulSet baseDeployment = createBaseDeployment(primary, context, operatorConfig);
TreeSet<String> allSecrets = new TreeSet<>();
TreeSet<String> allConfigMaps = new TreeSet<>();
if (isTlsConfigured(primary)) {
configureTLS(primary, baseDeployment, allSecrets);
}
Container kcContainer = baseDeployment.getSpec().getTemplate().getSpec().getContainers().get(0);
addTruststores(primary, baseDeployment, kcContainer, allSecrets);
addTruststores(primary, baseDeployment, kcContainer, allSecrets, allConfigMaps);
addEnvVars(baseDeployment, primary, allSecrets, context);
addResources(primary.getSpec().getResourceRequirements(), operatorConfig, kcContainer);
Optional.ofNullable(primary.getSpec().getCacheSpec())
.ifPresent(c -> configureCache(baseDeployment, kcContainer, c, context.getClient(), watchedResources));
.ifPresent(c -> configureCache(baseDeployment, kcContainer, c, allConfigMaps));
if (!allSecrets.isEmpty()) {
watchedResources.annotateDeployment(new ArrayList<>(allSecrets), Secret.class, baseDeployment, context.getClient());
}
if (!allConfigMaps.isEmpty()) {
watchedResources.annotateDeployment(new ArrayList<>(allConfigMaps), ConfigMap.class, baseDeployment, context.getClient());
}
// default to the new revision - will be overriden to the old one if needed
UpdateSpec.getRevision(primary).ifPresent(rev -> addUpdateRevisionAnnotation(rev, baseDeployment));
@ -185,7 +189,7 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent
};
}
private void configureCache(StatefulSet deployment, Container kcContainer, CacheSpec spec, KubernetesClient client, WatchedResources watchedResources) {
private void configureCache(StatefulSet deployment, Container kcContainer, CacheSpec spec, TreeSet<String> allConfigMaps) {
Optional.ofNullable(spec.getConfigMapFile()).ifPresent(configFile -> {
if (configFile.getName() == null || configFile.getKey() == null) {
throw new IllegalStateException("Cache file ConfigMap requires both a name and a key");
@ -206,33 +210,54 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent
deployment.getSpec().getTemplate().getSpec().getVolumes().add(0, volume);
kcContainer.getVolumeMounts().add(0, volumeMount);
// currently the only configmap we're watching
watchedResources.annotateDeployment(List.of(configFile.getName()), ConfigMap.class, deployment, client);
allConfigMaps.add(configFile.getName());
});
}
private void addTruststores(Keycloak keycloakCR, StatefulSet deployment, Container kcContainer, TreeSet<String> allSecrets) {
private void addTruststores(Keycloak keycloakCR, StatefulSet deployment, Container kcContainer, TreeSet<String> allSecrets, TreeSet<String> allConfigMaps) {
for (Truststore truststore : keycloakCR.getSpec().getTruststores().values()) {
// for now we'll assume only secrets, later we can support configmaps
TruststoreSource source = truststore.getSecret();
String secretName = source.getName();
var volume = new VolumeBuilder()
.withName("truststore-secret-" + secretName)
.withNewSecret()
.withSecretName(secretName)
.withOptional(source.getOptional())
.endSecret()
.build();
if (source != null) {
String secretName = source.getName();
var volume = new VolumeBuilder()
.withName("truststore-secret-" + secretName)
.withNewSecret()
.withSecretName(secretName)
.withOptional(source.getOptional())
.endSecret()
.build();
var volumeMount = new VolumeMountBuilder()
.withName(volume.getName())
.withMountPath(Constants.TRUSTSTORES_FOLDER + "/secret-" + secretName)
.build();
var volumeMount = new VolumeMountBuilder()
.withName(volume.getName())
.withMountPath(Constants.TRUSTSTORES_FOLDER + "/secret-" + secretName)
.build();
deployment.getSpec().getTemplate().getSpec().getVolumes().add(0, volume);
kcContainer.getVolumeMounts().add(0, volumeMount);
allSecrets.add(secretName);
deployment.getSpec().getTemplate().getSpec().getVolumes().add(0, volume);
kcContainer.getVolumeMounts().add(0, volumeMount);
allSecrets.add(secretName);
} else {
source = truststore.getConfigMap();
if (source != null) {
String name = source.getName();
var volume = new VolumeBuilder()
.withName("truststore-configmap-" + name)
.withNewConfigMap()
.withName(name)
.withOptional(source.getOptional())
.endConfigMap()
.build();
var volumeMount = new VolumeMountBuilder()
.withName(volume.getName())
.withMountPath(Constants.TRUSTSTORES_FOLDER + "/configmap-" + name)
.build();
deployment.getSpec().getTemplate().getSpec().getVolumes().add(0, volume);
kcContainer.getVolumeMounts().add(0, volumeMount);
allConfigMaps.add(name);
}
}
}
}
@ -471,7 +496,7 @@ public class KeycloakDeploymentDependentResource extends CRUDKubernetesDependent
var envVars = new ArrayList<>(varMap.values());
baseDeployment.getSpec().getTemplate().getSpec().getContainers().get(0).setEnv(envVars);
// watch the secrets used by secret key - we don't currently expect configmaps, optional refs, or watch the initial-admin
// watch the secrets used by secret key - we don't currently expect configmaps or watch the initial-admin
TreeSet<String> serverConfigSecretsNames = envVars.stream().map(EnvVar::getValueFrom).filter(Objects::nonNull)
.map(EnvVarSource::getSecretKeyRef).filter(Objects::nonNull).map(SecretKeySelector::getName).collect(Collectors.toCollection(TreeSet::new));

View File

@ -17,20 +17,22 @@
package org.keycloak.operator.crds.v2alpha1.deployment.spec;
import io.fabric8.generator.annotation.Required;
import io.sundr.builder.annotations.Buildable;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import io.sundr.builder.annotations.Buildable;
@JsonInclude(JsonInclude.Include.NON_NULL)
@Buildable(editableEnabled = false, builderPackage = "io.fabric8.kubernetes.api.builder")
public class Truststore {
@JsonPropertyDescription("Not used. To be removed in later versions.")
private String name;
@Required
@JsonPropertyDescription("The Secret containing the trust material - only set one of the other secret or configMap")
private TruststoreSource secret;
@JsonPropertyDescription("The ConfigMap containing the trust material - only set one of the other secret or configMap")
private TruststoreSource configMap;
public String getName() {
return name;
@ -48,4 +50,12 @@ public class Truststore {
this.secret = secret;
}
public TruststoreSource getConfigMap() {
return configMap;
}
public void setConfigMap(TruststoreSource configMap) {
this.configMap = configMap;
}
}

View File

@ -17,6 +17,7 @@
package org.keycloak.operator.testsuite.integration;
import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.fabric8.kubernetes.client.dsl.Resource;
@ -40,6 +41,7 @@ public class KeycloakTruststoresTests extends BaseOperatorTest {
var kc = getTestKeycloakDeployment(true);
var deploymentName = kc.getMetadata().getName();
kc.getSpec().getTruststores().put("xyz", new TruststoreBuilder().withNewSecret().withName("xyz").endSecret().build());
kc.getSpec().getTruststores().put("abc", new TruststoreBuilder().withNewConfigMap().withName("abc").endConfigMap().build());
deployKeycloak(k8sclient, kc, false);
Resource<StatefulSet> stsResource = k8sclient.resources(StatefulSet.class).withName(deploymentName);
@ -49,6 +51,10 @@ public class KeycloakTruststoresTests extends BaseOperatorTest {
statefulSet.getMetadata().getAnnotations().get(WatchedResourcesTest.KEYCLOAK_MISSING_SECRETS_ANNOTATION));
assertTrue(statefulSet.getMetadata().getAnnotations().get(WatchedResourcesTest.KEYCLOAK_WATCHING_ANNOTATION)
.contains("xyz"));
assertEquals("true",
statefulSet.getMetadata().getAnnotations().get(WatchedResourcesTest.KEYCLOAK_MISSING_CONFIGMAPS_ANNOTATION));
assertTrue(statefulSet.getMetadata().getAnnotations().get(WatchedResourcesTest.KEYCLOAK_WATCHING_CONFIGMAPS_ANNOTATION)
.contains("abc"));
});
}
@ -59,6 +65,9 @@ public class KeycloakTruststoresTests extends BaseOperatorTest {
K8sUtils.set(k8sclient, getResourceFromFile("example-truststore-secret.yaml", Secret.class));
kc.getSpec().getTruststores().put("example", new TruststoreBuilder().withNewSecret().withName("example-truststore-secret").endSecret().build());
kc.getSpec().getTruststores().put("abc", new TruststoreBuilder().withNewConfigMap().withName("abc").endConfigMap().build());
k8sclient.configMaps().resource(new ConfigMapBuilder().withNewMetadata().withName("abc").endMetadata().build()).create();
deployKeycloak(k8sclient, kc, true);
Resource<StatefulSet> stsResource = k8sclient.resources(StatefulSet.class).withName(deploymentName);
@ -68,6 +77,11 @@ public class KeycloakTruststoresTests extends BaseOperatorTest {
assertTrue(statefulSet.getSpec().getTemplate().getSpec().getContainers().get(0).getVolumeMounts().stream()
.anyMatch(v -> v.getMountPath()
.equals("/opt/keycloak/conf/truststores/secret-example-truststore-secret")));
assertEquals("false",
statefulSet.getMetadata().getAnnotations().get(WatchedResourcesTest.KEYCLOAK_MISSING_CONFIGMAPS_ANNOTATION));
assertTrue(statefulSet.getSpec().getTemplate().getSpec().getContainers().get(0).getVolumeMounts().stream()
.anyMatch(v -> v.getMountPath()
.equals("/opt/keycloak/conf/truststores/configmap-abc")));
}
}

View File

@ -19,6 +19,7 @@ package org.keycloak.operator.testsuite.unit;
import io.fabric8.kubernetes.api.model.Affinity;
import io.fabric8.kubernetes.api.model.AffinityBuilder;
import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.Container;
import io.fabric8.kubernetes.api.model.EnvVar;
import io.fabric8.kubernetes.api.model.IntOrString;
@ -26,6 +27,7 @@ import io.fabric8.kubernetes.api.model.LocalObjectReference;
import io.fabric8.kubernetes.api.model.PodTemplateSpec;
import io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder;
import io.fabric8.kubernetes.api.model.ProbeBuilder;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.SecretKeySelector;
import io.fabric8.kubernetes.api.model.Toleration;
import io.fabric8.kubernetes.api.model.TopologySpreadConstraint;
@ -591,6 +593,8 @@ public class PodTemplateTest {
.filter(v -> v.getName().equals(KeycloakDeploymentDependentResource.CACHE_CONFIG_FILE_MOUNT_NAME))
.findFirst().orElseThrow();
assertThat(volume.getConfigMap().getName()).isEqualTo("cm");
Mockito.verify(this.watchedResources).annotateDeployment(Mockito.eq(List.of("cm")), Mockito.eq(ConfigMap.class), Mockito.any(), Mockito.any());
}
@Test
@ -794,7 +798,7 @@ public class PodTemplateTest {
envVar = env.get("SECRET");
assertEquals("key", envVar.getValueFrom().getSecretKeyRef().getKey());
Mockito.verify(this.watchedResources).annotateDeployment(Mockito.eq(List.of("example-tls-secret", "instance-initial-admin", "my-secret")), Mockito.any(), Mockito.any(), Mockito.any());
Mockito.verify(this.watchedResources).annotateDeployment(Mockito.eq(List.of("example-tls-secret", "instance-initial-admin", "my-secret")), Mockito.eq(Secret.class), Mockito.any(), Mockito.any());
}
}

View File

@ -39,7 +39,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
public class WatchedResourcesTest {
public static final String KEYCLOAK_WATCHING_ANNOTATION = "operator.keycloak.org/watching-secrets";
public static final String KEYCLOAK_WATCHING_CONFIGMAPS_ANNOTATION = "operator.keycloak.org/watching-configmaps";
public static final String KEYCLOAK_MISSING_SECRETS_ANNOTATION = "operator.keycloak.org/missing-secrets";
public static final String KEYCLOAK_MISSING_CONFIGMAPS_ANNOTATION = "operator.keycloak.org/missing-configmaps";
@Inject
WatchedResources watchedResources;