All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.helidon.integrations.oci.secrets.configsource.SecretBundleNodeConfigSource Maven / Gradle / Ivy

The newest version!
/*
 * 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 secretsSupplier = Objects.requireNonNull(b.secretsSupplier(), "b.secretsSupplier()");
        Supplier 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 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 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 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 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 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 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); } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy