io.helidon.integrations.oci.secrets.configsource.SecretBundleNodeConfigSource Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of helidon-integrations-oci-secrets-config-source Show documentation
Show all versions of helidon-integrations-oci-secrets-config-source Show documentation
OCI Secrets Retrieval API ConfigSourceProvider Implementation
/*
* Copyright (c) 2023, 2024 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.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.NodeConfigSource;
import io.helidon.config.spi.PollableSource;
import io.helidon.config.spi.PollingStrategy;
import com.oracle.bmc.secrets.Secrets;
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 java.lang.System.Logger.Level.WARNING;
import static java.time.Instant.now;
import static java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor;
/**
* 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 SecretBundleNodeConfigSource
extends AbstractSecretBundleConfigSource
implements NodeConfigSource, PollableSource {
private static final String COMPARTMENT_OCID_PROPERTY_NAME = "compartment-ocid";
private static final Logger LOGGER = System.getLogger(SecretBundleNodeConfigSource.class.getName());
private final Supplier> loader;
private final Supplier stamper;
private SecretBundleNodeConfigSource(Builder b) {
super(b);
Supplier extends Secrets> secretsSupplier = Objects.requireNonNull(b.secretsSupplier(), "b.secretsSupplier()");
Supplier extends Vaults> vaultsSupplier = Objects.requireNonNull(b.vaultsSupplier, "b.vaultsSupplier");
String vaultOcid = b.vaultOcid();
if (b.compartmentOcid == null || vaultOcid == null) {
this.loader = Optional::empty;
this.stamper = Stamp::new;
} else {
ListSecretsRequest listSecretsRequest = ListSecretsRequest.builder()
.compartmentId(b.compartmentOcid)
.lifecycleState(LifecycleState.Active)
.vaultId(vaultOcid)
.build();
this.loader = () -> this.load(vaultsSupplier, secretsSupplier, listSecretsRequest);
this.stamper = () -> toStamp(secretSummaries(vaultsSupplier, listSecretsRequest), secretsSupplier);
}
}
/*
* Instance methods.
*/
/**
* Creates and returns a new {@link Builder} for {@linkplain Builder#build() building} {@link
* SecretBundleNodeConfigSource} instances.
*
* @return a new {@link Builder}
*/
public static Builder builder() {
return new Builder();
}
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);
}
static boolean isModified(Stamp pollStamp, Stamp stamp) {
return
!pollStamp.eTags().equals(stamp.eTags())
|| stamp.earliestExpiration().isBefore(pollStamp.earliestExpiration());
}
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
};
}
/**
* Returns {@code true} if the values in this {@link SecretBundleNodeConfigSource} 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 static Stamp toStamp(Collection secretSummaries,
Supplier extends Secrets> 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);
}
private static void completeTasks(Collection> tasks, AutoCloseable autoCloseable) {
try (ExecutorService es = newVirtualThreadPerTaskExecutor();
autoCloseable) {
completeTasks(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 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);
}
}
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 extends Vaults> 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);
}
}
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);
}
private Optional load(Supplier extends Vaults> vaultsSupplier,
Supplier extends Secrets> secretsSupplier,
ListSecretsRequest listSecretsRequest) {
Collection secretSummaries = secretSummaries(vaultsSupplier, listSecretsRequest);
return this.load(secretSummaries, secretsSupplier);
}
private Optional load(Collection secretSummaries,
Supplier extends Secrets> secretsSupplier) {
if (secretSummaries.isEmpty()) {
return Optional.empty();
}
ConcurrentMap valueNodes = new ConcurrentHashMap<>();
Set eTags = ConcurrentHashMap.newKeySet();
Collection> tasks = new ArrayList<>();
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());
}
/**
* An {@link AbstractConfigSourceBuilder} that {@linkplain #build() builds} {@link SecretBundleNodeConfigSource}
* instances.
*/
public static final class Builder extends AbstractSecretBundleConfigSource.Builder {
private String compartmentOcid;
private Supplier extends Vaults> vaultsSupplier;
private Builder() {
super();
VaultsClient.Builder vcb = VaultsClient.builder();
this.vaultsSupplier = () -> vcb.build(adpSupplier().get());
}
/**
* Creates and returns a new {@link SecretBundleNodeConfigSource} instance initialized from the state of this
* {@link Builder}.
*
* @return a new {@link SecretBundleNodeConfigSource}
*/
public SecretBundleNodeConfigSource build() {
return new SecretBundleNodeConfigSource(this);
}
/**
* Sets the (required) OCID of the OCI compartment housing the vault from which a {@link
* SecretBundleNodeConfigSource} will retrieve values.
*
* @param compartmentOcid a valid OCID identifying an OCI compartment; must not be {@code null}
* @return this {@link Builder}
* @throws 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}
* @throws NullPointerException if {@code metaConfig} is {@code null}
*/
@Override // AbstractConfigSourceBuilder
public 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");
}
});
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}
* @throws 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 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}
* @throws NullPointerException if {@code vaultsSupplier} is {@code null}
*/
public Builder vaultsSupplier(Supplier extends Vaults> 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 record Stamp(Set> eTags, Instant earliestExpiration) {
/**
* Creates a new {@link Stamp}.
*/
public Stamp() {
this(Set.of(), now());
}
/**
* Creates a new {@link Stamp}.
*
* @throws NullPointerException if any argument is {@code null}
*/
public Stamp {
eTags = Set.copyOf(Objects.requireNonNull(eTags, "eTags"));
Objects.requireNonNull(earliestExpiration);
}
}
}