Secret references in Keycloak CRD (#10716)
This commit is contained in:
parent
fb92b95c33
commit
c0255cbeea
18 changed files with 792 additions and 96 deletions
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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) &&
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue