Secret references in Keycloak CRD (#10716)

This commit is contained in:
Václav Muzikář 2022-03-22 11:16:54 +01:00 committed by GitHub
parent fb92b95c33
commit c0255cbeea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 792 additions and 96 deletions

View file

@ -26,6 +26,8 @@ public final class Constants {
public static final String PLURAL_NAME = "keycloaks";
public static final String MANAGED_BY_LABEL = "app.kubernetes.io/managed-by";
public static final String MANAGED_BY_VALUE = "keycloak-operator";
public static final String COMPONENT_LABEL = "app.kubernetes.io/component";
public static final String KEYCLOAK_COMPONENT_LABEL = "keycloak.org/component";
public static final Map<String, String> DEFAULT_LABELS = Map.of(
"app", NAME,

View file

@ -60,8 +60,10 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
@Override
public List<EventSource> prepareEventSources(EventSourceContext<Keycloak> context) {
String namespace = context.getConfigurationService().getClientConfiguration().getNamespace();
SharedIndexInformer<Deployment> deploymentInformer =
client.apps().deployments().inNamespace(context.getConfigurationService().getClientConfiguration().getNamespace())
client.apps().deployments().inNamespace(namespace)
.withLabels(Constants.DEFAULT_LABELS)
.runnableInformer(0);
@ -79,7 +81,11 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
EventSource servicesEvent = new InformerEventSource<>(servicesInformer, Mappers.fromOwnerReference());
EventSource ingressesEvent = new InformerEventSource<>(ingressesInformer, Mappers.fromOwnerReference());
return List.of(deploymentEvent, servicesEvent, ingressesEvent);
return List.of(deploymentEvent,
servicesEvent,
ingressesEvent,
WatchedSecretsStore.getStoreEventSource(client, namespace),
WatchedSecretsStore.getWatchedSecretsEventSource(client, namespace));
}
@Override
@ -97,8 +103,14 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
// TODO use caches in secondary resources; this is a workaround for https://github.com/java-operator-sdk/java-operator-sdk/issues/830
// KeycloakDeployment deployment = new KeycloakDeployment(client, config, kc, context.getSecondaryResource(Deployment.class).orElse(null));
var kcDeployment = new KeycloakDeployment(client, config, kc, null, kcAdminSecret.getName());
kcDeployment.updateStatus(statusBuilder);
var watchedSecrets = new WatchedSecretsStore(kcDeployment.getConfigSecretsNames(), client, kc);
kcDeployment.createOrUpdateReconciled();
if (watchedSecrets.changesDetected()) {
Log.info("Config Secrets modified, restarting deployment");
kcDeployment.rollingRestart();
}
kcDeployment.updateStatus(statusBuilder);
watchedSecrets.createOrUpdateReconciled();
var kcService = new KeycloakService(client, kc);
kcService.updateStatus(statusBuilder);

View file

@ -20,11 +20,12 @@ import io.fabric8.kubernetes.api.model.Container;
import io.fabric8.kubernetes.api.model.ContainerBuilder;
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.HasMetadata;
import io.fabric8.kubernetes.api.model.VolumeBuilder;
import io.fabric8.kubernetes.api.model.VolumeMountBuilder;
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.Deployment;
import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
@ -36,26 +37,29 @@ import org.keycloak.operator.OperatorManagedResource;
import org.keycloak.operator.StatusUpdater;
import org.keycloak.operator.v2alpha1.crds.Keycloak;
import org.keycloak.operator.v2alpha1.crds.KeycloakStatusBuilder;
import org.keycloak.operator.v2alpha1.crds.ValueOrSecret;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
public class KeycloakDeployment extends OperatorManagedResource implements StatusUpdater<KeycloakStatusBuilder> {
// public static final Pattern CONFIG_SECRET_PATTERN = Pattern.compile("^\\$\\{secret:([^:]+):(.+)}$");
private final Config config;
private final Keycloak keycloakCR;
private final Deployment existingDeployment;
private final Deployment baseDeployment;
private final String adminSecretName;
private Set<String> serverConfigSecretsNames;
public KeycloakDeployment(KubernetesClient client, Config config, Keycloak keycloakCR, Deployment existingDeployment, String adminSecretName) {
super(client, keycloakCR);
this.config = config;
@ -84,9 +88,18 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu
}
else {
Log.info("Existing Deployment found, updating specs");
reconciledDeployment = existingDeployment;
// don't override metadata, just specs
reconciledDeployment = new DeploymentBuilder(existingDeployment).build();
// don't overwrite metadata, just specs
reconciledDeployment.setSpec(baseDeployment.getSpec());
// don't overwrite annotations in pod templates to support rolling restarts
if (existingDeployment.getSpec() != null && existingDeployment.getSpec().getTemplate() != null) {
mergeMaps(
Optional.ofNullable(reconciledDeployment.getSpec().getTemplate().getMetadata()).map(m -> m.getAnnotations()).orElse(null),
Optional.ofNullable(existingDeployment.getSpec().getTemplate().getMetadata()).map(m -> m.getAnnotations()).orElse(null),
annotations -> reconciledDeployment.getSpec().getTemplate().getMetadata().setAnnotations(annotations));
}
}
return Optional.of(reconciledDeployment);
@ -453,67 +466,62 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu
addInitContainer(baseDeployment, keycloakCR.getSpec().getExtensions());
mergePodTemplate(baseDeployment.getSpec().getTemplate());
// Set<String> configSecretsNames = new HashSet<>();
// List<EnvVar> configEnvVars = serverConfig.entrySet().stream()
// .map(e -> {
// EnvVarBuilder builder = new EnvVarBuilder().withName(e.getKey());
// Matcher matcher = CONFIG_SECRET_PATTERN.matcher(e.getValue());
// // check if given config var is actually a secret reference
// if (matcher.matches()) {
// builder.withValueFrom(
// new EnvVarSourceBuilder()
// .withNewSecretKeyRef(matcher.group(2), matcher.group(1), false)
// .build());
// configSecretsNames.add(matcher.group(1)); // for watching it later
// } else {
// builder.withValue(e.getValue());
// }
// builder.withValue(e.getValue());
// return builder.build();
// })
// .collect(Collectors.toList());
// container.setEnv(configEnvVars);
// this.configSecretsNames = Collections.unmodifiableSet(configSecretsNames);
// Log.infof("Found config secrets names: %s", configSecretsNames);
return baseDeployment;
}
private List<EnvVar> getEnvVars() {
var serverConfig = new HashMap<>(Constants.DEFAULT_DIST_CONFIG);
serverConfig.put("jgroups.dns.query", getName() + Constants.KEYCLOAK_DISCOVERY_SERVICE_SUFFIX +"." + getNamespace());
if (keycloakCR.getSpec().getServerConfiguration() != null) {
serverConfig.putAll(keycloakCR.getSpec().getServerConfiguration());
}
var envVars = serverConfig.entrySet().stream()
.map(e -> new EnvVarBuilder()
.withName(e.getKey())
.withValue(e.getValue())
.build())
// default config values
List<ValueOrSecret> serverConfig = Constants.DEFAULT_DIST_CONFIG.entrySet().stream()
.map(e -> new ValueOrSecret(e.getKey(), e.getValue()))
.collect(Collectors.toList());
serverConfig.add(new ValueOrSecret("jgroups.dns.query", getName() + Constants.KEYCLOAK_DISCOVERY_SERVICE_SUFFIX +"." + getNamespace()));
// merge with the CR; the values in CR take precedence
if (keycloakCR.getSpec().getServerConfiguration() != null) {
serverConfig.removeAll(keycloakCR.getSpec().getServerConfiguration());
serverConfig.addAll(keycloakCR.getSpec().getServerConfiguration());
}
// set env vars
serverConfigSecretsNames = new HashSet<>();
List<EnvVar> envVars = serverConfig.stream()
.map(v -> {
var envBuilder = new EnvVarBuilder().withName(v.getName());
var secret = v.getSecret();
if (secret != null) {
envBuilder.withValueFrom(
new EnvVarSourceBuilder().withSecretKeyRef(secret).build());
serverConfigSecretsNames.add(secret.getName()); // for watching it later
} else {
envBuilder.withValue(v.getValue());
}
return envBuilder.build();
})
.collect(Collectors.toList());
Log.infof("Found config secrets names: %s", serverConfigSecretsNames);
envVars.add(
new EnvVarBuilder()
.withName("KEYCLOAK_ADMIN")
.withNewValueFrom()
.withNewSecretKeyRef()
.withName(this.adminSecretName)
.withKey("username")
.withOptional(false)
.endSecretKeyRef()
.endValueFrom()
.build());
new EnvVarBuilder()
.withName("KEYCLOAK_ADMIN")
.withNewValueFrom()
.withNewSecretKeyRef()
.withName(adminSecretName)
.withKey("username")
.withOptional(false)
.endSecretKeyRef()
.endValueFrom()
.build());
envVars.add(
new EnvVarBuilder()
.withName("KEYCLOAK_ADMIN_PASSWORD")
.withNewValueFrom()
.withNewSecretKeyRef()
.withName(this.adminSecretName)
.withKey("password")
.withOptional(false)
.endSecretKeyRef()
.endValueFrom()
.build());
new EnvVarBuilder()
.withName("KEYCLOAK_ADMIN_PASSWORD")
.withNewValueFrom()
.withNewSecretKeyRef()
.withName(adminSecretName)
.withKey("password")
.withOptional(false)
.endSecretKeyRef()
.endValueFrom()
.build());
return envVars;
}
@ -537,13 +545,27 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu
|| existingDeployment.getStatus().getReadyReplicas() == null
|| existingDeployment.getStatus().getReadyReplicas() < keycloakCR.getSpec().getInstances()) {
status.addNotReadyMessage("Waiting for more replicas");
return;
}
var progressing = existingDeployment.getStatus().getConditions().stream()
.filter(c -> c.getType().equals("Progressing")).findFirst();
progressing.ifPresent(p -> {
String reason = p.getReason();
// https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#progressing-deployment
if (p.getStatus().equals("True") &&
(reason.equals("NewReplicaSetCreated") || reason.equals("FoundNewReplicaSet") || reason.equals("ReplicaSetUpdated"))) {
status.addRollingUpdateMessage("Rolling out deployment update");
}
});
}
// public Set<String> getConfigSecretsNames() {
// return configSecretsNames;
// }
public Set<String> getConfigSecretsNames() {
Set<String> ret = new HashSet<>(serverConfigSecretsNames);
if (!keycloakCR.getSpec().isHttp()) {
ret.add(keycloakCR.getSpec().getTlsSecret());
}
return ret;
}
@Override
public String getName() {

View file

@ -0,0 +1,241 @@
/*
* 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.v2alpha1;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.SecretBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.informers.SharedIndexInformer;
import io.fabric8.kubernetes.client.utils.Serialization;
import io.javaoperatorsdk.operator.processing.event.ResourceID;
import io.javaoperatorsdk.operator.processing.event.source.EventSource;
import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers;
import io.quarkus.logging.Log;
import org.keycloak.operator.Constants;
import org.keycloak.operator.OperatorManagedResource;
import org.keycloak.operator.v2alpha1.crds.Keycloak;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Represents a version store of Secrets that are watched by a CR but is not owned by it. E.g. Secrets with
* credentials provided by user.
*
* It is backed by a Secret which holds a list of watched Secrets together with their last observed version. It marks
* all the watched Secrets with a label indicating which CRs are watching that resource.
*
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public class WatchedSecretsStore extends OperatorManagedResource {
public static final String COMPONENT = "secrets-store";
public static final String WATCHED_SECRETS_LABEL_VALUE = "watched-secret";
public static final String STORE_SUFFIX = "-" + COMPONENT;
private final Secret existingStore; // a Secret to store the last observed versions
// key is name of the secret
private final Map<String, String> lastObservedVersions;
private final Map<String, String> currentVersions;
private final Set<Secret> currentSecrets;
public WatchedSecretsStore(Set<String> desiredWatchedSecretsNames, KubernetesClient client, Keycloak kc) {
super(client, kc);
existingStore = fetchExistingStore();
lastObservedVersions = getNewLastObservedVersions();
currentSecrets = fetchCurrentSecrets(desiredWatchedSecretsNames);
currentVersions = getNewCurrentVersions();
}
/**
* @return true if any of the watched Secrets was changed, false otherwise (incl. if it's a newly watched Secret)
*/
public boolean changesDetected() {
return currentVersions.entrySet().stream().anyMatch(e -> {
String prevVersion = lastObservedVersions.get(e.getKey());
return prevVersion != null && !prevVersion.equals(e.getValue());
});
}
@Override
protected Optional<HasMetadata> getReconciledResource() {
Secret secret = existingStore != null ? existingStore : getNewStore();
secret.setData(null);
secret.setStringData(currentVersions);
return Optional.of(secret);
}
@Override
protected void setDefaultLabels(HasMetadata resource) {
super.setDefaultLabels(resource);
resource.getMetadata().getLabels().put(Constants.COMPONENT_LABEL, COMPONENT);
}
@Override
public void createOrUpdateReconciled() {
super.createOrUpdateReconciled();
addLabelsToWatchedSecrets();
}
public void addLabelsToWatchedSecrets() {
for (Secret secret : currentSecrets) {
if (secret.getMetadata() == null
|| secret.getMetadata().getLabels() == null
|| !secret.getMetadata().getLabels().containsKey(Constants.KEYCLOAK_COMPONENT_LABEL)) {
Log.infof("Adding label to Secret \"%s\"", secret.getMetadata().getName());
secret = new SecretBuilder(secret)
.editMetadata()
.addToLabels(Constants.KEYCLOAK_COMPONENT_LABEL, WATCHED_SECRETS_LABEL_VALUE)
.endMetadata()
.build();
client.secrets().patch(secret);
}
}
}
private Secret fetchExistingStore() {
return client.secrets().inNamespace(getNamespace()).withName(getName()).get();
}
private Secret getNewStore() {
return new SecretBuilder()
.withNewMetadata()
.withName(getName())
.withNamespace(getNamespace())
.endMetadata()
.build();
}
private Map<String, String> getNewLastObservedVersions() {
if (existingStore != null && existingStore.getData() != null) {
return existingStore.getData().entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> new String(Base64.getDecoder().decode(e.getValue()))
));
}
else {
return Collections.emptyMap();
}
}
private Map<String, String> getNewCurrentVersions() {
return currentSecrets.stream()
.collect(Collectors.toMap(s -> s.getMetadata().getName(), this::getSecretVersion));
}
private String getSecretVersion(Secret secret) {
String serializedData = Serialization.asYaml(secret.getData());
try {
// using hashes as it's more robust than resource versions that can change e.g. just when adding a label
byte[] bytes = MessageDigest.getInstance("MD5").digest(serializedData.getBytes(StandardCharsets.UTF_8));
return new BigInteger(1, bytes).toString(16);
}
catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
private Set<Secret> fetchCurrentSecrets(Set<String> secretsNames) {
return secretsNames.stream()
.map(n -> {
Secret secret = client.secrets().inNamespace(getNamespace()).withName(n).get();
if (secret == null) {
throw new IllegalStateException("Secret " + n + " not found");
}
return secret;
})
.collect(Collectors.toSet());
}
@Override
public String getName() {
return cr.getMetadata().getName() + STORE_SUFFIX;
}
public static EventSource getStoreEventSource(KubernetesClient client, String namespace) {
SharedIndexInformer<Secret> informer =
client.secrets()
.inNamespace(namespace)
.withLabel(Constants.COMPONENT_LABEL, COMPONENT)
.runnableInformer(0);
return new InformerEventSource<>(informer, Mappers.fromOwnerReference()) {
@Override
public String name() {
return "watchedResourcesStoreEventSource";
}
};
}
private static void cleanObsoleteLabelFromSecret(KubernetesClient client, Secret secret) {
secret.getMetadata().getLabels().remove(Constants.KEYCLOAK_COMPONENT_LABEL);
client.secrets().patch(secret);
}
public static EventSource getWatchedSecretsEventSource(KubernetesClient client, String namespace) {
SharedIndexInformer<Secret> informer =
client.secrets()
.inNamespace(namespace)
.withLabel(Constants.KEYCLOAK_COMPONENT_LABEL, WATCHED_SECRETS_LABEL_VALUE)
.runnableInformer(0);
return new InformerEventSource<>(informer, secret -> {
// get all stores
List<Secret> stores = client.secrets().inNamespace(namespace).withLabel(Constants.COMPONENT_LABEL, COMPONENT).list().getItems();
// find all CR names that are watching this Secret
var ret = stores.stream()
// check if any of the stores tracks this secret
.filter(store -> store.getData().containsKey(secret.getMetadata().getName()))
.map(store -> {
String crName = store.getMetadata().getName().split(STORE_SUFFIX)[0];
return new ResourceID(crName, namespace);
})
.collect(Collectors.toSet());
if (ret.isEmpty()) {
Log.infof("No CRs watching \"%s\" Secret, cleaning up labels", secret.getMetadata().getName());
cleanObsoleteLabelFromSecret(client, secret);
Log.debug("Labels removed");
}
return ret;
}) {
@Override
public String name() {
return "watchedSecretsEventSource";
}
};
}
}

View file

@ -23,13 +23,12 @@ import org.keycloak.operator.v2alpha1.crds.keycloakspec.Unsupported;
import javax.validation.constraints.NotNull;
import java.util.List;
import java.util.Map;
public class KeycloakSpec {
private int instances = 1;
private String image;
private Map<String, String> serverConfiguration;
private List<ValueOrSecret> serverConfiguration; // can't use Set due to a bug in Sundrio
@NotNull
@JsonPropertyDescription("Hostname for the Keycloak server.\n" +
@ -112,11 +111,11 @@ public class KeycloakSpec {
this.image = image;
}
public Map<String, String> getServerConfiguration() {
public List<ValueOrSecret> getServerConfiguration() {
return serverConfiguration;
}
public void setServerConfiguration(Map<String, String> serverConfiguration) {
public void setServerConfiguration(List<ValueOrSecret> serverConfiguration) {
this.serverConfiguration = serverConfiguration;
}
}

View file

@ -26,9 +26,11 @@ import java.util.List;
public class KeycloakStatusBuilder {
private final KeycloakStatusCondition readyCondition;
private final KeycloakStatusCondition hasErrorsCondition;
private final KeycloakStatusCondition rollingUpdate;
private final List<String> notReadyMessages = new ArrayList<>();
private final List<String> errorMessages = new ArrayList<>();
private final List<String> rollingUpdateMessages = new ArrayList<>();
public KeycloakStatusBuilder() {
readyCondition = new KeycloakStatusCondition();
@ -38,6 +40,10 @@ public class KeycloakStatusBuilder {
hasErrorsCondition = new KeycloakStatusCondition();
hasErrorsCondition.setType(KeycloakStatusCondition.HAS_ERRORS);
hasErrorsCondition.setStatus(false);
rollingUpdate = new KeycloakStatusCondition();
rollingUpdate.setType(KeycloakStatusCondition.ROLLING_UPDATE);
rollingUpdate.setStatus(false);
}
public KeycloakStatusBuilder addNotReadyMessage(String message) {
@ -57,12 +63,19 @@ public class KeycloakStatusBuilder {
return this;
}
public KeycloakStatusBuilder addRollingUpdateMessage(String message) {
rollingUpdate.setStatus(true);
rollingUpdateMessages.add(message);
return this;
}
public KeycloakStatus build() {
readyCondition.setMessage(String.join("\n", notReadyMessages));
hasErrorsCondition.setMessage(String.join("\n", errorMessages));
rollingUpdate.setMessage(String.join("\n", rollingUpdateMessages));
KeycloakStatus status = new KeycloakStatus();
status.setConditions(List.of(readyCondition, hasErrorsCondition));
status.setConditions(List.of(readyCondition, hasErrorsCondition, rollingUpdate));
return status;
}
}

View file

@ -17,7 +17,6 @@
package org.keycloak.operator.v2alpha1.crds;
import java.io.Serializable;
import java.util.Objects;
/**
@ -26,6 +25,7 @@ import java.util.Objects;
public class KeycloakStatusCondition {
public static final String READY = "Ready";
public static final String HAS_ERRORS = "HasErrors";
public static final String ROLLING_UPDATE = "RollingUpdate";
// string to avoid enums in CRDs
private String type;
@ -61,7 +61,7 @@ public class KeycloakStatusCondition {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
KeycloakStatusCondition that = (KeycloakStatusCondition) o;
return getType() == that.getType() && Objects.equals(getStatus(), that.getStatus()) && Objects.equals(getMessage(), that.getMessage());
return Objects.equals(getType(), that.getType()) && Objects.equals(getStatus(), that.getStatus()) && Objects.equals(getMessage(), that.getMessage());
}
@Override

View file

@ -0,0 +1,81 @@
/*
* 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.v2alpha1.crds;
import io.fabric8.kubernetes.api.model.SecretKeySelector;
import java.util.Objects;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public class ValueOrSecret {
private String name;
private String value;
private SecretKeySelector secret;
public ValueOrSecret() {
}
public ValueOrSecret(String name, String value) {
this.name = name;
this.value = value;
}
public ValueOrSecret(String name, SecretKeySelector secret) {
this.name = name;
this.secret = secret;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
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;
ValueOrSecret that = (ValueOrSecret) o;
return getName().equals(that.getName()); // comparing just name as it doesn't make sense to have more than one config value with the same name
}
@Override
public int hashCode() {
return Objects.hash(getName());
}
}

View file

@ -38,7 +38,7 @@ spec:
- https://127.0.0.1:8443/health/live
initialDelaySeconds: 20
periodSeconds: 2
failureThreshold: 100
failureThreshold: 150
readinessProbe:
exec:
command:
@ -50,7 +50,7 @@ spec:
- https://127.0.0.1:8443/health/ready
initialDelaySeconds: 20
periodSeconds: 2
failureThreshold: 200
failureThreshold: 250
dnsPolicy: ClusterFirst
restartPolicy: Always
terminationGracePeriodSeconds: 30

View file

@ -5,12 +5,18 @@ metadata:
spec:
instances: 1
serverConfiguration:
KC_DB: postgres
KC_DB_URL_HOST: postgres-db
# KC_DB_USERNAME: ${secret:keycloak-db-secret:username}
# KC_DB_PASSWORD: ${secret:keycloak-db-secret:password}
KC_DB_USERNAME: postgres
KC_DB_PASSWORD: testpassword
- name: KC_DB
value: postgres
- name: KC_DB_URL_HOST
value: postgres-db
- name: KC_DB_USERNAME
secret:
name: keycloak-db-secret
key: username
- name: KC_DB_PASSWORD
secret:
name: keycloak-db-secret
key: password
hostname: example.com
tlsSecret: example-tls-secret
---

View file

@ -2,6 +2,7 @@ package org.keycloak.operator;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.NamespaceBuilder;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.apps.Deployment;
import io.fabric8.kubernetes.client.Config;
import io.fabric8.kubernetes.client.ConfigBuilder;
@ -33,6 +34,7 @@ import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.keycloak.operator.utils.K8sUtils.getResourceFromMultiResourceFile;
public abstract class ClusterOperatorTest {
@ -161,6 +163,12 @@ public abstract class ClusterOperatorTest {
Log.info("Checking Postgres is running");
Awaitility.await()
.untilAsserted(() -> assertThat(k8sclient.apps().statefulSets().inNamespace(namespace).withName("postgresql-db").get().getStatus().getReadyReplicas()).isEqualTo(1));
deployDBSecret();
}
protected static void deployDBSecret() {
k8sclient.secrets().inNamespace(namespace).createOrReplace((Secret) getResourceFromMultiResourceFile("example-keycloak.yml", 1));
}
protected static void deleteDB() {

View file

@ -39,8 +39,8 @@ public class ClusteringE2EIT extends ClusterOperatorTest {
Keycloak keycloak = crSelector.get();
// when scale it to 10
keycloak.getSpec().setInstances(10);
// when scale it to 3
keycloak.getSpec().setInstances(3);
k8sclient.resources(Keycloak.class).inNamespace(namespace).createOrReplace(keycloak);
Awaitility.await()
@ -51,13 +51,15 @@ public class ClusteringE2EIT extends ClusterOperatorTest {
Awaitility.await()
.atMost(Duration.ofSeconds(5))
.untilAsserted(() -> assertThat(kcPodsSelector.list().getItems().size()).isEqualTo(10));
.ignoreExceptions()
.untilAsserted(() -> assertThat(kcPodsSelector.list().getItems().size()).isEqualTo(3));
// when scale it down to 2
keycloak.getSpec().setInstances(2);
k8sclient.resources(Keycloak.class).inNamespace(namespace).createOrReplace(keycloak);
Awaitility.await()
.atMost(Duration.ofSeconds(180))
.ignoreExceptions()
.untilAsserted(() -> assertThat(kcPodsSelector.list().getItems().size()).isEqualTo(2));
Awaitility.await()

View file

@ -10,6 +10,7 @@ import org.keycloak.operator.utils.K8sUtils;
import org.keycloak.operator.v2alpha1.KeycloakAdminSecret;
import org.keycloak.operator.v2alpha1.KeycloakService;
import org.keycloak.operator.v2alpha1.crds.Keycloak;
import org.keycloak.operator.v2alpha1.crds.ValueOrSecret;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
@ -65,8 +66,11 @@ public class KeycloakDeploymentE2EIT extends ClusterOperatorTest {
var deploymentName = kc.getMetadata().getName();
deployKeycloak(k8sclient, kc, true);
final var dbConf = new ValueOrSecret("KC_DB_PASSWORD", "Ay Caramba!");
kc.getSpec().setImage("quay.io/keycloak/non-existing-keycloak");
kc.getSpec().getServerConfiguration().put("KC_DB_PASSWORD", "Ay Caramba!");
kc.getSpec().getServerConfiguration().remove(dbConf);
kc.getSpec().getServerConfiguration().add(dbConf);
deployKeycloak(k8sclient, kc, false);
Awaitility.await()
@ -76,7 +80,7 @@ public class KeycloakDeploymentE2EIT extends ClusterOperatorTest {
.getSpec().getTemplate().getSpec().getContainers().get(0);
assertThat(c.getImage()).isEqualTo("quay.io/keycloak/non-existing-keycloak");
assertThat(c.getEnv().stream()
.anyMatch(e -> e.getName().equals("KC_DB_PASSWORD") && e.getValue().equals("Ay Caramba!")))
.anyMatch(e -> e.getName().equals(dbConf.getName()) && e.getValue().equals(dbConf.getValue())))
.isTrue();
});
@ -86,6 +90,32 @@ public class KeycloakDeploymentE2EIT extends ClusterOperatorTest {
}
}
@Test
public void testConfigInCRTakesPrecedence() {
try {
var kc = getDefaultKeycloakDeployment();
var health = new ValueOrSecret("KC_HEALTH_ENABLED", "false");
var e = new EnvVarBuilder().withName(health.getName()).withValue(health.getValue()).build();
kc.getSpec().getServerConfiguration().add(health);
deployKeycloak(k8sclient, kc, false);
assertThat(Constants.DEFAULT_DIST_CONFIG.get(health.getName())).isEqualTo("true"); // just a sanity check default values did not change
Awaitility.await()
.ignoreExceptions()
.untilAsserted(() -> {
Log.info("Asserting default value was overwritten by CR value");
var c = k8sclient.apps().deployments().inNamespace(namespace).withName(kc.getMetadata().getName()).get()
.getSpec().getTemplate().getSpec().getContainers().get(0);
assertThat(c.getEnv()).contains(e);
});
} catch (Exception e) {
savePodLogs();
throw e;
}
}
@Test
public void testDeploymentDurability() {
try {

View file

@ -0,0 +1,263 @@
/*
* 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;
import io.fabric8.kubernetes.api.model.Secret;
import io.quarkus.logging.Log;
import io.quarkus.test.junit.QuarkusTest;
import org.awaitility.Awaitility;
import org.bouncycastle.util.encoders.Base64;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.keycloak.operator.v2alpha1.WatchedSecretsStore;
import org.keycloak.operator.v2alpha1.crds.Keycloak;
import org.keycloak.operator.v2alpha1.crds.KeycloakStatusCondition;
import org.keycloak.operator.v2alpha1.crds.ValueOrSecret;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.keycloak.operator.utils.CRAssert.assertKeycloakStatusCondition;
import static org.keycloak.operator.utils.K8sUtils.deployKeycloak;
import static org.keycloak.operator.utils.K8sUtils.getDefaultKeycloakDeployment;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
@QuarkusTest
public class WatchedSecretsTestE2EIT extends ClusterOperatorTest {
@Test
public void testSecretsAreWatched() {
try {
var kc = getDefaultKeycloakDeployment();
deployKeycloak(k8sclient, kc, true);
Secret dbSecret = getDbSecret();
Secret tlsSecret = getTlsSecret();
assertThat(dbSecret.getMetadata().getLabels()).containsEntry(Constants.KEYCLOAK_COMPONENT_LABEL, WatchedSecretsStore.WATCHED_SECRETS_LABEL_VALUE);
assertThat(tlsSecret.getMetadata().getLabels()).containsEntry(Constants.KEYCLOAK_COMPONENT_LABEL, WatchedSecretsStore.WATCHED_SECRETS_LABEL_VALUE);
Log.info("Updating DB Secret, expecting restart");
testDeploymentRestarted(Set.of(kc), Set.of(), () -> {
dbSecret.getData().put(UUID.randomUUID().toString(), "YmxhaGJsYWg=");
k8sclient.secrets().createOrReplace(dbSecret);
});
Log.info("Updating TLS Secret, expecting restart");
testDeploymentRestarted(Set.of(kc), Set.of(), () -> {
tlsSecret.getData().put(UUID.randomUUID().toString(), "YmxhaGJsYWg=");
k8sclient.secrets().createOrReplace(tlsSecret);
});
Log.info("Updating DB Secret metadata, NOT expecting restart");
testDeploymentRestarted(Set.of(), Set.of(kc), () -> {
dbSecret.getMetadata().getLabels().put(UUID.randomUUID().toString(), "YmxhaGJsYWg");
k8sclient.secrets().createOrReplace(dbSecret);
});
} catch (Exception e) {
savePodLogs();
throw e;
}
}
@Test
public void testSecretChangesArePropagated() {
try {
final String username = "HomerSimpson";
var kc = getDefaultKeycloakDeployment();
deployKeycloak(k8sclient, kc, true);
var prevPodNames = getPodNamesForCrs(Set.of(kc));
var dbSecret = getDbSecret();
dbSecret.getData().put("username", Base64.toBase64String(username.getBytes()));
k8sclient.secrets().createOrReplace(dbSecret);
Awaitility.await()
.ignoreExceptions()
.untilAsserted(() -> {
Log.info("Checking pod logs for DB auth failures");
var podlogs = getPodNamesForCrs(Set.of(kc)).stream()
.filter(n -> !prevPodNames.contains(n)) // checking just new pods
.map(n -> k8sclient.pods().inNamespace(namespace).withName(n).getLog())
.collect(Collectors.toList());
assertThat(podlogs).anyMatch(l -> l.contains("password authentication failed for user \"" + username + "\""));
});
} catch (Exception e) {
savePodLogs();
throw e;
}
}
@Test
public void testSecretsCanBeUnWatched() {
try {
var kc = getDefaultKeycloakDeployment();
deployKeycloak(k8sclient, kc, true);
Log.info("Updating KC to not to rely on DB Secret");
hardcodeDBCredsInCR(kc);
testDeploymentRestarted(Set.of(kc), Set.of(), () -> {
deployKeycloak(k8sclient, kc, false, false);
});
Log.info("Updating DB Secret to trigger clean-up process");
testDeploymentRestarted(Set.of(), Set.of(kc), () -> {
var dbSecret = getDbSecret();
dbSecret.getMetadata().getLabels().put(UUID.randomUUID().toString(), "YmxhaGJsYWg");
k8sclient.secrets().createOrReplace(dbSecret);
});
Awaitility.await().untilAsserted(() -> {
Log.info("Checking labels on DB Secret");
assertThat(getDbSecret().getMetadata().getLabels()).doesNotContainKey(Constants.KEYCLOAK_COMPONENT_LABEL);
});
} catch (Exception e) {
savePodLogs();
throw e;
}
}
@Test
public void testSingleSecretMultipleKeycloaks() {
try {
var kc1 = getDefaultKeycloakDeployment();
kc1.getMetadata().setName(kc1.getMetadata().getName() + "-1");
kc1.getSpec().setHostname("kc1.local");
var kc2 = getDefaultKeycloakDeployment();
kc2.getMetadata().setName(kc2.getMetadata().getName() + "-2");
kc2.getSpec().setHostname("kc2.local"); // to prevent Ingress conflicts
deployKeycloak(k8sclient, kc1, true);
deployKeycloak(k8sclient, kc2, true);
var dbSecret = getDbSecret();
Log.info("Updating DB Secret, expecting restart of both KCs");
testDeploymentRestarted(Set.of(kc1, kc2), Set.of(), () -> {
dbSecret.getData().put(UUID.randomUUID().toString(), "YmxhaGJsYWg=");
k8sclient.secrets().createOrReplace(dbSecret);
});
Log.info("Updating KC1 to not to rely on DB Secret");
hardcodeDBCredsInCR(kc1);
testDeploymentRestarted(Set.of(kc1), Set.of(kc2), () -> {
deployKeycloak(k8sclient, kc1, false, false);
});
Log.info("Updating DB Secret, expecting restart of just KC2");
testDeploymentRestarted(Set.of(kc2), Set.of(kc1), () -> {
dbSecret.getData().put(UUID.randomUUID().toString(), "YmxhaGJsYWg=");
k8sclient.secrets().createOrReplace(dbSecret);
});
}
catch (Exception e) {
savePodLogs();
throw e;
}
}
private void testDeploymentRestarted(Set<Keycloak> crsToBeRestarted, Set<Keycloak> crsNotToBeRestarted, Runnable action) {
boolean restartExpected = !crsToBeRestarted.isEmpty();
List<String> podsToBeRestarted = getPodNamesForCrs(crsToBeRestarted);
List<String> podsNotToBeRestarted = getPodNamesForCrs(crsNotToBeRestarted);
action.run();
if (restartExpected) {
assertRollingUpdate(crsToBeRestarted, true);
}
Set<Keycloak> allCrs = new HashSet<>(crsToBeRestarted);
allCrs.addAll(crsNotToBeRestarted);
assertRollingUpdate(allCrs, false);
if (restartExpected) {
Awaitility.await()
.untilAsserted(() -> {
List<String> newPods = getPodNamesForCrs(allCrs);
Log.infof("Pods to be restarted: %s\nPods NOT to be restarted: %s\nCurrent Pods: %s",
podsToBeRestarted, podsNotToBeRestarted, newPods);
assertThat(newPods).noneMatch(podsToBeRestarted::contains);
assertThat(newPods).containsAll(podsNotToBeRestarted);
});
}
else {
Awaitility.await()
.during(10, TimeUnit.SECONDS) // to ensure no pods were created
.untilAsserted(() -> {
List<String> newPods = getPodNamesForCrs(allCrs);
Log.infof("Pods NOT to be restarted: %s, expected pods: %s\nAsserting current pods are unchanged: %s",
podsNotToBeRestarted, newPods);
assertThat(newPods).isEqualTo(podsNotToBeRestarted);
});
}
}
private List<String> getPodNamesForCrs(Set<Keycloak> crs) {
return k8sclient.pods().inNamespace(namespace).list().getItems().stream()
.map(pod -> pod.getMetadata().getName())
.filter(pod -> crs.stream().map(c -> c.getMetadata().getName()).anyMatch(pod::startsWith))
.collect(Collectors.toList());
}
private void assertRollingUpdate(Set<Keycloak> crs, boolean expectedStatus) {
Awaitility.await()
.untilAsserted(() -> {
for (var cr : crs) {
Keycloak kc = k8sclient.resources(Keycloak.class)
.inNamespace(namespace)
.withName(cr.getMetadata().getName())
.get();
assertKeycloakStatusCondition(kc, KeycloakStatusCondition.ROLLING_UPDATE, expectedStatus);
}
});
}
private Secret getDbSecret() {
return k8sclient.secrets().inNamespace(namespace).withName("keycloak-db-secret").get();
}
private Secret getTlsSecret() {
return k8sclient.secrets().inNamespace(namespace).withName("example-tls-secret").get();
}
private void hardcodeDBCredsInCR(Keycloak kc) {
var username = new ValueOrSecret("KC_DB_USERNAME", "postgres");
var password = new ValueOrSecret("KC_DB_PASSWORD", "testpassword");
kc.getSpec().getServerConfiguration().remove(username);
kc.getSpec().getServerConfiguration().add(username);
kc.getSpec().getServerConfiguration().remove(password);
kc.getSpec().getServerConfiguration().add(password);
}
@AfterEach
public void restoreDBSecret() {
deployDBSecret();
}
}

View file

@ -17,6 +17,7 @@
package org.keycloak.operator.utils;
import io.quarkus.logging.Log;
import org.keycloak.operator.v2alpha1.crds.Keycloak;
import org.keycloak.operator.v2alpha1.crds.KeycloakRealmImport;
@ -31,6 +32,7 @@ public final class CRAssert {
assertKeycloakStatusCondition(kc, condition, status, null);
}
public static void assertKeycloakStatusCondition(Keycloak kc, String condition, boolean status, String containedMessage) {
Log.infof("Asserting CR: %s, condition: %s, status: %s, message: %s", kc.getMetadata().getName(), condition, status, containedMessage);
assertThat(kc.getStatus().getConditions().stream()
.anyMatch(c ->
c.getType().equals(condition) &&

View file

@ -59,8 +59,15 @@ public final class K8sUtils {
public static void deployKeycloak(KubernetesClient client, Keycloak kc, boolean waitUntilReady) {
deployKeycloak(client, kc, waitUntilReady, true);
}
public static void deployKeycloak(KubernetesClient client, Keycloak kc, boolean waitUntilReady, boolean deployTlsSecret) {
client.resources(Keycloak.class).inNamespace(kc.getMetadata().getNamespace()).createOrReplace(kc);
client.secrets().inNamespace(kc.getMetadata().getNamespace()).createOrReplace(getDefaultTlsSecret());
if (deployTlsSecret) {
client.secrets().inNamespace(kc.getMetadata().getNamespace()).createOrReplace(getDefaultTlsSecret());
}
if (waitUntilReady) {
waitForKeycloakToBeReady(client, kc);

View file

@ -5,10 +5,14 @@ metadata:
spec:
instances: 1
serverConfiguration:
KC_DB: postgres
KC_DB_URL_HOST: postgres-db
KC_DB_USERNAME: postgres
KC_DB_PASSWORD: testpassword
- name: KC_DB
value: postgres
- name: KC_DB_URL_HOST
value: postgres-db
- name: KC_DB_USERNAME
value: postgres
- name: KC_DB_PASSWORD
value: testpassword
hostname: example.com
tlsSecret: INSECURE-DISABLE
unsupported:

View file

@ -5,10 +5,14 @@ metadata:
spec:
instances: 1
serverConfiguration:
KC_DB: postgres
KC_DB_URL_HOST: postgres-db
KC_DB_USERNAME: postgres
KC_DB_PASSWORD: testpassword
- name: KC_DB
value: postgres
- name: KC_DB_URL_HOST
value: postgres-db
- name: KC_DB_USERNAME
value: postgres
- name: KC_DB_PASSWORD
value: testpassword
hostname: example.com
tlsSecret: INSECURE-DISABLE
unsupported: