diff --git a/operator/src/main/java/org/keycloak/operator/Constants.java b/operator/src/main/java/org/keycloak/operator/Constants.java index ab9c41d6bf..3c89114fa9 100644 --- a/operator/src/main/java/org/keycloak/operator/Constants.java +++ b/operator/src/main/java/org/keycloak/operator/Constants.java @@ -22,6 +22,8 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; public final class Constants { 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 COMPONENT_LABEL = "app.kubernetes.io/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 DEFAULT_LABELS = Collections.unmodifiableMap(new TreeMap<>(Map.of( - "app", NAME, - MANAGED_BY_LABEL, MANAGED_BY_VALUE - ))); + public static final String DEFAULT_LABELS_AS_STRING = "app=keycloak,app.kubernetes.io/managed-by=keycloak-operator"; - public static final String DEFAULT_LABELS_AS_STRING = Utils.toSelectorString(DEFAULT_LABELS); + public static final Map 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 DEFAULT_DIST_CONFIG_LIST = List.of( new ValueOrSecret("health-enabled", "true"), diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakController.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakController.java index c38114081d..e2c298f795 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakController.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakController.java @@ -55,6 +55,9 @@ public class KeycloakController implements Reconciler, EventSourceInit @Inject Config config; + @Inject + WatchedSecrets watchedSecrets; + @Override public Map prepareEventSources(EventSourceContext context) { String namespace = context.getControllerConfiguration().getConfigurationService().getKubernetesClient().getNamespace(); @@ -89,9 +92,7 @@ public class KeycloakController implements Reconciler, EventSourceInit return EventSourceInitializer.nameEventSources(statefulSetEvent, servicesEvent, - ingressesEvent, - WatchedSecretsStore.getStoreEventSource(client, namespace), - WatchedSecretsStore.getWatchedSecretsEventSource(client, namespace)); + ingressesEvent, watchedSecrets.getWatchedSecretsEventSource()); } @Override @@ -107,14 +108,9 @@ public class KeycloakController implements Reconciler, EventSourceInit kcAdminSecret.createOrUpdateReconciled(); 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(); - if (watchedSecrets.changesDetected()) { - Log.info("Config Secrets modified, restarting deployment"); - kcDeployment.rollingRestart(); - } kcDeployment.updateStatus(statusAggregator); - watchedSecrets.createOrUpdateReconciled(); var kcService = new KeycloakService(client, kc); kcService.updateStatus(statusAggregator); diff --git a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeployment.java b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeployment.java index 7cbabe557f..ba4d6627e9 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeployment.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/KeycloakDeployment.java @@ -21,7 +21,6 @@ import io.fabric8.kubernetes.api.model.ContainerStateWaiting; 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.PodSpec; import io.fabric8.kubernetes.api.model.PodSpecFluent.ContainersNested; import io.fabric8.kubernetes.api.model.PodStatus; @@ -54,13 +53,14 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.TreeSet; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; import static org.keycloak.operator.crds.v2alpha1.CRDUtils.isTlsConfigured; -public class KeycloakDeployment extends OperatorManagedResource implements StatusUpdater { +public class KeycloakDeployment extends OperatorManagedResource implements StatusUpdater { private final Config operatorConfig; private final KeycloakDistConfigurator distConfigurator; @@ -71,6 +71,7 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu private final String adminSecretName; private Set serverConfigSecretsNames; + private WatchedSecrets watchedSecrets; private boolean migrationInProgress; @@ -87,8 +88,13 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu addRemainingEnvVars(); } + public void setWatchedSecrets(WatchedSecrets watchedSecrets) { + this.watchedSecrets = watchedSecrets; + } + @Override - public Optional getReconciledResource() { + public Optional getReconciledResource() { + StatefulSet baseDeployment = new StatefulSetBuilder(this.baseDeployment).build(); // clone not to change the base template if (existingDeployment == null) { Log.info("No existing Deployment found, using the default"); } @@ -102,6 +108,12 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu migrateDeployment(existingDeployment, baseDeployment); } + + var configSecretsNames = getConfigSecretsNames(); + if (!configSecretsNames.isEmpty() && watchedSecrets != null) { + watchedSecrets.annotateDeployment(configSecretsNames, keycloakCR, 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); } + @Override + public Optional 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) { var spec = getPodTemplateSpec(); if (spec.isEmpty()) { @@ -391,10 +413,10 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu }); } - public Set getConfigSecretsNames() { - Set ret = new HashSet<>(serverConfigSecretsNames); + public List getConfigSecretsNames() { + TreeSet ret = new TreeSet<>(serverConfigSecretsNames); ret.addAll(distConfigurator.getSecretNames()); - return ret; + return new ArrayList<>(ret); } @Override @@ -402,13 +424,6 @@ public class KeycloakDeployment extends OperatorManagedResource implements Statu return keycloakCR.getMetadata().getName(); } - public void rollingRestart() { - client.apps().statefulSets() - .inNamespace(getNamespace()) - .withName(getName()) - .rolling().restart(); - } - public void migrateDeployment(StatefulSet previousDeployment, StatefulSet reconciledDeployment) { if (previousDeployment == null || previousDeployment.getSpec() == null diff --git a/operator/src/main/java/org/keycloak/operator/controllers/OperatorManagedResource.java b/operator/src/main/java/org/keycloak/operator/controllers/OperatorManagedResource.java index b0c93fdccd..d8feb84c94 100644 --- a/operator/src/main/java/org/keycloak/operator/controllers/OperatorManagedResource.java +++ b/operator/src/main/java/org/keycloak/operator/controllers/OperatorManagedResource.java @@ -20,7 +20,6 @@ package org.keycloak.operator.controllers; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.OwnerReference; import io.fabric8.kubernetes.api.model.OwnerReferenceBuilder; -import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.dsl.base.PatchContext; @@ -40,20 +39,20 @@ import java.util.Optional; * * @author Vaclav Muzikar */ -public abstract class OperatorManagedResource { +public abstract class OperatorManagedResource { private static final String KEYCLOAK_OPERATOR_FIELD_MANAGER = "keycloak-operator"; 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.cr = cr; } - protected abstract Optional getReconciledResource(); + protected abstract Optional getReconciledResource(); - public void createOrUpdateReconciled() { - getReconciledResource().ifPresent(resource -> { + public Optional createOrUpdateReconciled() { + return getReconciledResource().map(resource -> { try { setInstanceLabels(resource); setOwnerReferences(resource); @@ -80,6 +79,7 @@ public abstract class OperatorManagedResource { } } Log.debugf("Successfully created or updated resource: %s", resource); + return resource; } catch (Exception e) { Log.error("Failed to create or update resource"); Log.error(Serialization.asYaml(resource)); diff --git a/operator/src/main/java/org/keycloak/operator/controllers/WatchedSecrets.java b/operator/src/main/java/org/keycloak/operator/controllers/WatchedSecrets.java new file mode 100644 index 0000000000..83a81c493c --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/controllers/WatchedSecrets.java @@ -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 + */ +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 desiredWatchedSecretsNames, Keycloak keycloakCR, StatefulSet deployment); + + EventSource getWatchedSecretsEventSource(); + + void addLabelsToWatchedSecrets(StatefulSet deployment); + +} \ No newline at end of file diff --git a/operator/src/main/java/org/keycloak/operator/controllers/WatchedSecretsController.java b/operator/src/main/java/org/keycloak/operator/controllers/WatchedSecretsController.java new file mode 100644 index 0000000000..460d0f3012 --- /dev/null +++ b/operator/src/main/java/org/keycloak/operator/controllers/WatchedSecretsController.java @@ -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, EventSourceInitializer, WatchedSecrets { + + @Inject + KubernetesClient client; + + private final SimpleInboundEventSource eventSource = new SimpleInboundEventSource(); + + private volatile IndexerResourceCache secrets; + + @Override + public Map prepareEventSources(EventSourceContext context) { + this.secrets = context.getPrimaryCache(); + return Map.of(); + } + + @Override + public UpdateControl reconcile(Secret resource, Context 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 desiredWatchedSecretsNames, Keycloak keycloakCR, StatefulSet deployment) { + List 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 fetchSecrets(List 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 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 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()); + } + +} diff --git a/operator/src/main/java/org/keycloak/operator/controllers/WatchedSecretsStore.java b/operator/src/main/java/org/keycloak/operator/controllers/WatchedSecretsStore.java deleted file mode 100644 index 0ca64302cf..0000000000 --- a/operator/src/main/java/org/keycloak/operator/controllers/WatchedSecretsStore.java +++ /dev/null @@ -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 - */ -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 lastObservedVersions; - private final Map currentVersions; - private final Set currentSecrets; - - public WatchedSecretsStore(Set 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 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 getNewLastObservedVersions() { - return Optional.ofNullable(existingStore).map(Secret::getData).orElse(Map.of()); - } - - private Map 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 fetchCurrentSecrets(Set 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 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 informerConfiguration = InformerConfiguration - .from(Secret.class) - .withLabelSelector(Constants.KEYCLOAK_COMPONENT_LABEL + "=" + WATCHED_SECRETS_LABEL_VALUE) - .withNamespaces(namespace) - .withSecondaryToPrimaryMapper(secret -> { - // get all stores - List 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); - } -} diff --git a/operator/src/test/java/org/keycloak/operator/testsuite/integration/ClusteringTest.java b/operator/src/test/java/org/keycloak/operator/testsuite/integration/ClusteringTest.java index 4a90b57720..70758abbd6 100644 --- a/operator/src/test/java/org/keycloak/operator/testsuite/integration/ClusteringTest.java +++ b/operator/src/test/java/org/keycloak/operator/testsuite/integration/ClusteringTest.java @@ -84,7 +84,7 @@ public class ClusteringTest extends BaseOperatorTest { // the main resources are ready, check for the expected dependents 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(2, Service.class, kc, kc1); diff --git a/operator/src/test/java/org/keycloak/operator/testsuite/integration/WatchedSecretsTest.java b/operator/src/test/java/org/keycloak/operator/testsuite/integration/WatchedSecretsTest.java index 9e303ed8db..b3e427bd8d 100644 --- a/operator/src/test/java/org/keycloak/operator/testsuite/integration/WatchedSecretsTest.java +++ b/operator/src/test/java/org/keycloak/operator/testsuite/integration/WatchedSecretsTest.java @@ -25,7 +25,7 @@ import org.awaitility.Awaitility; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; 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.KeycloakStatusCondition; import org.keycloak.operator.crds.v2alpha1.deployment.ValueOrSecret; @@ -58,8 +58,8 @@ public class WatchedSecretsTest extends BaseOperatorTest { 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); + assertThat(dbSecret.getMetadata().getLabels()).containsEntry(Constants.KEYCLOAK_COMPONENT_LABEL, WatchedSecrets.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"); testDeploymentRestarted(Set.of(kc), Set.of(), () -> { diff --git a/operator/src/test/java/org/keycloak/operator/testsuite/unit/WatchedSecretsControllerTest.java b/operator/src/test/java/org/keycloak/operator/testsuite/unit/WatchedSecretsControllerTest.java new file mode 100644 index 0000000000..e8d027a820 --- /dev/null +++ b/operator/src/test/java/org/keycloak/operator/testsuite/unit/WatchedSecretsControllerTest.java @@ -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 data) { + return new SecretBuilder().withNewMetadata().withName(UUID.randomUUID().toString()) + .withLabels(Map.of(UUID.randomUUID().toString(), UUID.randomUUID().toString())).endMetadata() + .withData(data).build(); + } + +}