parent
d015fa392c
commit
fa83034474
10 changed files with 314 additions and 257 deletions
|
@ -22,6 +22,8 @@ import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.TreeMap;
|
import java.util.TreeMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
public final class Constants {
|
public final class Constants {
|
||||||
public static final String CRDS_GROUP = "k8s.keycloak.org";
|
public static final String CRDS_GROUP = "k8s.keycloak.org";
|
||||||
|
@ -34,13 +36,14 @@ public final class Constants {
|
||||||
public static final String MANAGED_BY_VALUE = "keycloak-operator";
|
public static final String MANAGED_BY_VALUE = "keycloak-operator";
|
||||||
public static final String COMPONENT_LABEL = "app.kubernetes.io/component";
|
public static final String COMPONENT_LABEL = "app.kubernetes.io/component";
|
||||||
public static final String KEYCLOAK_COMPONENT_LABEL = "operator.keycloak.org/component";
|
public static final String KEYCLOAK_COMPONENT_LABEL = "operator.keycloak.org/component";
|
||||||
|
public static final String KEYCLOAK_WATCHED_SECRET_HASH_ANNOTATION = "operator.keycloak.org/watched-secret-hash";
|
||||||
|
public static final String KEYCLOAK_WATCHING_ANNOTATION = "operator.keycloak.org/watching-secrets";
|
||||||
|
|
||||||
public static final Map<String, String> DEFAULT_LABELS = Collections.unmodifiableMap(new TreeMap<>(Map.of(
|
public static final String DEFAULT_LABELS_AS_STRING = "app=keycloak,app.kubernetes.io/managed-by=keycloak-operator";
|
||||||
"app", NAME,
|
|
||||||
MANAGED_BY_LABEL, MANAGED_BY_VALUE
|
|
||||||
)));
|
|
||||||
|
|
||||||
public static final String DEFAULT_LABELS_AS_STRING = Utils.toSelectorString(DEFAULT_LABELS);
|
public static final Map<String, String> DEFAULT_LABELS = Collections
|
||||||
|
.unmodifiableMap(Stream.of(DEFAULT_LABELS_AS_STRING.split(",")).map(s -> s.split("="))
|
||||||
|
.collect(Collectors.toMap(e -> e[0], e -> e[1], (u1, u2) -> u1, TreeMap::new)));
|
||||||
|
|
||||||
public static final List<ValueOrSecret> DEFAULT_DIST_CONFIG_LIST = List.of(
|
public static final List<ValueOrSecret> DEFAULT_DIST_CONFIG_LIST = List.of(
|
||||||
new ValueOrSecret("health-enabled", "true"),
|
new ValueOrSecret("health-enabled", "true"),
|
||||||
|
|
|
@ -55,6 +55,9 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
|
||||||
@Inject
|
@Inject
|
||||||
Config config;
|
Config config;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
WatchedSecrets watchedSecrets;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, EventSource> prepareEventSources(EventSourceContext<Keycloak> context) {
|
public Map<String, EventSource> prepareEventSources(EventSourceContext<Keycloak> context) {
|
||||||
String namespace = context.getControllerConfiguration().getConfigurationService().getKubernetesClient().getNamespace();
|
String namespace = context.getControllerConfiguration().getConfigurationService().getKubernetesClient().getNamespace();
|
||||||
|
@ -89,9 +92,7 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
|
||||||
|
|
||||||
return EventSourceInitializer.nameEventSources(statefulSetEvent,
|
return EventSourceInitializer.nameEventSources(statefulSetEvent,
|
||||||
servicesEvent,
|
servicesEvent,
|
||||||
ingressesEvent,
|
ingressesEvent, watchedSecrets.getWatchedSecretsEventSource());
|
||||||
WatchedSecretsStore.getStoreEventSource(client, namespace),
|
|
||||||
WatchedSecretsStore.getWatchedSecretsEventSource(client, namespace));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -107,14 +108,9 @@ public class KeycloakController implements Reconciler<Keycloak>, EventSourceInit
|
||||||
kcAdminSecret.createOrUpdateReconciled();
|
kcAdminSecret.createOrUpdateReconciled();
|
||||||
|
|
||||||
var kcDeployment = new KeycloakDeployment(client, config, kc, context.getSecondaryResource(StatefulSet.class).orElse(null), kcAdminSecret.getName());
|
var kcDeployment = new KeycloakDeployment(client, config, kc, context.getSecondaryResource(StatefulSet.class).orElse(null), kcAdminSecret.getName());
|
||||||
var watchedSecrets = new WatchedSecretsStore(kcDeployment.getConfigSecretsNames(), client, kc);
|
kcDeployment.setWatchedSecrets(watchedSecrets);
|
||||||
kcDeployment.createOrUpdateReconciled();
|
kcDeployment.createOrUpdateReconciled();
|
||||||
if (watchedSecrets.changesDetected()) {
|
|
||||||
Log.info("Config Secrets modified, restarting deployment");
|
|
||||||
kcDeployment.rollingRestart();
|
|
||||||
}
|
|
||||||
kcDeployment.updateStatus(statusAggregator);
|
kcDeployment.updateStatus(statusAggregator);
|
||||||
watchedSecrets.createOrUpdateReconciled();
|
|
||||||
|
|
||||||
var kcService = new KeycloakService(client, kc);
|
var kcService = new KeycloakService(client, kc);
|
||||||
kcService.updateStatus(statusAggregator);
|
kcService.updateStatus(statusAggregator);
|
||||||
|
|
|
@ -21,7 +21,6 @@ import io.fabric8.kubernetes.api.model.ContainerStateWaiting;
|
||||||
import io.fabric8.kubernetes.api.model.EnvVar;
|
import io.fabric8.kubernetes.api.model.EnvVar;
|
||||||
import io.fabric8.kubernetes.api.model.EnvVarBuilder;
|
import io.fabric8.kubernetes.api.model.EnvVarBuilder;
|
||||||
import io.fabric8.kubernetes.api.model.EnvVarSourceBuilder;
|
import io.fabric8.kubernetes.api.model.EnvVarSourceBuilder;
|
||||||
import io.fabric8.kubernetes.api.model.HasMetadata;
|
|
||||||
import io.fabric8.kubernetes.api.model.PodSpec;
|
import io.fabric8.kubernetes.api.model.PodSpec;
|
||||||
import io.fabric8.kubernetes.api.model.PodSpecFluent.ContainersNested;
|
import io.fabric8.kubernetes.api.model.PodSpecFluent.ContainersNested;
|
||||||
import io.fabric8.kubernetes.api.model.PodStatus;
|
import io.fabric8.kubernetes.api.model.PodStatus;
|
||||||
|
@ -54,13 +53,14 @@ import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.TreeSet;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import static org.keycloak.operator.crds.v2alpha1.CRDUtils.isTlsConfigured;
|
import static org.keycloak.operator.crds.v2alpha1.CRDUtils.isTlsConfigured;
|
||||||
|
|
||||||
public class KeycloakDeployment extends OperatorManagedResource implements StatusUpdater<KeycloakStatusAggregator> {
|
public class KeycloakDeployment extends OperatorManagedResource<StatefulSet> implements StatusUpdater<KeycloakStatusAggregator> {
|
||||||
|
|
||||||
private final Config operatorConfig;
|
private final Config operatorConfig;
|
||||||
private final KeycloakDistConfigurator distConfigurator;
|
private final KeycloakDistConfigurator distConfigurator;
|
||||||
|
@ -71,6 +71,7 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu
|
||||||
private final String adminSecretName;
|
private final String adminSecretName;
|
||||||
|
|
||||||
private Set<String> serverConfigSecretsNames;
|
private Set<String> serverConfigSecretsNames;
|
||||||
|
private WatchedSecrets watchedSecrets;
|
||||||
|
|
||||||
private boolean migrationInProgress;
|
private boolean migrationInProgress;
|
||||||
|
|
||||||
|
@ -87,8 +88,13 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu
|
||||||
addRemainingEnvVars();
|
addRemainingEnvVars();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setWatchedSecrets(WatchedSecrets watchedSecrets) {
|
||||||
|
this.watchedSecrets = watchedSecrets;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<HasMetadata> getReconciledResource() {
|
public Optional<StatefulSet> getReconciledResource() {
|
||||||
|
StatefulSet baseDeployment = new StatefulSetBuilder(this.baseDeployment).build(); // clone not to change the base template
|
||||||
if (existingDeployment == null) {
|
if (existingDeployment == null) {
|
||||||
Log.info("No existing Deployment found, using the default");
|
Log.info("No existing Deployment found, using the default");
|
||||||
}
|
}
|
||||||
|
@ -102,6 +108,12 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu
|
||||||
|
|
||||||
migrateDeployment(existingDeployment, baseDeployment);
|
migrateDeployment(existingDeployment, baseDeployment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var configSecretsNames = getConfigSecretsNames();
|
||||||
|
if (!configSecretsNames.isEmpty() && watchedSecrets != null) {
|
||||||
|
watchedSecrets.annotateDeployment(configSecretsNames, keycloakCR, baseDeployment);
|
||||||
|
}
|
||||||
|
|
||||||
return Optional.of(baseDeployment);
|
return Optional.of(baseDeployment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,6 +121,16 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu
|
||||||
return Optional.ofNullable(statefulSet).map(s -> getInstanceLabels().equals(s.getSpec().getSelector().getMatchLabels())).orElse(true);
|
return Optional.ofNullable(statefulSet).map(s -> getInstanceLabels().equals(s.getSpec().getSelector().getMatchLabels())).orElse(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<StatefulSet> createOrUpdateReconciled() {
|
||||||
|
var ret = super.createOrUpdateReconciled();
|
||||||
|
// after the change to the statefulset has been "committed", start watching
|
||||||
|
if (watchedSecrets != null) {
|
||||||
|
ret.map(StatefulSet.class::cast).ifPresent(watchedSecrets::addLabelsToWatchedSecrets);
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
public void validatePodTemplate(KeycloakStatusAggregator status) {
|
public void validatePodTemplate(KeycloakStatusAggregator status) {
|
||||||
var spec = getPodTemplateSpec();
|
var spec = getPodTemplateSpec();
|
||||||
if (spec.isEmpty()) {
|
if (spec.isEmpty()) {
|
||||||
|
@ -391,10 +413,10 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<String> getConfigSecretsNames() {
|
public List<String> getConfigSecretsNames() {
|
||||||
Set<String> ret = new HashSet<>(serverConfigSecretsNames);
|
TreeSet<String> ret = new TreeSet<>(serverConfigSecretsNames);
|
||||||
ret.addAll(distConfigurator.getSecretNames());
|
ret.addAll(distConfigurator.getSecretNames());
|
||||||
return ret;
|
return new ArrayList<>(ret);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -402,13 +424,6 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu
|
||||||
return keycloakCR.getMetadata().getName();
|
return keycloakCR.getMetadata().getName();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void rollingRestart() {
|
|
||||||
client.apps().statefulSets()
|
|
||||||
.inNamespace(getNamespace())
|
|
||||||
.withName(getName())
|
|
||||||
.rolling().restart();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void migrateDeployment(StatefulSet previousDeployment, StatefulSet reconciledDeployment) {
|
public void migrateDeployment(StatefulSet previousDeployment, StatefulSet reconciledDeployment) {
|
||||||
if (previousDeployment == null
|
if (previousDeployment == null
|
||||||
|| previousDeployment.getSpec() == null
|
|| previousDeployment.getSpec() == null
|
||||||
|
|
|
@ -20,7 +20,6 @@ package org.keycloak.operator.controllers;
|
||||||
import io.fabric8.kubernetes.api.model.HasMetadata;
|
import io.fabric8.kubernetes.api.model.HasMetadata;
|
||||||
import io.fabric8.kubernetes.api.model.OwnerReference;
|
import io.fabric8.kubernetes.api.model.OwnerReference;
|
||||||
import io.fabric8.kubernetes.api.model.OwnerReferenceBuilder;
|
import io.fabric8.kubernetes.api.model.OwnerReferenceBuilder;
|
||||||
import io.fabric8.kubernetes.client.CustomResource;
|
|
||||||
import io.fabric8.kubernetes.client.KubernetesClient;
|
import io.fabric8.kubernetes.client.KubernetesClient;
|
||||||
import io.fabric8.kubernetes.client.KubernetesClientException;
|
import io.fabric8.kubernetes.client.KubernetesClientException;
|
||||||
import io.fabric8.kubernetes.client.dsl.base.PatchContext;
|
import io.fabric8.kubernetes.client.dsl.base.PatchContext;
|
||||||
|
@ -40,20 +39,20 @@ import java.util.Optional;
|
||||||
*
|
*
|
||||||
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
||||||
*/
|
*/
|
||||||
public abstract class OperatorManagedResource {
|
public abstract class OperatorManagedResource<T extends HasMetadata> {
|
||||||
private static final String KEYCLOAK_OPERATOR_FIELD_MANAGER = "keycloak-operator";
|
private static final String KEYCLOAK_OPERATOR_FIELD_MANAGER = "keycloak-operator";
|
||||||
protected KubernetesClient client;
|
protected KubernetesClient client;
|
||||||
protected CustomResource<?, ?> cr;
|
protected HasMetadata cr;
|
||||||
|
|
||||||
public OperatorManagedResource(KubernetesClient client, CustomResource<?, ?> cr) {
|
public OperatorManagedResource(KubernetesClient client, HasMetadata cr) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.cr = cr;
|
this.cr = cr;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract Optional<HasMetadata> getReconciledResource();
|
protected abstract Optional<T> getReconciledResource();
|
||||||
|
|
||||||
public void createOrUpdateReconciled() {
|
public Optional<T> createOrUpdateReconciled() {
|
||||||
getReconciledResource().ifPresent(resource -> {
|
return getReconciledResource().map(resource -> {
|
||||||
try {
|
try {
|
||||||
setInstanceLabels(resource);
|
setInstanceLabels(resource);
|
||||||
setOwnerReferences(resource);
|
setOwnerReferences(resource);
|
||||||
|
@ -80,6 +79,7 @@ public abstract class OperatorManagedResource {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.debugf("Successfully created or updated resource: %s", resource);
|
Log.debugf("Successfully created or updated resource: %s", resource);
|
||||||
|
return resource;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.error("Failed to create or update resource");
|
Log.error("Failed to create or update resource");
|
||||||
Log.error(Serialization.asYaml(resource));
|
Log.error(Serialization.asYaml(resource));
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* 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.controllers;
|
||||||
|
|
||||||
|
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
|
||||||
|
import io.javaoperatorsdk.operator.processing.event.source.EventSource;
|
||||||
|
|
||||||
|
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a mechanism to track secrets
|
||||||
|
*
|
||||||
|
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
||||||
|
*/
|
||||||
|
public interface WatchedSecrets {
|
||||||
|
public static final String WATCHED_SECRETS_LABEL_VALUE = "watched-secret";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param deployment mutable resource being reconciled, it will be updated with annotations
|
||||||
|
*/
|
||||||
|
void annotateDeployment(List<String> desiredWatchedSecretsNames, Keycloak keycloakCR, StatefulSet deployment);
|
||||||
|
|
||||||
|
EventSource getWatchedSecretsEventSource();
|
||||||
|
|
||||||
|
void addLabelsToWatchedSecrets(StatefulSet deployment);
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,155 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2021 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.controllers;
|
||||||
|
|
||||||
|
import io.fabric8.kubernetes.api.model.Secret;
|
||||||
|
import io.fabric8.kubernetes.api.model.SecretBuilder;
|
||||||
|
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
|
||||||
|
import io.fabric8.kubernetes.client.KubernetesClient;
|
||||||
|
import io.fabric8.kubernetes.client.utils.Serialization;
|
||||||
|
import io.javaoperatorsdk.operator.api.reconciler.Context;
|
||||||
|
import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
|
||||||
|
import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext;
|
||||||
|
import io.javaoperatorsdk.operator.api.reconciler.EventSourceInitializer;
|
||||||
|
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
|
||||||
|
import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
|
||||||
|
import io.javaoperatorsdk.operator.processing.event.ResourceID;
|
||||||
|
import io.javaoperatorsdk.operator.processing.event.source.EventSource;
|
||||||
|
import io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache;
|
||||||
|
import io.javaoperatorsdk.operator.processing.event.source.inbound.SimpleInboundEventSource;
|
||||||
|
import io.quarkus.logging.Log;
|
||||||
|
|
||||||
|
import org.keycloak.operator.Constants;
|
||||||
|
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
|
||||||
|
import static io.javaoperatorsdk.operator.api.reconciler.Constants.WATCH_CURRENT_NAMESPACE;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
@ControllerConfiguration(namespaces = WATCH_CURRENT_NAMESPACE, labelSelector = Constants.KEYCLOAK_COMPONENT_LABEL + "=" + WatchedSecrets.WATCHED_SECRETS_LABEL_VALUE)
|
||||||
|
public class WatchedSecretsController implements Reconciler<Secret>, EventSourceInitializer<Secret>, WatchedSecrets {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
KubernetesClient client;
|
||||||
|
|
||||||
|
private final SimpleInboundEventSource eventSource = new SimpleInboundEventSource();
|
||||||
|
|
||||||
|
private volatile IndexerResourceCache<Secret> secrets;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, EventSource> prepareEventSources(EventSourceContext<Secret> context) {
|
||||||
|
this.secrets = context.getPrimaryCache();
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UpdateControl<Secret> reconcile(Secret resource, Context<Secret> context) throws Exception {
|
||||||
|
// find all statefulsets to notify
|
||||||
|
// - this could detect whether the reconciliation is even necessary if we track individual hashes
|
||||||
|
var ret = client.apps().statefulSets().inNamespace(resource.getMetadata().getNamespace())
|
||||||
|
.withLabels(Constants.DEFAULT_LABELS).list().getItems().stream()
|
||||||
|
.filter(statefulSet -> getSecretNames(statefulSet).contains(resource.getMetadata().getName()))
|
||||||
|
.map(statefulSet -> new ResourceID(statefulSet.getMetadata().getName(),
|
||||||
|
resource.getMetadata().getNamespace()))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
if (ret.isEmpty()) {
|
||||||
|
Log.infof("Removing label from Secret \"%s\"", resource.getMetadata().getName());
|
||||||
|
|
||||||
|
return UpdateControl.updateResource(new SecretBuilder(resource)
|
||||||
|
.editMetadata()
|
||||||
|
.removeFromLabels(Constants.KEYCLOAK_COMPONENT_LABEL)
|
||||||
|
.endMetadata()
|
||||||
|
.build());
|
||||||
|
} else {
|
||||||
|
ret.forEach(eventSource::propagateEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return UpdateControl.noUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public EventSource getWatchedSecretsEventSource() {
|
||||||
|
return eventSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void annotateDeployment(List<String> desiredWatchedSecretsNames, Keycloak keycloakCR, StatefulSet deployment) {
|
||||||
|
List<Secret> currentSecrets = fetchSecrets(desiredWatchedSecretsNames, keycloakCR.getMetadata().getNamespace());
|
||||||
|
deployment.getMetadata().getAnnotations().put(Constants.KEYCLOAK_WATCHING_ANNOTATION, desiredWatchedSecretsNames.stream().collect(Collectors.joining(";")));
|
||||||
|
deployment.getSpec().getTemplate().getMetadata().getAnnotations().put(Constants.KEYCLOAK_WATCHED_SECRET_HASH_ANNOTATION, getSecretHash(currentSecrets));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Secret> fetchSecrets(List<String> secretsNames, String namespace) {
|
||||||
|
return secretsNames.stream()
|
||||||
|
.map(n -> Optional.ofNullable(secrets).flatMap(cache -> cache.get(new ResourceID(n, namespace)))
|
||||||
|
.orElseGet(() -> client.secrets().inNamespace(namespace).withName(n).require()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSecretHash(List<Secret> currentSecrets) {
|
||||||
|
try {
|
||||||
|
// using hashes as it's more robust than resource versions that can change e.g. just when adding a label
|
||||||
|
// Uses a fips compliant hash
|
||||||
|
var messageDigest = MessageDigest.getInstance("SHA-256");
|
||||||
|
|
||||||
|
currentSecrets.stream()
|
||||||
|
.map(s -> Serialization.asYaml(s.getData()).getBytes(StandardCharsets.UTF_8))
|
||||||
|
.forEachOrdered(s -> messageDigest.update(s));
|
||||||
|
|
||||||
|
return new BigInteger(1, messageDigest.digest()).toString(16);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addLabelsToWatchedSecrets(StatefulSet deployment) {
|
||||||
|
for (Secret secret : fetchSecrets(getSecretNames(deployment), deployment.getMetadata().getNamespace())) {
|
||||||
|
if (!secret.getMetadata().getLabels().containsKey(Constants.KEYCLOAK_COMPONENT_LABEL)) {
|
||||||
|
|
||||||
|
Log.infof("Adding label to Secret \"%s\"", secret.getMetadata().getName());
|
||||||
|
|
||||||
|
client.resource(secret).accept(s -> {
|
||||||
|
s.getMetadata().getLabels().put(Constants.KEYCLOAK_COMPONENT_LABEL, WatchedSecrets.WATCHED_SECRETS_LABEL_VALUE);
|
||||||
|
s.getMetadata().setResourceVersion(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getSecretNames(StatefulSet deployment) {
|
||||||
|
return Optional
|
||||||
|
.ofNullable(deployment.getMetadata().getAnnotations().get(Constants.KEYCLOAK_WATCHING_ANNOTATION))
|
||||||
|
.filter(watching -> !watching.isEmpty())
|
||||||
|
.map(watching -> watching.split(";")).map(Arrays::asList).orElse(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,219 +0,0 @@
|
||||||
/*
|
|
||||||
* 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.controllers;
|
|
||||||
|
|
||||||
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.utils.Serialization;
|
|
||||||
import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration;
|
|
||||||
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.Utils;
|
|
||||||
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
|
|
||||||
|
|
||||||
import java.math.BigInteger;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
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 = getNewStore();
|
|
||||||
secret.setData(currentVersions);
|
|
||||||
|
|
||||||
return Optional.of(secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void setInstanceLabels(HasMetadata resource) {
|
|
||||||
super.setInstanceLabels(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());
|
|
||||||
|
|
||||||
client.secrets().inNamespace(secret.getMetadata().getNamespace()).withName(secret.getMetadata().getName())
|
|
||||||
.edit(s -> new SecretBuilder(s)
|
|
||||||
.editMetadata()
|
|
||||||
.addToLabels(Constants.KEYCLOAK_COMPONENT_LABEL, WATCHED_SECRETS_LABEL_VALUE)
|
|
||||||
.endMetadata()
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
return Optional.ofNullable(existingStore).map(Secret::getData).orElse(Map.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
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 Utils.asBase64(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 -> client.secrets().inNamespace(getNamespace()).withName(n).require())
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getName() {
|
|
||||||
return cr.getMetadata().getName() + STORE_SUFFIX;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static EventSource getStoreEventSource(KubernetesClient client, String namespace) {
|
|
||||||
InformerConfiguration<Secret> informerConfiguration = InformerConfiguration
|
|
||||||
.from(Secret.class)
|
|
||||||
.withLabelSelector(Constants.COMPONENT_LABEL + "=" + COMPONENT)
|
|
||||||
.withNamespaces(namespace)
|
|
||||||
.withSecondaryToPrimaryMapper(Mappers.fromOwnerReference())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return new InformerEventSource<>(informerConfiguration, client);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void cleanObsoleteLabelFromSecret(KubernetesClient client, Secret secret) {
|
|
||||||
client.secrets().inNamespace(secret.getMetadata().getNamespace()).withName(secret.getMetadata().getName())
|
|
||||||
.edit(s -> new SecretBuilder(s)
|
|
||||||
.editMetadata()
|
|
||||||
.removeFromLabels(Constants.KEYCLOAK_COMPONENT_LABEL)
|
|
||||||
.endMetadata()
|
|
||||||
.build()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static EventSource getWatchedSecretsEventSource(KubernetesClient client, String namespace) {
|
|
||||||
InformerConfiguration<Secret> informerConfiguration = InformerConfiguration
|
|
||||||
.from(Secret.class)
|
|
||||||
.withLabelSelector(Constants.KEYCLOAK_COMPONENT_LABEL + "=" + WATCHED_SECRETS_LABEL_VALUE)
|
|
||||||
.withNamespaces(namespace)
|
|
||||||
.withSecondaryToPrimaryMapper(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;
|
|
||||||
})
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return new InformerEventSource<>(informerConfiguration, client);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -84,7 +84,7 @@ public class ClusteringTest extends BaseOperatorTest {
|
||||||
|
|
||||||
// the main resources are ready, check for the expected dependents
|
// the main resources are ready, check for the expected dependents
|
||||||
checkInstanceCount(1, StatefulSet.class, kc, kc1);
|
checkInstanceCount(1, StatefulSet.class, kc, kc1);
|
||||||
checkInstanceCount(2, Secret.class, kc, kc1);
|
checkInstanceCount(1, Secret.class, kc, kc1);
|
||||||
checkInstanceCount(1, Ingress.class, kc, kc1);
|
checkInstanceCount(1, Ingress.class, kc, kc1);
|
||||||
checkInstanceCount(2, Service.class, kc, kc1);
|
checkInstanceCount(2, Service.class, kc, kc1);
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ import org.awaitility.Awaitility;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.keycloak.operator.Constants;
|
import org.keycloak.operator.Constants;
|
||||||
import org.keycloak.operator.controllers.WatchedSecretsStore;
|
import org.keycloak.operator.controllers.WatchedSecrets;
|
||||||
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
|
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
|
||||||
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakStatusCondition;
|
import org.keycloak.operator.crds.v2alpha1.deployment.KeycloakStatusCondition;
|
||||||
import org.keycloak.operator.crds.v2alpha1.deployment.ValueOrSecret;
|
import org.keycloak.operator.crds.v2alpha1.deployment.ValueOrSecret;
|
||||||
|
@ -58,8 +58,8 @@ public class WatchedSecretsTest extends BaseOperatorTest {
|
||||||
Secret dbSecret = getDbSecret();
|
Secret dbSecret = getDbSecret();
|
||||||
Secret tlsSecret = getTlsSecret();
|
Secret tlsSecret = getTlsSecret();
|
||||||
|
|
||||||
assertThat(dbSecret.getMetadata().getLabels()).containsEntry(Constants.KEYCLOAK_COMPONENT_LABEL, WatchedSecretsStore.WATCHED_SECRETS_LABEL_VALUE);
|
assertThat(dbSecret.getMetadata().getLabels()).containsEntry(Constants.KEYCLOAK_COMPONENT_LABEL, WatchedSecrets.WATCHED_SECRETS_LABEL_VALUE);
|
||||||
assertThat(tlsSecret.getMetadata().getLabels()).containsEntry(Constants.KEYCLOAK_COMPONENT_LABEL, WatchedSecretsStore.WATCHED_SECRETS_LABEL_VALUE);
|
assertThat(tlsSecret.getMetadata().getLabels()).containsEntry(Constants.KEYCLOAK_COMPONENT_LABEL, WatchedSecrets.WATCHED_SECRETS_LABEL_VALUE);
|
||||||
|
|
||||||
Log.info("Updating DB Secret, expecting restart");
|
Log.info("Updating DB Secret, expecting restart");
|
||||||
testDeploymentRestarted(Set.of(kc), Set.of(), () -> {
|
testDeploymentRestarted(Set.of(kc), Set.of(), () -> {
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
/*
|
||||||
|
* 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.testsuite.unit;
|
||||||
|
|
||||||
|
import io.fabric8.kubernetes.api.model.Secret;
|
||||||
|
import io.fabric8.kubernetes.api.model.SecretBuilder;
|
||||||
|
import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder;
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.keycloak.operator.Constants;
|
||||||
|
import org.keycloak.operator.controllers.WatchedSecretsController;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
public class WatchedSecretsControllerTest {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
WatchedSecretsController watchedSecretsController;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSecretHashing() {
|
||||||
|
assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", watchedSecretsController.getSecretHash(List.of()));
|
||||||
|
assertEquals("b5655bfe4d4e130f5023a76a5de0906cf84eb5895bda5d44642673f9eb4024bf", watchedSecretsController.getSecretHash(List.of(newSecret(Map.of("a", "b")), newSecret(Map.of("c", "d")))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testGetSecretNames() {
|
||||||
|
assertEquals(List.of(), watchedSecretsController.getSecretNames(new StatefulSetBuilder().withNewMetadata().addToAnnotations(Constants.KEYCLOAK_WATCHING_ANNOTATION, "").endMetadata().build()));
|
||||||
|
assertEquals(Arrays.asList("something"), watchedSecretsController.getSecretNames(new StatefulSetBuilder().withNewMetadata().addToAnnotations(Constants.KEYCLOAK_WATCHING_ANNOTATION, "something").endMetadata().build()));
|
||||||
|
assertEquals(Arrays.asList("x", "y"), watchedSecretsController.getSecretNames(new StatefulSetBuilder().withNewMetadata().addToAnnotations(Constants.KEYCLOAK_WATCHING_ANNOTATION, "x;y").endMetadata().build()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Secret newSecret(Map<String, String> data) {
|
||||||
|
return new SecretBuilder().withNewMetadata().withName(UUID.randomUUID().toString())
|
||||||
|
.withLabels(Map.of(UUID.randomUUID().toString(), UUID.randomUUID().toString())).endMetadata()
|
||||||
|
.withData(data).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in a new issue