From 4a06c3f0a44ce08acf015a52b8ca1b2115d4ead3 Mon Sep 17 00:00:00 2001 From: Laird Nelson Date: Fri, 25 Aug 2023 13:14:36 -0700 Subject: [PATCH 1/2] 3.x: Makes SecretBundleConfigSource public; improves modification logic (backport of #7421) Signed-off-by: Laird Nelson --- .../etc/spotbugs/exclude.xml | 2 +- .../OciSecretsConfigSourceProvider.java | 435 +----------- .../SecretBundleConfigSource.java | 639 ++++++++++++++++++ .../src/main/java/module-info.java | 4 +- .../secrets/configsource/IsModifiedTest.java | 16 +- .../secrets/configsource/ValueNodeTest.java | 2 +- 6 files changed, 661 insertions(+), 437 deletions(-) create mode 100644 integrations/oci/oci-secrets-config-source/src/main/java/io/helidon/integrations/oci/secrets/configsource/SecretBundleConfigSource.java diff --git a/integrations/oci/oci-secrets-config-source/etc/spotbugs/exclude.xml b/integrations/oci/oci-secrets-config-source/etc/spotbugs/exclude.xml index b967a4ee6bc..f54bf53b43d 100644 --- a/integrations/oci/oci-secrets-config-source/etc/spotbugs/exclude.xml +++ b/integrations/oci/oci-secrets-config-source/etc/spotbugs/exclude.xml @@ -23,7 +23,7 @@ xsi:schemaLocation="https://github.com/spotbugs/filter/3.0.0 https://raw.githubusercontent.com/spotbugs/spotbugs/3.1.0/spotbugs/etc/findbugsfilter.xsd"> - + diff --git a/integrations/oci/oci-secrets-config-source/src/main/java/io/helidon/integrations/oci/secrets/configsource/OciSecretsConfigSourceProvider.java b/integrations/oci/oci-secrets-config-source/src/main/java/io/helidon/integrations/oci/secrets/configsource/OciSecretsConfigSourceProvider.java index e5e16f26b7c..a7052067963 100644 --- a/integrations/oci/oci-secrets-config-source/src/main/java/io/helidon/integrations/oci/secrets/configsource/OciSecretsConfigSourceProvider.java +++ b/integrations/oci/oci-secrets-config-source/src/main/java/io/helidon/integrations/oci/secrets/configsource/OciSecretsConfigSourceProvider.java @@ -15,58 +15,14 @@ */ package io.helidon.integrations.oci.secrets.configsource; -import java.lang.System.Logger; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Base64; -import java.util.Collection; -import java.util.List; -import java.util.Map.Entry; -import java.util.Objects; -import java.util.Optional; import java.util.Set; -import java.util.concurrent.Callable; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; -import java.util.function.BiConsumer; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.function.Supplier; -import io.helidon.common.LazyValue; -import io.helidon.config.AbstractConfigSource; -import io.helidon.config.AbstractConfigSourceBuilder; import io.helidon.config.Config; -import io.helidon.config.spi.ConfigContent.NodeContent; -import io.helidon.config.spi.ConfigNode.ObjectNode; -import io.helidon.config.spi.ConfigNode.ValueNode; import io.helidon.config.spi.ConfigSource; import io.helidon.config.spi.ConfigSourceProvider; -import io.helidon.config.spi.NodeConfigSource; -import io.helidon.config.spi.PollableSource; -import io.helidon.config.spi.PollingStrategy; -import com.oracle.bmc.auth.BasicAuthenticationDetailsProvider; -import com.oracle.bmc.secrets.Secrets; -import com.oracle.bmc.secrets.SecretsClient; -import com.oracle.bmc.secrets.model.Base64SecretBundleContentDetails; -import com.oracle.bmc.secrets.model.SecretBundleContentDetails; -import com.oracle.bmc.secrets.requests.GetSecretBundleRequest; -import com.oracle.bmc.vault.Vaults; -import com.oracle.bmc.vault.VaultsClient; -import com.oracle.bmc.vault.model.SecretSummary; -import com.oracle.bmc.vault.requests.ListSecretsRequest; import jakarta.annotation.Priority; -import static io.helidon.integrations.oci.sdk.runtime.OciExtension.ociAuthenticationProvider; -import static java.lang.System.Logger.Level.WARNING; -import static java.nio.charset.StandardCharsets.UTF_8; -import static java.time.Instant.now; -import static java.util.concurrent.Executors.newCachedThreadPool; - /** * A {@link ConfigSourceProvider} that {@linkplain #create(String, Config) creates} {@link ConfigSource} implementations * that interact with the Oracle Cloud Infrastructure (OCI) Vault. * * @param type one of the {@linkplain #supported() supported types}; not actually used @@ -146,18 +102,20 @@ public OciSecretsConfigSourceProvider() { * @param metaConfig a {@link Config} serving as meta-configuration for this provider; must not be {@code null} when * {@code type} is {@linkplain #supports(String) supported} * - * @return a non-{@code null} {@link ConfigSource} + * @return a non-{@code null} {@link SecretBundleConfigSource} * * @exception NullPointerException if {@code type} is {@linkplain #supports(String) supported} and {@code * metaConfig} is {@code null} * * @see #supported() * + * @see SecretBundleConfigSource + * * @deprecated For use by the Helidon Config subsystem only. */ @Deprecated // For use by the Helidon Config subsystem only. @Override // ConfigSourceProvider - public ConfigSource create(String type, Config metaConfig) { + public SecretBundleConfigSource create(String type, Config metaConfig) { return SecretBundleConfigSource.builder().config(metaConfig).build(); } @@ -200,385 +158,4 @@ public boolean supports(String type) { return this.supported().contains(type); } - - /* - * Inner and nested classes. - */ - - - static final class SecretBundleConfigSource - extends AbstractConfigSource implements NodeConfigSource, PollableSource { - - - /* - * Static fields. - */ - - - private static final Optional ABSENT_NODE_CONTENT = - Optional.of(NodeContent.builder().node(ObjectNode.empty()).build()); - - private static final String COMPARTMENT_OCID_PROPERTY_NAME = "compartment-ocid"; - - private static final Logger LOGGER = System.getLogger(SecretBundleConfigSource.class.getName()); - - private static final String VAULT_OCID_PROPERTY_NAME = "vault-ocid"; - - - /* - * Instance fields. - */ - - - private final ExecutorService es; - - private final Supplier> loader; - - private volatile Instant closestSecretExpirationInstant; - - - /* - * Constructors. - */ - - - private SecretBundleConfigSource(Builder b) { - super(b); - // From Executors#newCachedThreadPool() javadoc: "Creates a thread pool that creates new threads as needed, - // but will reuse previously constructed threads when they are available. These pools will typically improve - // the performance of programs that execute many short-lived asynchronous tasks." That describes our use - // case exactly. - this.es = newCachedThreadPool(); - // Helidon Config has no defined lifecycle so the best we can do is forcibly close the ExecutorService on VM - // exit. - Runtime.getRuntime().addShutdownHook(new Thread(() -> this.es.shutdownNow())); - Supplier secretsSupplier = Objects.requireNonNull(b.secretsSupplier, "b.secretsSupplier"); - Supplier vaultsSupplier = Objects.requireNonNull(b.vaultsSupplier, "b.vaultsSupplier"); - this.closestSecretExpirationInstant = now(); - String compartmentOcid = b.compartmentOcid; - String vaultOcid = b.vaultOcid; - if (compartmentOcid == null || vaultOcid == null) { - // (It is not immediately clear why the OCI Java SDK requires a Compartment OCID, since a Vault OCID is - // sufficient to uniquely identify any Vault.) - this.loader = this::absentNodeContent; - } else { - ListSecretsRequest listSecretsRequest = ListSecretsRequest.builder() - .compartmentId(compartmentOcid) - .vaultId(vaultOcid) - .build(); - this.loader = () -> this.load(vaultsSupplier, secretsSupplier, listSecretsRequest); - } - } - - - /* - * Instance methods. - */ - - - @Deprecated // For use by the Helidon Config subsystem only. - @Override // PollableSource - public boolean isModified(Instant pollInstant) { - return isModified(pollInstant, this.closestSecretExpirationInstant); // volatile read - } - - @Deprecated // For use by the Helidon Config subsystem only. - @Override // NodeConfigSource - public Optional load() { - return this.loader.get(); - } - - @Deprecated // For use by the Helidon Config subsystem only. - @Override // PollableSource - public Optional pollingStrategy() { - return super.pollingStrategy(); - } - - private Optional absentNodeContent() { - return ABSENT_NODE_CONTENT; - } - - private void completeTasks(Collection> tasks, AutoCloseable autoCloseable) { - try (autoCloseable) { - completeTasks(this.es, tasks); - } catch (RuntimeException e) { - throw e; - } catch (InterruptedException e) { - // (Can legally be thrown by any AutoCloseable. Must preserve interrupt status.) - Thread.currentThread().interrupt(); - throw new IllegalStateException(e.getMessage(), e); - } catch (Exception e) { - // (Can legally be thrown by any AutoCloseable.) - throw new IllegalStateException(e.getMessage(), e); - } - } - - private Optional load(Supplier vaultsSupplier, - Supplier secretsSupplier, - ListSecretsRequest listSecretsRequest) { - Collection secretSummaries = secretSummaries(vaultsSupplier, listSecretsRequest); - return this.load(secretSummaries, secretsSupplier); - } - - private Optional load(Collection secretSummaries, - Supplier secretsSupplier) { - if (secretSummaries.isEmpty()) { - return this.absentNodeContent(); - } - ConcurrentMap valueNodes = new ConcurrentHashMap<>(); - Collection> tasks = new ArrayList<>(secretSummaries.size()); - Base64.Decoder decoder = Base64.getDecoder(); - Secrets secrets = secretsSupplier.get(); - Instant closestSecretExpirationInstant = this.closestSecretExpirationInstant; // volatile read - for (SecretSummary ss : secretSummaries) { - tasks.add(task(valueNodes::put, - ss.getSecretName(), - r -> secrets.getSecretBundle(r).getSecretBundle().getSecretBundleContent(), - ss.getId(), - decoder)); - java.util.Date d = ss.getTimeOfCurrentVersionExpiry(); - // If d is null, which is permitted by the OCI Vaults API, you could interpret it as meaning "this - // secret never ever expires, so never poll it for changes ever again". (This is sort of like if its - // expiration time were set to the end of time.) - // - // Or you could interpret it as the much more common "this secret never had its expiration time set, - // probably by mistake, or because it's a temporary scratch secret, or any of a zillion other possible - // common human explanations, so we'd better check each time we poll to see if the secret is still - // there; i.e. we should pretend it is continually expiring". (This is sort of like if its expiration - // time were set to the beginning of time.) - // - // We opt for the latter interpretation. - Instant secretExpirationInstant = d == null ? null : d.toInstant(); - if (secretExpirationInstant != null && secretExpirationInstant.isBefore(closestSecretExpirationInstant)) { - closestSecretExpirationInstant = secretExpirationInstant; - } - } - this.closestSecretExpirationInstant = closestSecretExpirationInstant; // volatile write - this.completeTasks(tasks, secrets); - ObjectNode.Builder onb = ObjectNode.builder(); - for (Entry e : valueNodes.entrySet()) { - onb.addValue(e.getKey(), e.getValue()); - } - return Optional.of(NodeContent.builder() - .node(onb.build()) - .build()); - } - - - /* - * Static methods. - */ - - - private static Builder builder() { - return new Builder(); - } - - private static void closeUnchecked(AutoCloseable autoCloseable) { - try { - autoCloseable.close(); - } catch (RuntimeException e) { - throw e; - } catch (InterruptedException e) { - // (Can legally be thrown by any AutoCloseable. Must preserve interrupt status.) - Thread.currentThread().interrupt(); - throw new IllegalStateException(e.getMessage(), e); - } catch (Exception e) { - // (Can legally be thrown by any AutoCloseable.) - throw new IllegalStateException(e.getMessage(), e); - } - } - - private static void completeTasks(ExecutorService es, Collection> tasks) { - RuntimeException re = null; - for (Future future : invokeAllUnchecked(es, tasks)) { - try { - futureGetUnchecked(future); - } catch (RuntimeException e) { - if (re == null) { - re = e; - } else { - re.addSuppressed(e); - } - } - } - if (re != null) { - throw re; - } - } - - private static T futureGetUnchecked(Future future) { - try { - return future.get(); - } catch (ExecutionException e) { - throw new IllegalStateException(e.getMessage(), e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(e.getMessage(), e); - } - } - - private static List> invokeAllUnchecked(ExecutorService es, Collection> tasks) { - try { - return es.invokeAll(tasks); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(e.getMessage(), e); - } - } - - static boolean isModified(Instant pollInstant, Instant closestSecretExpirationInstant) { - return closestSecretExpirationInstant.isBefore(pollInstant); - } - - private static GetSecretBundleRequest request(String secretId) { - return GetSecretBundleRequest.builder().secretId(secretId).build(); - } - - // Suppress "[try] auto-closeable resource Vaults has a member method close() that could throw - // InterruptedException" since we handle it. - @SuppressWarnings("try") - private static Collection secretSummaries(Supplier vaultsSupplier, - ListSecretsRequest listSecretsRequest) { - try (Vaults v = vaultsSupplier.get()) { - return v.listSecrets(listSecretsRequest).getItems(); - } catch (RuntimeException e) { - throw e; - } catch (InterruptedException e) { - // (Can legally be thrown by any AutoCloseable (such as Vaults). Must preserve interrupt status.) - Thread.currentThread().interrupt(); - throw new IllegalStateException(e.getMessage(), e); - } catch (Exception e) { - // (Can legally be thrown by any AutoCloseable (such as Vaults).) - throw new IllegalStateException(e.getMessage(), e); - } - } - - static Callable task(BiConsumer valueNodes, - String secretName, - Function f, - String secretId, - Base64.Decoder base64Decoder) { - return () -> { - valueNodes.accept(secretName, valueNode(f, secretId, base64Decoder)); - return null; - }; - } - - private static ValueNode valueNode(Function f, - String secretId, - Base64.Decoder base64Decoder) { - return valueNode((Base64SecretBundleContentDetails) f.apply(request(secretId)), base64Decoder); - } - - private static ValueNode valueNode(Base64SecretBundleContentDetails details, Base64.Decoder base64Decoder) { - return valueNode(details.getContent(), base64Decoder); - } - - static ValueNode valueNode(String base64EncodedContent, Base64.Decoder base64Decoder) { - String decodedContent = new String(base64Decoder.decode(base64EncodedContent), UTF_8); - return ValueNode.create(decodedContent.intern()); - } - - - /* - * Inner and nested classes. - */ - - - private static final class Builder extends AbstractConfigSourceBuilder { - - - /* - * Instance fields. - */ - - - private String compartmentOcid; - - private Supplier secretsSupplier; - - private String vaultOcid; - - private Supplier vaultsSupplier; - - - /* - * Constructors. - */ - - - private Builder() { - super(); - Supplier adpSupplier = - LazyValue.create(() -> (BasicAuthenticationDetailsProvider) ociAuthenticationProvider().get()); - SecretsClient.Builder scb = SecretsClient.builder(); - this.secretsSupplier = () -> scb.build(adpSupplier.get()); - VaultsClient.Builder vcb = VaultsClient.builder(); - this.vaultsSupplier = () -> vcb.build(adpSupplier.get()); - } - - - /* - * Instance methods. - */ - - - @Override // AbstractConfigSourceBuilder - protected Builder config(Config metaConfig) { - metaConfig.get("compartment-ocid") - .asString() - .filter(Predicate.not(String::isBlank)) - .ifPresentOrElse(this::compartmentOcid, - () -> { - if (LOGGER.isLoggable(WARNING)) { - LOGGER.log(WARNING, - "No meta-configuration value supplied for " - + metaConfig.key().toString() + "." + COMPARTMENT_OCID_PROPERTY_NAME - + "); resulting ConfigSource will be empty"); - } - }); - metaConfig.get("vault-ocid") - .asString() - .filter(Predicate.not(String::isBlank)) - .ifPresentOrElse(this::vaultOcid, - () -> { - if (LOGGER.isLoggable(WARNING)) { - LOGGER.log(WARNING, - "No meta-configuration value supplied for " - + metaConfig.key().toString() + "." + VAULT_OCID_PROPERTY_NAME - + "); resulting ConfigSource will be empty"); - } - }); - return super.config(metaConfig); - } - - private SecretBundleConfigSource build() { - return new SecretBundleConfigSource(this); - } - - private Builder compartmentOcid(String compartmentOcid) { - this.compartmentOcid = Objects.requireNonNull(compartmentOcid, "compartmentOcid"); - return this; - } - - private Builder secretsSupplier(Supplier secretsSupplier) { - this.secretsSupplier = Objects.requireNonNull(secretsSupplier, "secretsSupplier"); - return this; - } - - private Builder vaultOcid(String vaultOcid) { - this.vaultOcid = Objects.requireNonNull(vaultOcid, "vaultOcid"); - return this; - } - - private Builder vaultsSupplier(Supplier vaultsSupplier) { - this.vaultsSupplier = Objects.requireNonNull(vaultsSupplier, "vaultsSupplier"); - return this; - } - - } - - } - } diff --git a/integrations/oci/oci-secrets-config-source/src/main/java/io/helidon/integrations/oci/secrets/configsource/SecretBundleConfigSource.java b/integrations/oci/oci-secrets-config-source/src/main/java/io/helidon/integrations/oci/secrets/configsource/SecretBundleConfigSource.java new file mode 100644 index 00000000000..5cd8b89d3d9 --- /dev/null +++ b/integrations/oci/oci-secrets-config-source/src/main/java/io/helidon/integrations/oci/secrets/configsource/SecretBundleConfigSource.java @@ -0,0 +1,639 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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 io.helidon.integrations.oci.secrets.configsource; + +import java.lang.System.Logger; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.List; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import io.helidon.common.LazyValue; +import io.helidon.config.AbstractConfigSource; +import io.helidon.config.AbstractConfigSourceBuilder; +import io.helidon.config.Config; +import io.helidon.config.ConfigException; +import io.helidon.config.spi.ConfigContent.NodeContent; +import io.helidon.config.spi.ConfigNode.ObjectNode; +import io.helidon.config.spi.ConfigNode.ValueNode; +import io.helidon.config.spi.NodeConfigSource; +import io.helidon.config.spi.PollableSource; +import io.helidon.config.spi.PollingStrategy; + +import com.oracle.bmc.auth.BasicAuthenticationDetailsProvider; +import com.oracle.bmc.secrets.Secrets; +import com.oracle.bmc.secrets.SecretsClient; +import com.oracle.bmc.secrets.model.Base64SecretBundleContentDetails; +import com.oracle.bmc.secrets.requests.GetSecretBundleRequest; +import com.oracle.bmc.secrets.responses.GetSecretBundleResponse; +import com.oracle.bmc.vault.Vaults; +import com.oracle.bmc.vault.VaultsClient; +import com.oracle.bmc.vault.model.SecretSummary; +import com.oracle.bmc.vault.model.SecretSummary.LifecycleState; +import com.oracle.bmc.vault.requests.ListSecretsRequest; + +import static io.helidon.integrations.oci.sdk.runtime.OciExtension.ociAuthenticationProvider; +import static java.lang.System.Logger.Level.WARNING; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.time.Instant.now; +import static java.util.concurrent.Executors.newCachedThreadPool; + +/** + * An {@link AbstractConfigSource}, {@link NodeConfigSource} and {@link PollableSource} implementation that sources its + * values from the Oracle Cloud Infrastructure (OCI) Secrets + * Retrieval and Vault APIs. + */ +public final class SecretBundleConfigSource + extends AbstractConfigSource implements NodeConfigSource, PollableSource { + + + /* + * Static fields. + */ + + + private static final Optional ABSENT_NODE_CONTENT = + Optional.of(NodeContent.builder().node(ObjectNode.empty()).build()); + + private static final String COMPARTMENT_OCID_PROPERTY_NAME = "compartment-ocid"; + + private static final Logger LOGGER = System.getLogger(SecretBundleConfigSource.class.getName()); + + private static final String VAULT_OCID_PROPERTY_NAME = "vault-ocid"; + + + /* + * Instance fields. + */ + + + private final ExecutorService es; + + private final Supplier> loader; + + private final Supplier stamper; + + + /* + * Constructors. + */ + + + private SecretBundleConfigSource(Builder b) { + super(b); + // From Executors#newCachedThreadPool() javadoc: "Creates a thread pool that creates new threads as needed, + // but will reuse previously constructed threads when they are available. These pools will typically improve + // the performance of programs that execute many short-lived asynchronous tasks." That describes our use + // case exactly. + this.es = newCachedThreadPool(); + // Helidon Config has no defined lifecycle so the best we can do is forcibly close the ExecutorService on VM + // exit. + Runtime.getRuntime().addShutdownHook(new Thread(() -> this.es.shutdownNow())); + Supplier secretsSupplier = Objects.requireNonNull(b.secretsSupplier, "b.secretsSupplier"); + Supplier vaultsSupplier = Objects.requireNonNull(b.vaultsSupplier, "b.vaultsSupplier"); + String compartmentOcid = b.compartmentOcid; + String vaultOcid = b.vaultOcid; + if (compartmentOcid == null || vaultOcid == null) { + this.loader = this::absentNodeContent; + this.stamper = Stamp::new; + } else { + ListSecretsRequest listSecretsRequest = ListSecretsRequest.builder() + .compartmentId(compartmentOcid) + .lifecycleState(LifecycleState.Active) + .vaultId(vaultOcid) + .build(); + this.loader = () -> this.load(vaultsSupplier, secretsSupplier, listSecretsRequest); + this.stamper = () -> toStamp(secretSummaries(vaultsSupplier, listSecretsRequest), secretsSupplier); + } + } + + + /* + * Instance methods. + */ + + + /** + * Returns {@code true} if the values in this {@link SecretBundleConfigSource} have been modified. + * + * @param lastKnownStamp a {@link Stamp} + * + * @return {@code true} if modified + */ + @Deprecated // For use by the Helidon Config subsystem only. + @Override // PollableSource + public boolean isModified(Stamp lastKnownStamp) { + Stamp stamp = this.stamper.get(); + if (!stamp.eTags().equals(lastKnownStamp.eTags())) { + return true; + } + return stamp.earliestExpiration().isBefore(lastKnownStamp.earliestExpiration()); + } + + @Deprecated // For use by the Helidon Config subsystem only. + @Override // NodeConfigSource + public Optional load() { + return this.loader.get(); + } + + @Deprecated // For use by the Helidon Config subsystem only. + @Override // PollableSource + public Optional pollingStrategy() { + return super.pollingStrategy(); + } + + private Optional absentNodeContent() { + return ABSENT_NODE_CONTENT; + } + + private Optional load(Supplier vaultsSupplier, + Supplier secretsSupplier, + ListSecretsRequest listSecretsRequest) { + Collection secretSummaries = secretSummaries(vaultsSupplier, listSecretsRequest); + return this.load(secretSummaries, secretsSupplier); + } + + private Optional load(Collection secretSummaries, + Supplier secretsSupplier) { + if (secretSummaries.isEmpty()) { + return this.absentNodeContent(); + } + ConcurrentMap valueNodes = new ConcurrentHashMap<>(); + Set eTags = ConcurrentHashMap.newKeySet(); + Collection> tasks = new ArrayList<>(secretSummaries.size()); + Base64.Decoder decoder = Base64.getDecoder(); + Secrets secrets = secretsSupplier.get(); + for (SecretSummary ss : secretSummaries) { + tasks.add(task(valueNodes::put, + eTags::add, + ss.getSecretName(), + secrets::getSecretBundle, + ss.getId(), + decoder)); + } + completeTasks(tasks, secrets); + ObjectNode.Builder objectNodeBuilder = ObjectNode.builder(); + for (Entry e : valueNodes.entrySet()) { + objectNodeBuilder.addValue(e.getKey(), e.getValue()); + } + return Optional.of(NodeContent.builder() + .node(objectNodeBuilder.build()) + .stamp(toStamp(secretSummaries, eTags)) + .build()); + } + + private void completeTasks(Collection> tasks, AutoCloseable autoCloseable) { + try (autoCloseable) { + completeTasks(this.es, tasks); + } catch (RuntimeException e) { + throw e; + } catch (InterruptedException e) { + // (Can legally be thrown by any AutoCloseable. Must preserve interrupt status.) + Thread.currentThread().interrupt(); + throw new IllegalStateException(e.getMessage(), e); + } catch (Exception e) { + // (Can legally be thrown by any AutoCloseable.) + throw new IllegalStateException(e.getMessage(), e); + } + } + + private Stamp toStamp(Collection secretSummaries, + Supplier secretsSupplier) { + if (secretSummaries.isEmpty()) { + return new Stamp(); + } + Set eTags = ConcurrentHashMap.newKeySet(); + Collection> tasks = new ArrayList<>(secretSummaries.size()); + Secrets secrets = secretsSupplier.get(); + for (SecretSummary ss : secretSummaries) { + if (ss.getLifecycleState() == LifecycleState.Active) { + tasks.add(() -> { + GetSecretBundleResponse response = secrets.getSecretBundle(request(ss.getId())); + eTags.add(response.getEtag()); + return null; // Callable; null is the only possible return value + }); + } + } + completeTasks(tasks, secrets); + return toStamp(secretSummaries, eTags); + } + + + /* + * Static methods. + */ + + + static Stamp toStamp(Collection secretSummaries, Set eTags) { + if (secretSummaries.isEmpty()) { + return new Stamp(); + } + Instant earliestExpiration = null; + for (SecretSummary ss : secretSummaries) { + if (ss.getLifecycleState() == LifecycleState.Active) { + java.util.Date d = ss.getTimeOfCurrentVersionExpiry(); + if (d == null) { + d = ss.getTimeOfDeletion(); + } + // If d is null, which is permitted by the OCI Vaults API, you could interpret it as meaning "this + // secret never ever expires, so never poll it for changes ever again". (This is sort of like if its + // expiration time were set to the end of time.) + // + // Or you could interpret it as the much more common "this secret never had its expiration time set, + // probably by mistake, or because it's a temporary scratch secret, or any of a zillion other possible + // common human explanations, so we'd better check each time we poll to see if the secret is still + // there; i.e. we should pretend it is continually expiring". (This is sort of like if its expiration + // time were set to the beginning of time.) + // + // We opt for the latter interpretation. + if (d != null) { + Instant i = d.toInstant(); + if (earliestExpiration == null || i.isBefore(earliestExpiration)) { + earliestExpiration = i; + } + } + } + } + return new Stamp(Set.copyOf(eTags), earliestExpiration == null ? now() : earliestExpiration); + } + + /** + * Creates and returns a new {@link Builder} for {@linkplain Builder#build() building} {@link + * SecretBundleConfigSource} instances. + * + * @return a new {@link Builder} + */ + public static Builder builder() { + return new Builder(); + } + + private static void closeUnchecked(AutoCloseable autoCloseable) { + try { + autoCloseable.close(); + } catch (RuntimeException e) { + throw e; + } catch (InterruptedException e) { + // (Can legally be thrown by any AutoCloseable. Must preserve interrupt status.) + Thread.currentThread().interrupt(); + throw new IllegalStateException(e.getMessage(), e); + } catch (Exception e) { + // (Can legally be thrown by any AutoCloseable.) + throw new IllegalStateException(e.getMessage(), e); + } + } + + private static void completeTasks(ExecutorService es, Collection> tasks) { + RuntimeException re = null; + for (Future future : invokeAllUnchecked(es, tasks)) { + try { + futureGetUnchecked(future); + } catch (RuntimeException e) { + if (re == null) { + re = e; + } else { + re.addSuppressed(e); + } + } + } + if (re != null) { + throw re; + } + } + + private static T futureGetUnchecked(Future future) { + try { + return future.get(); + } catch (ExecutionException e) { + throw new IllegalStateException(e.getMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e.getMessage(), e); + } + } + + private static List> invokeAllUnchecked(ExecutorService es, Collection> tasks) { + try { + return es.invokeAll(tasks); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e.getMessage(), e); + } + } + + static boolean isModified(Stamp pollStamp, Stamp stamp) { + return + !pollStamp.eTags().equals(stamp.eTags()) + || stamp.earliestExpiration().isBefore(pollStamp.earliestExpiration()); + } + + private static GetSecretBundleRequest request(String secretId) { + return GetSecretBundleRequest.builder().secretId(secretId).build(); + } + + // Suppress "[try] auto-closeable resource Vaults has a member method close() that could throw + // InterruptedException" since we handle it. + @SuppressWarnings("try") + private static Collection secretSummaries(Supplier vaultsSupplier, + ListSecretsRequest listSecretsRequest) { + try (Vaults v = vaultsSupplier.get()) { + return v.listSecrets(listSecretsRequest).getItems(); + } catch (RuntimeException e) { + throw e; + } catch (InterruptedException e) { + // (Can legally be thrown by any AutoCloseable (such as Vaults). Must preserve interrupt status.) + Thread.currentThread().interrupt(); + throw new IllegalStateException(e.getMessage(), e); + } catch (Exception e) { + // (Can legally be thrown by any AutoCloseable (such as Vaults).) + throw new IllegalStateException(e.getMessage(), e); + } + } + + static Callable task(BiConsumer valueNodes, + Consumer eTags, + String secretName, + Function f, + String secretId, + Base64.Decoder base64Decoder) { + return () -> { + valueNodes.accept(secretName, + valueNode(request -> secretBundleContentDetails(request, f, eTags), + secretId, + base64Decoder)); + return null; // Callable; null is the only possible return value + }; + } + + private static Base64SecretBundleContentDetails + secretBundleContentDetails(GetSecretBundleRequest request, + Function f, + Consumer eTags) { + GetSecretBundleResponse response = f.apply(request); + eTags.accept(response.getEtag()); + return (Base64SecretBundleContentDetails) response.getSecretBundle().getSecretBundleContent(); + } + + private static ValueNode valueNode(Function f, + String secretId, + Base64.Decoder base64Decoder) { + return valueNode(f.apply(request(secretId)), base64Decoder); + } + + private static ValueNode valueNode(Base64SecretBundleContentDetails details, Base64.Decoder base64Decoder) { + return valueNode(details.getContent(), base64Decoder); + } + + static ValueNode valueNode(String base64EncodedContent, Base64.Decoder base64Decoder) { + String decodedContent = new String(base64Decoder.decode(base64EncodedContent), UTF_8); + return ValueNode.create(decodedContent.intern()); + } + + + /* + * Inner and nested classes. + */ + + + /** + * An {@link AbstractConfigSourceBuilder} that {@linkplain #build() builds} {@link SecretBundleConfigSource} + * instances. + */ + public static final class Builder extends AbstractConfigSourceBuilder { + + + /* + * Instance fields. + */ + + + private String compartmentOcid; + + private Supplier secretsSupplier; + + private String vaultOcid; + + private Supplier vaultsSupplier; + + + /* + * Constructors. + */ + + + private Builder() { + super(); + Supplier adpSupplier = + LazyValue.create(() -> (BasicAuthenticationDetailsProvider) ociAuthenticationProvider().get()); + SecretsClient.Builder scb = SecretsClient.builder(); + this.secretsSupplier = () -> scb.build(adpSupplier.get()); + VaultsClient.Builder vcb = VaultsClient.builder(); + this.vaultsSupplier = () -> vcb.build(adpSupplier.get()); + } + + + /* + * Instance methods. + */ + + + /** + * Creates and returns a new {@link SecretBundleConfigSource} instance initialized from the state of this {@link + * Builder}. + * + * @return a new {@link SecretBundleConfigSource} + */ + public SecretBundleConfigSource build() { + return new SecretBundleConfigSource(this); + } + + /** + * Sets the (required) OCID of the OCI compartment housing the vault from which a {@link + * SecretBundleConfigSource} will retrieve values. + * + * @param compartmentOcid a valid OCID identifying an OCI compartment; must not be {@code null} + * + * @return this {@link Builder} + * + * @exception NullPointerException if {@code compartmentId} is {@code null} + */ + public Builder compartmentOcid(String compartmentOcid) { + this.compartmentOcid = Objects.requireNonNull(compartmentOcid, "compartmentOcid"); + return this; + } + + /** + * Configures this {@link Builder} from the supplied meta-configuration. + * + * @param metaConfig the meta-configuration; must not be {@code null} + * + * @return this {@link Builder} + * + * @exception NullPointerException if {@code metaConfig} is {@code null} + */ + @Override // AbstractConfigSourceBuilder + public Builder config(Config metaConfig) { + metaConfig.get("change-watcher") + .asNode() + .ifPresent(n -> { + throw new ConfigException("Invalid meta-configuration key: change-watcher: " + + "Change watching is not supported by " + + this.getClass().getName() + " instances"); + }); + metaConfig.get("compartment-ocid") + .asString() + .filter(Predicate.not(String::isBlank)) + .ifPresentOrElse(this::compartmentOcid, + () -> { + if (LOGGER.isLoggable(WARNING)) { + LOGGER.log(WARNING, + "No meta-configuration value supplied for " + + metaConfig.key().toString() + "." + COMPARTMENT_OCID_PROPERTY_NAME + + "); resulting ConfigSource will be empty"); + } + }); + metaConfig.get("vault-ocid") + .asString() + .filter(Predicate.not(String::isBlank)) + .ifPresentOrElse(this::vaultOcid, + () -> { + if (LOGGER.isLoggable(WARNING)) { + LOGGER.log(WARNING, + "No meta-configuration value supplied for " + + metaConfig.key().toString() + "." + VAULT_OCID_PROPERTY_NAME + + "); resulting ConfigSource will be empty"); + } + }); + return super.config(metaConfig); + } + + /** + * Sets the {@link PollingStrategy} for use by this {@link Builder}. + * + *

If this method is never called, no {@link PollingStrategy} will be used by this {@link Builder}.

+ * + *

The implementation of this method calls {@link + * io.helidon.config.AbstractSourceBuilder#pollingStrategy(PollingStrategy) + * super.pollingStrategy(pollingStrategy)} and returns the result.

+ * + * @param pollingStrategy a {@link PollingStrategy}; must not be {@code null} + * + * @return this {@link Builder} + * + * @exception NullPointerException if {@code pollingStrategy} is {@code null} + * + * @see PollableSource + * + * @see PollingStrategy + */ + public Builder pollingStrategy(PollingStrategy pollingStrategy) { + return super.pollingStrategy(pollingStrategy); + } + + /** + * Uses the supplied {@link Supplier} of {@link Secrets} instances, instead of the default one, for + * communicating with the OCI Secrets Retrieval API. + * + * @param secretsSupplier the non-default {@link Supplier} to use; must not be {@code null} + * + * @return this {@link Builder} + * + * @exception NullPointerException if {@code secretsSupplier} is {@code null} + */ + public Builder secretsSupplier(Supplier secretsSupplier) { + this.secretsSupplier = Objects.requireNonNull(secretsSupplier, "secretsSupplier"); + return this; + } + + /** + * Sets the (required) OCID of the OCI vault from which a {@link SecretBundleConfigSource} will retrieve values. + * + * @param vaultOcid a valid OCID identifying an OCI vault; must not be {@code null} + * + * @return this {@link Builder} + * + * @exception NullPointerException if {@code vaultId} is {@code null} + */ + public Builder vaultOcid(String vaultOcid) { + this.vaultOcid = Objects.requireNonNull(vaultOcid, "vaultOcid"); + return this; + } + + /** + * Uses the supplied {@link Supplier} of {@link Vaults} instances, instead of the default one, for + * communicating with the OCI Vaults API. + * + * @param vaultsSupplier the non-default {@link Supplier} to use; must not be {@code null} + * + * @return this {@link Builder} + * + * @exception NullPointerException if {@code vaultsSupplier} is {@code null} + */ + public Builder vaultsSupplier(Supplier vaultsSupplier) { + this.vaultsSupplier = Objects.requireNonNull(vaultsSupplier, "vaultsSupplier"); + return this; + } + + } + + /** + * A pairing of a {@link Set} of entity tags with an {@link Instant} identifying the earliest expiration + * of a Secret indirectly identified by one of those tags. + * + * @param eTags a {@link Set} of entity tags + * + * @param earliestExpiration an {@link Instant} identifying the earliest expiration of a Secret indirectly + * identified by one of the supplied tags + */ + public static record Stamp(Set eTags, Instant earliestExpiration) { + + /** + * Creates a new {@link Stamp}. + */ + public Stamp() { + this(Set.of(), now()); + } + + /** + * Creates a new {@link Stamp}. + * + * @exception NullPointerException if any argument is {@code null} + */ + public Stamp { + eTags = Set.copyOf(Objects.requireNonNull(eTags, "eTags")); + Objects.requireNonNull(earliestExpiration); + } + + } + +} diff --git a/integrations/oci/oci-secrets-config-source/src/main/java/module-info.java b/integrations/oci/oci-secrets-config-source/src/main/java/module-info.java index cbacd5343b3..ef604eda5dd 100644 --- a/integrations/oci/oci-secrets-config-source/src/main/java/module-info.java +++ b/integrations/oci/oci-secrets-config-source/src/main/java/module-info.java @@ -32,8 +32,8 @@ requires transitive io.helidon.config; requires io.helidon.integrations.oci.sdk.runtime; requires oci.java.sdk.common; - requires oci.java.sdk.secrets; - requires oci.java.sdk.vault; + requires transitive oci.java.sdk.secrets; + requires transitive oci.java.sdk.vault; provides io.helidon.config.spi.ConfigSourceProvider with io.helidon.integrations.oci.secrets.configsource.OciSecretsConfigSourceProvider; diff --git a/integrations/oci/oci-secrets-config-source/src/test/java/io/helidon/integrations/oci/secrets/configsource/IsModifiedTest.java b/integrations/oci/oci-secrets-config-source/src/test/java/io/helidon/integrations/oci/secrets/configsource/IsModifiedTest.java index 00379b53a6d..ad97d01ad6b 100644 --- a/integrations/oci/oci-secrets-config-source/src/test/java/io/helidon/integrations/oci/secrets/configsource/IsModifiedTest.java +++ b/integrations/oci/oci-secrets-config-source/src/test/java/io/helidon/integrations/oci/secrets/configsource/IsModifiedTest.java @@ -16,10 +16,13 @@ package io.helidon.integrations.oci.secrets.configsource; import java.time.Instant; +import java.util.Set; + +import io.helidon.integrations.oci.secrets.configsource.SecretBundleConfigSource.Stamp; import org.junit.jupiter.api.Test; -import static io.helidon.integrations.oci.secrets.configsource.OciSecretsConfigSourceProvider.SecretBundleConfigSource.isModified; +import static io.helidon.integrations.oci.secrets.configsource.SecretBundleConfigSource.isModified; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -36,10 +39,15 @@ void testIsModified() { Instant earlier = now0.minusSeconds(500); // arbitrary amount assertThat(earlier.isBefore(now0), is(true)); + Stamp s0 = new Stamp(Set.of(), now0); + Stamp s1 = new Stamp(Set.of(), now1); + Stamp laterStamp = new Stamp(Set.of(), later); + Stamp earlierStamp = new Stamp(Set.of(), earlier); + // Test that isModified properly encapsulates java.time behavior. - assertThat(isModified(now0, later), is(false)); - assertThat(isModified(now0, now1), is(false)); - assertThat(isModified(now0, earlier), is(true)); + assertThat(isModified(s0, laterStamp), is(false)); + assertThat(isModified(s0, s1), is(false)); + assertThat(isModified(s0, earlierStamp), is(true)); } } diff --git a/integrations/oci/oci-secrets-config-source/src/test/java/io/helidon/integrations/oci/secrets/configsource/ValueNodeTest.java b/integrations/oci/oci-secrets-config-source/src/test/java/io/helidon/integrations/oci/secrets/configsource/ValueNodeTest.java index 67d147bf678..c7dc272f5b2 100644 --- a/integrations/oci/oci-secrets-config-source/src/test/java/io/helidon/integrations/oci/secrets/configsource/ValueNodeTest.java +++ b/integrations/oci/oci-secrets-config-source/src/test/java/io/helidon/integrations/oci/secrets/configsource/ValueNodeTest.java @@ -19,7 +19,7 @@ import org.junit.jupiter.api.Test; -import static io.helidon.integrations.oci.secrets.configsource.OciSecretsConfigSourceProvider.SecretBundleConfigSource.valueNode; +import static io.helidon.integrations.oci.secrets.configsource.SecretBundleConfigSource.valueNode; import static java.nio.charset.StandardCharsets.UTF_8; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; From a6275b53fba52d1f6a762994af03098afccd6f4d Mon Sep 17 00:00:00 2001 From: Laird Nelson Date: Fri, 25 Aug 2023 13:18:55 -0700 Subject: [PATCH 2/2] Squashable commit; eliminates spurious lambda Signed-off-by: Laird Nelson --- .../oci/secrets/configsource/SecretBundleConfigSource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/oci/oci-secrets-config-source/src/main/java/io/helidon/integrations/oci/secrets/configsource/SecretBundleConfigSource.java b/integrations/oci/oci-secrets-config-source/src/main/java/io/helidon/integrations/oci/secrets/configsource/SecretBundleConfigSource.java index 5cd8b89d3d9..215d2bb89f1 100644 --- a/integrations/oci/oci-secrets-config-source/src/main/java/io/helidon/integrations/oci/secrets/configsource/SecretBundleConfigSource.java +++ b/integrations/oci/oci-secrets-config-source/src/main/java/io/helidon/integrations/oci/secrets/configsource/SecretBundleConfigSource.java @@ -119,7 +119,7 @@ private SecretBundleConfigSource(Builder b) { this.es = newCachedThreadPool(); // Helidon Config has no defined lifecycle so the best we can do is forcibly close the ExecutorService on VM // exit. - Runtime.getRuntime().addShutdownHook(new Thread(() -> this.es.shutdownNow())); + Runtime.getRuntime().addShutdownHook(new Thread(this.es::shutdownNow)); Supplier secretsSupplier = Objects.requireNonNull(b.secretsSupplier, "b.secretsSupplier"); Supplier vaultsSupplier = Objects.requireNonNull(b.vaultsSupplier, "b.vaultsSupplier"); String compartmentOcid = b.compartmentOcid;