From 111bb271c5d1fcc3f2b3fd0b4e2217bddd7552ee Mon Sep 17 00:00:00 2001 From: nscuro Date: Sat, 27 Jul 2024 01:39:13 +0200 Subject: [PATCH] Introduce plugin system to deal with provider config and lifecycle Signed-off-by: nscuro --- pom.xml | 6 + .../kafka/processor/BomUploadProcessor.java | 21 +- .../model/ConfigPropertyConstants.java | 6 - .../persistence/jdbi/BomDao.java | 4 + .../plugin/ConfigRegistry.java | 106 +++++++ .../org/dependencytrack/plugin/Plugin.java | 46 +++ .../plugin/PluginInitializer.java | 47 +++ .../dependencytrack/plugin/PluginManager.java | 286 ++++++++++++++++++ .../org/dependencytrack/plugin/Provider.java | 34 +++ .../plugin/ProviderFactory.java | 60 ++++ .../v1/AbstractConfigPropertyResource.java | 20 -- .../resources/v1/BomResource.java | 32 +- .../resources/v1/PluginResource.java | 79 +++++ .../v1/vo/LoadedPluginListResponseItem.java | 35 +++ .../storage/BomUploadStoragePlugin.java | 52 ++++ .../storage/BomUploadStorageProvider.java | 46 +-- .../BomUploadStorageProviderFactory.java | 27 ++ .../DatabaseBomUploadStorageProvider.java | 6 +- ...tabaseBomUploadStorageProviderFactory.java | 48 +++ .../LocalBomUploadStorageProvider.java | 41 +-- .../LocalBomUploadStorageProviderFactory.java | 79 +++++ .../storage/S3BomUploadStorageProvider.java | 106 +++++++ .../S3BomUploadStorageProviderFactory.java | 99 ++++++ .../org.dependencytrack.plugin.Plugin | 1 + ...encytrack.storage.BomUploadStorageProvider | 2 - ...ck.storage.BomUploadStorageProviderFactory | 3 + src/main/resources/application.properties | 93 ++++++ src/main/webapp/WEB-INF/web.xml | 3 + .../PersistenceCapableTest.java | 7 + .../org/dependencytrack/ResourceTest.java | 7 + .../plugin/ConfigRegistryTest.java | 73 +++++ .../plugin/PluginManagerTest.java | 78 +++++ .../plugin/PluginManagerTestUtil.java | 31 ++ .../v1/ConfigPropertyResourceTest.java | 91 ------ .../resources/v1/PluginResourceTest.java | 61 ++++ .../DatabaseBomUploadStorageProviderTest.java | 77 +++-- .../LocalBomUploadStorageProviderTest.java | 82 +++-- .../S3BomUploadStorageProviderTest.java | 231 ++++++++++++++ 38 files changed, 1866 insertions(+), 260 deletions(-) create mode 100644 src/main/java/org/dependencytrack/plugin/ConfigRegistry.java create mode 100644 src/main/java/org/dependencytrack/plugin/Plugin.java create mode 100644 src/main/java/org/dependencytrack/plugin/PluginInitializer.java create mode 100644 src/main/java/org/dependencytrack/plugin/PluginManager.java create mode 100644 src/main/java/org/dependencytrack/plugin/Provider.java create mode 100644 src/main/java/org/dependencytrack/plugin/ProviderFactory.java create mode 100644 src/main/java/org/dependencytrack/resources/v1/PluginResource.java create mode 100644 src/main/java/org/dependencytrack/resources/v1/vo/LoadedPluginListResponseItem.java create mode 100644 src/main/java/org/dependencytrack/storage/BomUploadStoragePlugin.java create mode 100644 src/main/java/org/dependencytrack/storage/BomUploadStorageProviderFactory.java create mode 100644 src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderFactory.java create mode 100644 src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProviderFactory.java create mode 100644 src/main/java/org/dependencytrack/storage/S3BomUploadStorageProvider.java create mode 100644 src/main/java/org/dependencytrack/storage/S3BomUploadStorageProviderFactory.java create mode 100644 src/main/resources/META-INF/services/org.dependencytrack.plugin.Plugin delete mode 100644 src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProvider create mode 100644 src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProviderFactory create mode 100644 src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java create mode 100644 src/test/java/org/dependencytrack/plugin/PluginManagerTest.java create mode 100644 src/test/java/org/dependencytrack/plugin/PluginManagerTestUtil.java create mode 100644 src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java create mode 100644 src/test/java/org/dependencytrack/storage/S3BomUploadStorageProviderTest.java diff --git a/pom.xml b/pom.xml index a7d383e8c..e3d432a35 100644 --- a/pom.xml +++ b/pom.xml @@ -510,6 +510,12 @@ ${lib.json-unit.version} test + + org.testcontainers + minio + ${lib.testcontainers.version} + test + org.testcontainers redpanda diff --git a/src/main/java/org/dependencytrack/event/kafka/processor/BomUploadProcessor.java b/src/main/java/org/dependencytrack/event/kafka/processor/BomUploadProcessor.java index 2fede4774..1b043590e 100644 --- a/src/main/java/org/dependencytrack/event/kafka/processor/BomUploadProcessor.java +++ b/src/main/java/org/dependencytrack/event/kafka/processor/BomUploadProcessor.java @@ -23,7 +23,6 @@ import alpine.event.framework.ChainableEvent; import alpine.event.framework.Event; import alpine.event.framework.EventService; -import alpine.model.ConfigProperty; import alpine.notification.Notification; import alpine.notification.NotificationLevel; import org.apache.commons.collections4.MultiValuedMap; @@ -64,6 +63,7 @@ import org.dependencytrack.notification.vo.BomConsumedOrProcessed; import org.dependencytrack.notification.vo.BomProcessingFailed; import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.plugin.PluginManager; import org.dependencytrack.proto.event.v1alpha1.BomUploadedEvent; import org.dependencytrack.storage.BomUploadStorageProvider; import org.dependencytrack.util.InternalComponentIdentifier; @@ -107,7 +107,6 @@ import static org.dependencytrack.common.MdcKeys.MDC_PROJECT_VERSION; import static org.dependencytrack.event.kafka.componentmeta.RepoMetaConstants.SUPPORTED_PACKAGE_URLS_FOR_INTEGRITY_CHECK; import static org.dependencytrack.event.kafka.componentmeta.RepoMetaConstants.TIME_SPAN; -import static org.dependencytrack.model.ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER; import static org.dependencytrack.parser.cyclonedx.util.ModelConverter.convertComponents; import static org.dependencytrack.parser.cyclonedx.util.ModelConverter.convertDependencyGraph; import static org.dependencytrack.parser.cyclonedx.util.ModelConverter.convertServices; @@ -157,6 +156,7 @@ private Context(final UUID token, final BomUploadedEvent.Project project) { public BomUploadProcessor() { this(new KafkaEventDispatcher(), Config.getInstance().getPropertyAsBoolean(ConfigKey.TMP_DELAY_BOM_PROCESSED_NOTIFICATION)); } + BomUploadProcessor(final KafkaEventDispatcher kafkaEventDispatcher, final boolean delayBomProcessedNotification) { this.kafkaEventDispatcher = kafkaEventDispatcher; this.delayBomProcessedNotification = delayBomProcessedNotification; @@ -166,25 +166,16 @@ public BomUploadProcessor() { public void process(final ConsumerRecord record) throws ProcessingException { final BomUploadedEvent event = record.value(); - final BomUploadStorageProvider storageProvider; - try (final var qm = new QueryManager()) { - final ConfigProperty storageProviderProperty = qm.getConfigProperty( - BOM_UPLOAD_STORAGE_PROVIDER.getGroupName(), - BOM_UPLOAD_STORAGE_PROVIDER.getPropertyName() - ); - final String storageProviderClassName = storageProviderProperty != null - ? storageProviderProperty.getPropertyValue() - : BOM_UPLOAD_STORAGE_PROVIDER.getDefaultPropertyValue(); - storageProvider = BomUploadStorageProvider.getForClassName(storageProviderClassName); - } + final var storageProviderFactory = PluginManager.getInstance().getFactory(BomUploadStorageProvider.class); final var ctx = new Context(UUID.fromString(event.getToken()), event.getProject()); try (var ignoredMdcProjectUuid = MDC.putCloseable(MDC_PROJECT_UUID, ctx.project.getUuid().toString()); var ignoredMdcProjectName = MDC.putCloseable(MDC_PROJECT_NAME, ctx.project.getName()); var ignoredMdcProjectVersion = MDC.putCloseable(MDC_PROJECT_VERSION, ctx.project.getVersion()); - var ignoredMdcBomUploadToken = MDC.putCloseable(MDC_BOM_UPLOAD_TOKEN, ctx.token.toString())) { + var ignoredMdcBomUploadToken = MDC.putCloseable(MDC_BOM_UPLOAD_TOKEN, ctx.token.toString()); + final BomUploadStorageProvider storageProvider = storageProviderFactory.create()) { processEvent(ctx, storageProvider); - } finally { + try { storageProvider.deleteBomByToken(ctx.token); } catch (IOException | RuntimeException e) { diff --git a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java index 2934f2428..29b9d9864 100644 --- a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java +++ b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java @@ -20,13 +20,10 @@ import alpine.model.IConfigProperty; import alpine.model.IConfigProperty.PropertyType; -import com.github.luben.zstd.Zstd; import org.apache.commons.lang3.SystemUtils; -import org.dependencytrack.storage.DatabaseBomUploadStorageProvider; import java.util.Arrays; import java.util.UUID; -import java.util.concurrent.TimeUnit; public enum ConfigPropertyConstants { @@ -71,9 +68,6 @@ public enum ConfigPropertyConstants { VULNERABILITY_SOURCE_EPSS_FEEDS_URL("vuln-source", "epss.feeds.url", "https://epss.cyentia.com", PropertyType.URL, "A base URL pointing to the hostname and path of the EPSS feeds", ConfigPropertyAccessMode.READ_WRITE), ACCEPT_ARTIFACT_CYCLONEDX("artifact", "cyclonedx.enabled", "true", PropertyType.BOOLEAN, "Flag to enable/disable the systems ability to accept CycloneDX uploads", ConfigPropertyAccessMode.READ_WRITE), BOM_VALIDATION_ENABLED("artifact", "bom.validation.enabled", "true", PropertyType.BOOLEAN, "Flag to control bom validation", ConfigPropertyAccessMode.READ_WRITE), - BOM_UPLOAD_STORAGE_PROVIDER("artifact", "bom.upload.storage.provider", DatabaseBomUploadStorageProvider.class.getName(), PropertyType.STRING, "Class of the BOM upload storage provider", ConfigPropertyAccessMode.READ_WRITE), - BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL("artifact", "bom.upload.storage.compression.level", String.valueOf(Zstd.defaultCompressionLevel()), PropertyType.INTEGER, "Compression level to use for storage of uploaded BOMs", ConfigPropertyAccessMode.READ_WRITE), - BOM_UPLOAD_STORAGE_RETENTION_MS("artifact", "bom.upload.storage.retention.ms", String.valueOf(TimeUnit.HOURS.toMillis(1)), PropertyType.INTEGER, "Maximum storage retention duration for uploaded BOMs in milliseconds", ConfigPropertyAccessMode.READ_WRITE), FORTIFY_SSC_ENABLED("integrations", "fortify.ssc.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable Fortify SSC integration", ConfigPropertyAccessMode.READ_WRITE), FORTIFY_SSC_SYNC_CADENCE("integrations", "fortify.ssc.sync.cadence", "60", PropertyType.INTEGER, "The cadence (in minutes) to upload to Fortify SSC", ConfigPropertyAccessMode.READ_WRITE), FORTIFY_SSC_URL("integrations", "fortify.ssc.url", null, PropertyType.URL, "Base URL to Fortify SSC", ConfigPropertyAccessMode.READ_WRITE), diff --git a/src/main/java/org/dependencytrack/persistence/jdbi/BomDao.java b/src/main/java/org/dependencytrack/persistence/jdbi/BomDao.java index 684437f45..94f3781e5 100644 --- a/src/main/java/org/dependencytrack/persistence/jdbi/BomDao.java +++ b/src/main/java/org/dependencytrack/persistence/jdbi/BomDao.java @@ -34,6 +34,10 @@ public interface BomDao { @SqlUpdate(""" INSERT INTO "BOM_UPLOAD" ("TOKEN", "UPLOADED_AT", "BOM") VALUES (:token, NOW(), :bomBytes) + ON CONFLICT ("TOKEN") + DO UPDATE + SET "UPLOADED_AT" = NOW() + , "BOM" = :bomBytes """) void createUpload(@Bind UUID token, @Bind byte[] bomBytes); diff --git a/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java b/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java new file mode 100644 index 000000000..a18eccdbe --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java @@ -0,0 +1,106 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +import alpine.Config; + +import java.util.Optional; + +import static java.util.Objects.requireNonNull; +import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle; + +/** + * A read-only registry for accessing application configuration. + *

+ * The registry enforces namespacing of property names, + * to prevent {@link Provider}s from accessing values + * belonging to the core application, or other plugins. + *

+ * Namespacing is based on the plugin's, and the provider's name. + * Provider {@code foo} of plugin {@code bar} can access: + *

    + *
  • Runtime properties with {@code groupName} of {@code plugin} and {@code propertyName} starting with {@code bar.provider.foo}
  • + *
  • Deployment properties prefix {@code bar.provider.foo}
  • + *
+ *

+ * Runtime properties are sourced from the {@code CONFIGPROPERTY} database table. + * Deployment properties are sourced from environment variables, and the {@code application.properties} file. + * + * @since 5.6.0 + */ +public class ConfigRegistry { + + private final String pluginName; + private final String providerName; + + public ConfigRegistry(final String pluginName, final String providerName) { + this.pluginName = requireNonNull(pluginName); + this.providerName = requireNonNull(providerName); + } + + /** + * @param propertyName Name of the runtime property. + * @return An {@link Optional} holding the property value, or {@link Optional#empty()}. + */ + public Optional getRuntimeProperty(final String propertyName) { + final String namespacedPropertyName = "%s.provider.%s.%s".formatted(pluginName, providerName, propertyName); + + return withJdbiHandle(handle -> handle.createQuery(""" + SELECT "PROPERTYVALUE" + FROM "CONFIGPROPERTY" + WHERE "GROUPNAME" = 'plugin' + AND "PROPERTYNAME" = :propertyName + """) + .bind("propertyName", namespacedPropertyName) + .mapTo(String.class) + .findOne()); + } + + /** + * @param propertyName Name of the deployment property. + * @return An {@link Optional} holding the property value, or {@link Optional#empty()}. + */ + public Optional getDeploymentProperty(final String propertyName) { + final var key = new DeploymentConfigKey(pluginName, providerName, propertyName); + return Optional.ofNullable(Config.getInstance().getProperty(key)); + } + + record DeploymentConfigKey(String pluginName, String providerName, String name) implements Config.Key { + + DeploymentConfigKey(final String pluginName, final String name) { + this(pluginName, null, name); + } + + @Override + public String getPropertyName() { + if (providerName == null) { + return "%s.%s".formatted(pluginName, name); + } + + return "%s.provider.%s.%s".formatted(pluginName, providerName, name); + } + + @Override + public Object getDefaultValue() { + return null; + } + + } + +} diff --git a/src/main/java/org/dependencytrack/plugin/Plugin.java b/src/main/java/org/dependencytrack/plugin/Plugin.java new file mode 100644 index 000000000..311e6600b --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/Plugin.java @@ -0,0 +1,46 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +/** + * @since 5.6.0 + */ +public interface Plugin { + + /** + * @return The name of the plugin. Can contain lowercase letters, numbers, and periods. + */ + String name(); + + /** + * @return Whether this plugin is required. Required plugins must have at least one active {@link Provider}. + */ + boolean required(); + + /** + * @return Class of the {@link ProviderFactory} + */ + Class> providerFactoryClass(); + + /** + * @return Class of the {@link Provider} + */ + Class providerClass(); + +} diff --git a/src/main/java/org/dependencytrack/plugin/PluginInitializer.java b/src/main/java/org/dependencytrack/plugin/PluginInitializer.java new file mode 100644 index 000000000..e9ca7f08b --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/PluginInitializer.java @@ -0,0 +1,47 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +import alpine.common.logging.Logger; + +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; + +/** + * @since 5.6.0 + */ +public class PluginInitializer implements ServletContextListener { + + private static final Logger LOGGER = Logger.getLogger(PluginInitializer.class); + + private final PluginManager pluginManager = PluginManager.getInstance(); + + @Override + public void contextInitialized(final ServletContextEvent event) { + LOGGER.info("Loading plugins"); + pluginManager.loadPlugins(); + } + + @Override + public void contextDestroyed(final ServletContextEvent event) { + LOGGER.info("Unloading plugins"); + pluginManager.unloadPlugins(); + } + +} diff --git a/src/main/java/org/dependencytrack/plugin/PluginManager.java b/src/main/java/org/dependencytrack/plugin/PluginManager.java new file mode 100644 index 000000000..6e3df3684 --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/PluginManager.java @@ -0,0 +1,286 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +import alpine.Config; +import alpine.common.logging.Logger; +import org.dependencytrack.plugin.ConfigRegistry.DeploymentConfigKey; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.ServiceLoader; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Pattern; + +import static org.dependencytrack.plugin.ProviderFactory.PRIORITY_HIGHEST; +import static org.dependencytrack.plugin.ProviderFactory.PRIORITY_LOWEST; + +/** + * @since 5.6.0 + */ +public class PluginManager { + + private record ProviderIdentity(Class clazz, String name) { + } + + private static final Logger LOGGER = Logger.getLogger(PluginManager.class); + private static final Pattern PLUGIN_NAME_PATTERN = Pattern.compile("^[a-z0-9.]+$"); + private static final Pattern PROVIDER_NAME_PATTERN = PLUGIN_NAME_PATTERN; + private static final String PROPERTY_PROVIDER_ENABLED = "enabled"; + private static final String PROPERTY_DEFAULT_PROVIDER = "default.provider"; + private static final PluginManager INSTANCE = new PluginManager(); + + private final List loadedPlugins; + private final Map, Plugin> pluginByProviderClass; + private final Map, Set> providerNamesByProviderClass; + private final Map> factoryByProviderKey; + private final Map, ProviderFactory> defaultFactoryByProviderClass; + private final Comparator> providerFactoryComparator; + private final ReentrantLock lock; + + private PluginManager() { + this.loadedPlugins = new ArrayList<>(); + this.pluginByProviderClass = new HashMap<>(); + this.providerNamesByProviderClass = new HashMap<>(); + this.factoryByProviderKey = new HashMap<>(); + this.defaultFactoryByProviderClass = new HashMap<>(); + this.providerFactoryComparator = Comparator + .>comparingInt(ProviderFactory::priority) + .thenComparing(ProviderFactory::providerName); + this.lock = new ReentrantLock(); + } + + public static PluginManager getInstance() { + return INSTANCE; + } + + public List getLoadedPlugins() { + return List.copyOf(loadedPlugins); + } + + @SuppressWarnings("unchecked") + public > U getFactory(final Class providerClass) { + final ProviderFactory factory = defaultFactoryByProviderClass.get(providerClass); + if (factory == null) { + return null; + } + + return (U) factory; + } + + @SuppressWarnings("unchecked") + public > SortedSet getFactories(final Class providerClass) { + final Set providerNames = providerNamesByProviderClass.get(providerClass); + if (providerNames == null) { + return Collections.emptySortedSet(); + } + + final var factories = new TreeSet(providerFactoryComparator); + for (final String providerName : providerNames) { + final var providerKey = new ProviderIdentity(providerClass, providerName); + final ProviderFactory factory = factoryByProviderKey.get(providerKey); + if (factory != null) { + factories.add((U) factory); + } + } + + return factories; + } + + void loadPlugins() { + lock.lock(); + try { + if (!loadedPlugins.isEmpty()) { + // NB: This is primarily to prevent erroneous redundant calls to loadPlugins. + // Under normal circumstances, this method will be called once on application + // startup, making this very unlikely to happen. + throw new IllegalStateException("Plugins were already loaded; Unload them first"); + } + + loadPluginsLocked(); + } finally { + lock.unlock(); + } + } + + private void loadPluginsLocked() { + assert lock.isHeldByCurrentThread() : "Lock is not held by current thread"; + + LOGGER.debug("Discovering plugins"); + final var pluginServiceLoader = ServiceLoader.load(Plugin.class); + for (final Plugin plugin : pluginServiceLoader) { + if (!PLUGIN_NAME_PATTERN.matcher(plugin.name()).matches()) { + throw new IllegalStateException("%s is not a valid plugin name".formatted(plugin.name())); + } + + loadProvidersForPlugin(plugin); + + LOGGER.debug("Loaded plugin %s".formatted(plugin.name())); + loadedPlugins.add(plugin); + } + + determineDefaultProviders(); + + assertRequiredPlugins(); + } + + private void loadProvidersForPlugin(final Plugin plugin) { + LOGGER.debug("Discovering providers for plugin %s".formatted(plugin.name())); + final ServiceLoader> providerFactoryServiceLoader = ServiceLoader.load(plugin.providerFactoryClass()); + for (final ProviderFactory providerFactory : providerFactoryServiceLoader) { + if (!PROVIDER_NAME_PATTERN.matcher(providerFactory.providerName()).matches()) { + throw new IllegalStateException("%s is not a valid provider name".formatted(providerFactory.providerName())); + } + + LOGGER.debug("Discovered provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name())); + final var configRegistry = new ConfigRegistry(plugin.name(), providerFactory.providerName()); + final boolean isEnabled = configRegistry.getDeploymentProperty(PROPERTY_PROVIDER_ENABLED).map(Boolean::parseBoolean).orElse(true); + if (!isEnabled) { + LOGGER.debug("Provider %s for plugin %s is disabled; Skipping".formatted(providerFactory.providerName(), plugin.name())); + continue; + } + + if (providerFactory.priority() < PRIORITY_HIGHEST) { + throw new IllegalStateException(""" + Provider %s has an invalid priority of %d; \ + Allowed range is [%d..%d] (highest to lowest priority)\ + """.formatted(providerFactory.providerName(), providerFactory.priority(), PRIORITY_HIGHEST, PRIORITY_LOWEST) + ); + } + + LOGGER.debug("Initializing provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name())); + try { + providerFactory.init(configRegistry); + } catch (RuntimeException e) { + LOGGER.warn("Failed to initialize provider %s for plugin %s; Skipping".formatted(providerFactory.providerName(), plugin.name()), e); + continue; + } + + pluginByProviderClass.put(plugin.providerClass(), plugin); + + providerNamesByProviderClass.compute(plugin.providerClass(), (ignored, providerNames) -> { + if (providerNames == null) { + return new HashSet<>(Set.of(providerFactory.providerName())); + } + + providerNames.add(providerFactory.providerName()); + return providerNames; + }); + + final var providerIdentity = new ProviderIdentity(plugin.providerClass(), providerFactory.providerName()); + factoryByProviderKey.put(providerIdentity, providerFactory); + } + } + + private void determineDefaultProviders() { + for (final Class providerClass : providerNamesByProviderClass.keySet()) { + final SortedSet> factories = getFactories(providerClass); + if (factories == null || factories.isEmpty()) { + LOGGER.debug("No factories available for provider class %s; Skipping".formatted(providerClass.getName())); + continue; + } + + final Plugin plugin = pluginByProviderClass.get(providerClass); + if (plugin == null) { + throw new IllegalStateException(""" + No plugin exists for provider class %s; \ + This is likely a logic error in the plugin loading procedure\ + """.formatted(providerClass)); + } + + final ProviderFactory providerFactory; + final var defaultProviderConfigKey = new DeploymentConfigKey(plugin.name(), PROPERTY_DEFAULT_PROVIDER); + final String providerName = Config.getInstance().getProperty(defaultProviderConfigKey); + if (providerName == null) { + LOGGER.debug(""" + No default provider configured for plugin %s; \ + Choosing based on priority""".formatted(plugin.name())); + providerFactory = factories.first(); + LOGGER.debug("Chose provider %s with priority %d for plugin %s" + .formatted(providerFactory.providerName(), providerFactory.priority(), plugin.name())); + } else { + LOGGER.debug("Using configured default provider %s for plugin %s".formatted(providerName, plugin.name())); + providerFactory = factories.stream() + .filter(factory -> factory.providerName().equals(providerName)) + .findFirst() + .orElseThrow(() -> new NoSuchElementException(""" + No provider named %s exists for plugin %s\ + """.formatted(providerName, plugin.name()))); + } + + defaultFactoryByProviderClass.put(providerClass, providerFactory); + } + } + + private void assertRequiredPlugins() { + for (final Plugin plugin : loadedPlugins) { + if (!plugin.required()) { + continue; + } + + if (getFactory(plugin.providerClass()) == null) { + throw new IllegalStateException("Plugin %s is required, but no provider is active".formatted(plugin.name())); + } + } + } + + void unloadPlugins() { + lock.lock(); + try { + unloadPluginsLocked(); + defaultFactoryByProviderClass.clear(); + factoryByProviderKey.clear(); + providerNamesByProviderClass.clear(); + pluginByProviderClass.clear(); + loadedPlugins.clear(); + } finally { + lock.unlock(); + } + } + + private void unloadPluginsLocked() { + assert lock.isHeldByCurrentThread() : "Lock is not held by current thread"; + + for (final Plugin plugin : loadedPlugins) { + LOGGER.debug("Closing providers for plugin %s".formatted(plugin.name())); + + for (ProviderFactory providerFactory : getFactories(plugin.providerClass())) { + LOGGER.debug("Closing provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name())); + + try { + providerFactory.close(); + } catch (RuntimeException e) { + LOGGER.warn("Failed to close provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name()), e); + } + } + + LOGGER.debug("Unloaded plugin %s".formatted(plugin.name())); + } + } + +} diff --git a/src/main/java/org/dependencytrack/plugin/Provider.java b/src/main/java/org/dependencytrack/plugin/Provider.java new file mode 100644 index 000000000..c7f144f16 --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/Provider.java @@ -0,0 +1,34 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +/** + * @since 5.6.0 + */ +public interface Provider extends AutoCloseable { + + /** + * {@inheritDoc} + */ + @Override + default void close() { + // Default no-op to remove checked exception from method signature. + } + +} diff --git a/src/main/java/org/dependencytrack/plugin/ProviderFactory.java b/src/main/java/org/dependencytrack/plugin/ProviderFactory.java new file mode 100644 index 000000000..74eee2d07 --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/ProviderFactory.java @@ -0,0 +1,60 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +/** + * @since 5.6.0 + */ +public interface ProviderFactory extends AutoCloseable { + + int PRIORITY_HIGHEST = 0; + int PRIORITY_LOWEST = Integer.MAX_VALUE; + + /** + * @return Name of the provider. Can contain lowercase letters, numbers, and periods. + */ + String providerName(); + + /** + * @return Priority of the provider. Must be a value between {@value #PRIORITY_HIGHEST} + * (highest priority) and {@value #PRIORITY_LOWEST} (lowest priority). + */ + int priority(); + + /** + * Initialize the factory. This method is called once during application startup. + * + * @param configRegistry A {@link ConfigRegistry} to read configuration from. + */ + void init(final ConfigRegistry configRegistry); + + /** + * @return An instance of {@link T}. + */ + T create(); + + /** + * {@inheritDoc} + */ + @Override + default void close() { + // Default no-op to remove checked exception from method signature. + } + +} diff --git a/src/main/java/org/dependencytrack/resources/v1/AbstractConfigPropertyResource.java b/src/main/java/org/dependencytrack/resources/v1/AbstractConfigPropertyResource.java index 3fb0810fd..45a7c7027 100644 --- a/src/main/java/org/dependencytrack/resources/v1/AbstractConfigPropertyResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/AbstractConfigPropertyResource.java @@ -24,11 +24,9 @@ import alpine.model.IConfigProperty; import alpine.security.crypto.DataEncryption; import alpine.server.resources.AlpineResource; -import com.github.luben.zstd.Zstd; import org.dependencytrack.model.ConfigPropertyAccessMode; import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.persistence.QueryManager; -import org.dependencytrack.storage.BomUploadStorageProvider; import org.owasp.security.logging.SecurityMarkers; import javax.ws.rs.core.Response; @@ -67,24 +65,6 @@ private Response updatePropertyValueInternal(IConfigProperty json, IConfigProper .build(); } - if (wellKnownProperty == ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER - && !BomUploadStorageProvider.exists(json.getPropertyValue())) { - return Response - .status(Response.Status.BAD_REQUEST) - .entity("%s is not a known storage provider".formatted(json.getPropertyValue())) - .build(); - } else if (wellKnownProperty == ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL - && json.getPropertyValue() != null) { - final int compressionLevel = Integer.parseInt(json.getPropertyValue()); - if (compressionLevel < 1 || compressionLevel > Zstd.maxCompressionLevel()) { - return Response - .status(Response.Status.BAD_REQUEST) - .entity("Compression level %d is out of the valid [1..%d] range" - .formatted(compressionLevel, Zstd.maxCompressionLevel())) - .build(); - } - } - if (property.getPropertyType() == IConfigProperty.PropertyType.BOOLEAN) { boolean propertyValue = BooleanUtil.valueOf(json.getPropertyValue()); if (ConfigPropertyConstants.CUSTOM_RISK_SCORE_HISTORY_ENABLED.getPropertyName().equals(json.getPropertyName())){ diff --git a/src/main/java/org/dependencytrack/resources/v1/BomResource.java b/src/main/java/org/dependencytrack/resources/v1/BomResource.java index 44ff105a5..87484ec2c 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BomResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BomResource.java @@ -19,7 +19,6 @@ package org.dependencytrack.resources.v1; import alpine.common.logging.Logger; -import alpine.model.ConfigProperty; import alpine.server.auth.PermissionRequired; import alpine.server.resources.AlpineResource; import io.swagger.annotations.Api; @@ -45,6 +44,7 @@ import org.dependencytrack.parser.cyclonedx.CycloneDxValidator; import org.dependencytrack.parser.cyclonedx.InvalidBomException; import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.plugin.PluginManager; import org.dependencytrack.resources.v1.problems.InvalidBomProblemDetails; import org.dependencytrack.resources.v1.problems.ProblemDetails; import org.dependencytrack.resources.v1.vo.BomSubmitRequest; @@ -77,8 +77,6 @@ import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; -import static org.dependencytrack.model.ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL; -import static org.dependencytrack.model.ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER; import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_ENABLED; /** @@ -435,7 +433,7 @@ private Response process(QueryManager qm, Project project, String encodedBomData final var decodedInputStream = Base64.getDecoder().wrap(encodedInputStream); final var byteOrderMarkInputStream = new BOMInputStream(decodedInputStream)) { final byte[] bomBytes = IOUtils.toByteArray(byteOrderMarkInputStream); - validateAndStoreBom(qm, bomUploadEvent.getChainIdentifier(), bomBytes); + validateAndStoreBom(bomUploadEvent.getChainIdentifier(), bomBytes); } catch (IOException e) { LOGGER.error("An unexpected error occurred while validating or storing a BOM uploaded to project: " + project.getUuid(), e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); @@ -467,7 +465,7 @@ private Response process(QueryManager qm, Project project, List loadedPlugins = providerManager.getLoadedPlugins().stream() + .map(plugin -> { + final SortedSet> factories = + providerManager.getFactories(plugin.providerClass()); + final List providerNames = factories.stream() + .map(ProviderFactory::providerName) + .toList(); + + final ProviderFactory defaultFactory = providerManager.getFactory(plugin.providerClass()); + final String defaultProviderName = defaultFactory != null ? defaultFactory.providerName() : null; + + return new LoadedPluginListResponseItem(plugin.name(), providerNames, defaultProviderName); + }) + .toList(); + + return Response.ok(loadedPlugins).build(); + } + +} diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/LoadedPluginListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/LoadedPluginListResponseItem.java new file mode 100644 index 000000000..1288a2f8e --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/vo/LoadedPluginListResponseItem.java @@ -0,0 +1,35 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.vo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.annotations.ApiModelProperty; + +import java.util.List; + +/** + * @since 5.6.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record LoadedPluginListResponseItem( + @ApiModelProperty(value = "Name of the plugin", required = true) String name, + @ApiModelProperty(value = "Names of all loaded providers for the plugin") List providers, + @ApiModelProperty(value = "Name of the default provider for the plugin") String defaultProvider +) { +} diff --git a/src/main/java/org/dependencytrack/storage/BomUploadStoragePlugin.java b/src/main/java/org/dependencytrack/storage/BomUploadStoragePlugin.java new file mode 100644 index 000000000..7856714ab --- /dev/null +++ b/src/main/java/org/dependencytrack/storage/BomUploadStoragePlugin.java @@ -0,0 +1,52 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.storage; + +import org.dependencytrack.plugin.Plugin; +import org.dependencytrack.plugin.Provider; +import org.dependencytrack.plugin.ProviderFactory; + +/** + * @since 5.6.0 + */ +public class BomUploadStoragePlugin implements Plugin { + + static final String PLUGIN_NAME = "bom.upload.storage"; + + @Override + public String name() { + return PLUGIN_NAME; + } + + @Override + public boolean required() { + return true; + } + + @Override + public Class> providerFactoryClass() { + return BomUploadStorageProviderFactory.class; + } + + @Override + public Class providerClass() { + return BomUploadStorageProvider.class; + } + +} diff --git a/src/main/java/org/dependencytrack/storage/BomUploadStorageProvider.java b/src/main/java/org/dependencytrack/storage/BomUploadStorageProvider.java index c6da4d2ba..cf2803446 100644 --- a/src/main/java/org/dependencytrack/storage/BomUploadStorageProvider.java +++ b/src/main/java/org/dependencytrack/storage/BomUploadStorageProvider.java @@ -19,31 +19,57 @@ package org.dependencytrack.storage; import com.github.luben.zstd.Zstd; +import org.dependencytrack.plugin.Provider; import java.io.IOException; import java.time.Duration; -import java.util.NoSuchElementException; -import java.util.ServiceLoader; import java.util.UUID; /** * @since 5.6.0 */ -public interface BomUploadStorageProvider { +public interface BomUploadStorageProvider extends Provider { + /** + * @param token The token to store the BOM for. + * @param bom The BOM to store. + * @throws IOException When storing the BOM failed. + */ void storeBom(final UUID token, final byte[] bom) throws IOException; + /** + * @param token The token to get the BOM for. + * @return The BOM, or {@code null} when no BOM was found. + * @throws IOException When getting the BOM failed. + */ byte[] getBomByToken(final UUID token) throws IOException; + /** + * @param token The token to delete the BOM for. + * @return {@code true} when the BOM was deleted, otherwise {@code false}. + * @throws IOException When deleting the BOM failed. + */ boolean deleteBomByToken(final UUID token) throws IOException; int deleteBomsForRetentionDuration(final Duration duration) throws IOException; + /** + * @param token The token to store the BOM for. + * @param bom The BOM to store. + * @param compressionLevel The compression level to use. + * @throws IOException When storing the BOM failed. + * @see #storeBom(UUID, byte[]) + */ default void storeBomCompressed(final UUID token, final byte[] bom, final int compressionLevel) throws IOException { final byte[] compressedBom = Zstd.compress(bom, compressionLevel); storeBom(token, compressedBom); } + /** + * @param token The token to get the BOM for. + * @return The BOM, or {@code null} when no BOM was found. + * @throws IOException When getting the BOM failed. + */ default byte[] getDecompressedBomByToken(final UUID token) throws IOException { final byte[] compressedBom = getBomByToken(token); if (compressedBom == null) { @@ -58,18 +84,4 @@ default byte[] getDecompressedBomByToken(final UUID token) throws IOException { return Zstd.decompress(compressedBom, (int) decompressedSize); } - static BomUploadStorageProvider getForClassName(final String providerClassName) { - final var serviceLoader = ServiceLoader.load(BomUploadStorageProvider.class); - return serviceLoader.stream() - .filter(provider -> provider.type().getName().equals(providerClassName)) - .findFirst() - .map(ServiceLoader.Provider::get) - .orElseThrow(() -> new NoSuchElementException("%s is not a known storage provider".formatted(providerClassName))); - } - - static boolean exists(final String providerClassName) { - final var serviceLoader = ServiceLoader.load(BomUploadStorageProvider.class); - return serviceLoader.stream().anyMatch(provider -> provider.type().getName().equals(providerClassName)); - } - } diff --git a/src/main/java/org/dependencytrack/storage/BomUploadStorageProviderFactory.java b/src/main/java/org/dependencytrack/storage/BomUploadStorageProviderFactory.java new file mode 100644 index 000000000..be0141bd2 --- /dev/null +++ b/src/main/java/org/dependencytrack/storage/BomUploadStorageProviderFactory.java @@ -0,0 +1,27 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.storage; + +import org.dependencytrack.plugin.ProviderFactory; + +/** + * @since 5.6.0 + */ +public interface BomUploadStorageProviderFactory extends ProviderFactory { +} diff --git a/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProvider.java b/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProvider.java index c40a3ccfc..670cbdd3a 100644 --- a/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProvider.java +++ b/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProvider.java @@ -28,9 +28,13 @@ import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle; /** + * A {@link BomUploadStorageProvider} that stores uploaded BOMs in the {@code BOM_UPLOAD} database table. + * * @since 5.6.0 */ -public class DatabaseBomUploadStorageProvider implements BomUploadStorageProvider { +class DatabaseBomUploadStorageProvider implements BomUploadStorageProvider { + + static final String NAME = "database"; @Override public void storeBom(final UUID token, final byte[] bom) { diff --git a/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderFactory.java b/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderFactory.java new file mode 100644 index 000000000..24d2bce41 --- /dev/null +++ b/src/main/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderFactory.java @@ -0,0 +1,48 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.storage; + +import org.dependencytrack.plugin.ConfigRegistry; + +/** + * @since 5.6.0 + */ +public class DatabaseBomUploadStorageProviderFactory implements BomUploadStorageProviderFactory { + + @Override + public String providerName() { + return DatabaseBomUploadStorageProvider.NAME; + } + + @Override + public int priority() { + return 100; + } + + @Override + public void init(final ConfigRegistry configRegistry) { + // Nothing to configure. + } + + @Override + public BomUploadStorageProvider create() { + return new DatabaseBomUploadStorageProvider(); + } + +} diff --git a/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProvider.java b/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProvider.java index 3c4754b39..5fbdc522c 100644 --- a/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProvider.java +++ b/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProvider.java @@ -18,14 +18,13 @@ */ package org.dependencytrack.storage; -import alpine.Config; import alpine.common.logging.Logger; -import org.dependencytrack.common.ClusterInfo; import java.io.BufferedOutputStream; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.time.Duration; @@ -33,19 +32,17 @@ import java.util.UUID; /** + * A {@link BomUploadStorageProvider} that stores uploaded BOMs on the local filesystem. + * * @since 5.6.0 */ -public class LocalBomUploadStorageProvider implements BomUploadStorageProvider { +class LocalBomUploadStorageProvider implements BomUploadStorageProvider { private static final Logger LOGGER = Logger.getLogger(LocalBomUploadStorageProvider.class); + static final String NAME = "local"; private final Path baseDirPath; - @SuppressWarnings("unused") // Used by ServiceLoader. - public LocalBomUploadStorageProvider() { - this(defaultBaseDirPath()); - } - LocalBomUploadStorageProvider(final Path baseDirPath) { this.baseDirPath = baseDirPath; } @@ -53,7 +50,7 @@ public LocalBomUploadStorageProvider() { @Override public void storeBom(final UUID token, final byte[] bom) throws IOException { final Path outputFilePath = baseDirPath.resolve(token.toString()); - LOGGER.info("Storing BOM at %s".formatted(outputFilePath)); + LOGGER.debug("Storing BOM at %s".formatted(outputFilePath)); try (final var fileOutputStream = Files.newOutputStream(outputFilePath); final var bufferedOutputStream = new BufferedOutputStream(fileOutputStream)) { @@ -64,15 +61,19 @@ public void storeBom(final UUID token, final byte[] bom) throws IOException { @Override public byte[] getBomByToken(final UUID token) throws IOException { final Path inputFilePath = baseDirPath.resolve(token.toString()); - LOGGER.info("Retrieving BOM from %s".formatted(inputFilePath)); + LOGGER.debug("Retrieving BOM from %s".formatted(inputFilePath)); - return Files.readAllBytes(inputFilePath); + try { + return Files.readAllBytes(inputFilePath); + } catch (NoSuchFileException e) { + return null; + } } @Override public boolean deleteBomByToken(final UUID token) throws IOException { final Path bomFilePath = baseDirPath.resolve(token.toString()); - LOGGER.info("Deleting BOM from %s".formatted(token)); + LOGGER.debug("Deleting BOM from %s".formatted(token)); return Files.deleteIfExists(bomFilePath); } @@ -94,7 +95,7 @@ public int deleteBomsForRetentionDuration(final Duration duration) throws IOExce // TODO: Is this problematic for network volumes in other timezones? final var attributes = Files.readAttributes(filePath, BasicFileAttributes.class); if (retentionCutoff.isAfter(attributes.lastModifiedTime().toInstant())) { - LOGGER.info("Deleting BOM from %s".formatted(filePath)); + LOGGER.debug("Deleting BOM from %s".formatted(filePath)); Files.delete(filePath); bomFilesDeleted++; } @@ -103,18 +104,4 @@ public int deleteBomsForRetentionDuration(final Duration duration) throws IOExce return bomFilesDeleted; } - private static Path defaultBaseDirPath() { - // TODO: Use separate, operator-controllable directory. - final Path dataDirPath = Config.getInstance().getDataDirectorty().toPath(); - final Path bomUploadsDirPath = dataDirPath.resolve("bom-uploads").resolve(ClusterInfo.getClusterId()); - - try { - return Files.createDirectories(bomUploadsDirPath); - } catch (IOException e) { - throw new IllegalStateException(""" - Failed to create directory for BOM upload storage at %s\ - """.formatted(bomUploadsDirPath), e); - } - } - } diff --git a/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProviderFactory.java b/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProviderFactory.java new file mode 100644 index 000000000..57f02b027 --- /dev/null +++ b/src/main/java/org/dependencytrack/storage/LocalBomUploadStorageProviderFactory.java @@ -0,0 +1,79 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.storage; + +import alpine.Config; +import alpine.common.logging.Logger; +import org.dependencytrack.plugin.ConfigRegistry; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * @since 5.6.0 + */ +public class LocalBomUploadStorageProviderFactory implements BomUploadStorageProviderFactory { + + private static final Logger LOGGER = Logger.getLogger(LocalBomUploadStorageProviderFactory.class); + + private Path directoryPath; + + @Override + public String providerName() { + return LocalBomUploadStorageProvider.NAME; + } + + @Override + public int priority() { + return 110; + } + + @Override + public void init(final ConfigRegistry configRegistry) { + directoryPath = configRegistry.getDeploymentProperty("directory") + .map(Paths::get) + .orElseGet(() -> { + final Path path = Config.getInstance().getDataDirectorty().toPath().resolve("bom-uploads"); + try { + return Files.createDirectories(path); + } catch (IOException e) { + throw new IllegalStateException(""" + Failed to create directory for BOM upload storage at %s\ + """.formatted(path), e); + } + }); + + final boolean canRead = directoryPath.toFile().canRead(); + final boolean canWrite = directoryPath.toFile().canWrite(); + if (!canRead || !canWrite) { + throw new IllegalStateException("Insufficient permissions for directory %s (canRead=%s, canWrite=%s)" + .formatted(directoryPath, canRead, canWrite)); + } + + LOGGER.info("BOM uploads will be stored in %s".formatted(directoryPath)); + } + + @Override + public BomUploadStorageProvider create() { + return new LocalBomUploadStorageProvider(directoryPath); + } + +} diff --git a/src/main/java/org/dependencytrack/storage/S3BomUploadStorageProvider.java b/src/main/java/org/dependencytrack/storage/S3BomUploadStorageProvider.java new file mode 100644 index 000000000..cf292a65e --- /dev/null +++ b/src/main/java/org/dependencytrack/storage/S3BomUploadStorageProvider.java @@ -0,0 +1,106 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.storage; + +import io.minio.GetObjectArgs; +import io.minio.GetObjectResponse; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import io.minio.RemoveObjectArgs; +import io.minio.errors.ErrorResponseException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.time.Duration; +import java.util.UUID; + +/** + * A {@link BomUploadStorageProvider} that stores uploaded BOMs in an S3 bucket. + * + * @since 5.6.0 + */ +class S3BomUploadStorageProvider implements BomUploadStorageProvider { + + static final String NAME = "s3"; + + private final MinioClient s3Client; + private final String bucketName; + + S3BomUploadStorageProvider(final MinioClient s3Client, final String bucketName) { + this.s3Client = s3Client; + this.bucketName = bucketName; + } + + @Override + public void storeBom(final UUID token, final byte[] bom) throws IOException { + try { + s3Client.putObject(PutObjectArgs.builder() + .bucket(bucketName) + .object(token.toString()) + .stream(new ByteArrayInputStream(bom), bom.length, -1) + .build()); + } catch (Exception e) { + throw new IOException("Failed to store BOM for token %s".formatted(token), e); + } + } + + @Override + public byte[] getBomByToken(final UUID token) throws IOException { + try { + try (final GetObjectResponse response = s3Client.getObject(GetObjectArgs.builder() + .bucket(bucketName) + .object(token.toString()) + .build())) { + return response.readAllBytes(); + } + } catch (ErrorResponseException e) { + // https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html#ErrorCodeList + if ("NoSuchKey".equalsIgnoreCase(e.errorResponse().code())) { + return null; + } + + throw new IOException("Failed to get BOM for token %s".formatted(token), e); + } catch (Exception e) { + throw new IOException("Failed to get BOM for token %s".formatted(token), e); + } + } + + @Override + public boolean deleteBomByToken(final UUID token) throws IOException { + try { + s3Client.removeObject(RemoveObjectArgs.builder() + .bucket(bucketName) + .object(token.toString()) + .build()); + } catch (Exception e) { + throw new IOException("Failed to delete BOM for token %s".formatted(token), e); + } + + // NB: S3 doesn't return any indication or error if the object + // to be deleted did not exist. We have to assume that if the + // request succeeded, it has successfully deleted the object. + return true; + } + + @Override + public int deleteBomsForRetentionDuration(final Duration duration) throws IOException { + return 0; + } + +} diff --git a/src/main/java/org/dependencytrack/storage/S3BomUploadStorageProviderFactory.java b/src/main/java/org/dependencytrack/storage/S3BomUploadStorageProviderFactory.java new file mode 100644 index 000000000..8320242d2 --- /dev/null +++ b/src/main/java/org/dependencytrack/storage/S3BomUploadStorageProviderFactory.java @@ -0,0 +1,99 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.storage; + +import alpine.common.logging.Logger; +import io.minio.BucketExistsArgs; +import io.minio.MinioClient; +import org.dependencytrack.plugin.ConfigRegistry; + +import java.util.Optional; + +import static java.util.function.Predicate.not; + +/** + * @since 5.6.0 + */ +public class S3BomUploadStorageProviderFactory implements BomUploadStorageProviderFactory { + + private static final Logger LOGGER = Logger.getLogger(S3BomUploadStorageProviderFactory.class); + + private MinioClient s3Client; + private String bucketName; + + @Override + public String providerName() { + return S3BomUploadStorageProvider.NAME; + } + + @Override + public int priority() { + return 120; + } + + @Override + public void init(final ConfigRegistry configRegistry) { + final String endpoint = configRegistry.getDeploymentProperty("endpoint") + .map(String::trim) + .filter(not(String::isEmpty)) + .orElseThrow(() -> new IllegalStateException("No endpoint configured")); + bucketName = configRegistry.getDeploymentProperty("bucket") + .map(String::trim) + .filter(not(String::isEmpty)) + .orElseThrow(() -> new IllegalStateException("No bucket name configured")); + final Optional optionalAccessKey = configRegistry.getDeploymentProperty("access.key"); + final Optional optionalSecretKey = configRegistry.getDeploymentProperty("secret.key"); + final Optional optionalRegion = configRegistry.getDeploymentProperty("region"); + + final var clientBuilder = MinioClient.builder().endpoint(endpoint); + if (optionalAccessKey.isPresent() && optionalSecretKey.isPresent()) { + clientBuilder.credentials(optionalAccessKey.get(), optionalSecretKey.get()); + } + optionalRegion.ifPresent(clientBuilder::region); + s3Client = clientBuilder.build(); + + final boolean doesBucketExist; + try { + doesBucketExist = s3Client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); + } catch (Exception e) { + throw new IllegalStateException("Failed to determine if bucket %s exists".formatted(bucketName), e); + } + + if (!doesBucketExist) { + throw new IllegalStateException("Bucket %s does not exist".formatted(bucketName)); + } + } + + @Override + public BomUploadStorageProvider create() { + return new S3BomUploadStorageProvider(s3Client, bucketName); + } + + @Override + public void close() { + if (s3Client != null) { + try { + s3Client.close(); + } catch (Exception e) { + LOGGER.warn("Failed to close S3 client", e); + } + } + } + +} diff --git a/src/main/resources/META-INF/services/org.dependencytrack.plugin.Plugin b/src/main/resources/META-INF/services/org.dependencytrack.plugin.Plugin new file mode 100644 index 000000000..d9742db51 --- /dev/null +++ b/src/main/resources/META-INF/services/org.dependencytrack.plugin.Plugin @@ -0,0 +1 @@ +org.dependencytrack.storage.BomUploadStoragePlugin \ No newline at end of file diff --git a/src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProvider b/src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProvider deleted file mode 100644 index 79dc18e13..000000000 --- a/src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProvider +++ /dev/null @@ -1,2 +0,0 @@ -org.dependencytrack.storage.DatabaseBomUploadStorageProvider -org.dependencytrack.storage.LocalBomUploadStorageProvider \ No newline at end of file diff --git a/src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProviderFactory b/src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProviderFactory new file mode 100644 index 000000000..82c4a2712 --- /dev/null +++ b/src/main/resources/META-INF/services/org.dependencytrack.storage.BomUploadStorageProviderFactory @@ -0,0 +1,3 @@ +org.dependencytrack.storage.DatabaseBomUploadStorageProviderFactory +org.dependencytrack.storage.LocalBomUploadStorageProviderFactory +org.dependencytrack.storage.S3BomUploadStorageProviderFactory \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 20159b261..e1f4a994d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1343,3 +1343,96 @@ dev.services.image.kafka=docker.redpanda.com/vectorized/redpanda:v24.1.11 # @category: Development # @type: string dev.services.image.postgres=postgres:16 + +# Defines the BOM upload storage provider to use. +# When null, an enabled provider will be chosen based on its priority. +# It is recommended to explicitly configure a provider for predictable behavior. +#

+#

    +#
  • +# database is recommended for small to medium deployments. +# It does not require additional infrastructure, and works with multiple +# API server replicas. +#
  • +#
  • +# local is suitable for all deployment sizes. It can help with +# reducing database load, and does not require additional infrastructure for +# single-replica deployments. To support multiple API server replicas, usage +# of a shared volume is required. +#
  • +#
  • +# s3 is recommended for medium to large deployments. It can help +# with reducing database load, but does require additional infrastructure. +# It works with multiple API server replicas and is cheap to operate. +#
  • +#
+# Note that the selected provider must also be enabled, +# for example via bom.upload.storage.provider.s3.enabled. +# +# @category: Storage +# @type: enum +# @valid-values: [database, local, s3] +# bom.upload.storage.default.provider= + +# Whether the database BOM upload storage provider shall be enabled. +# +# @category: Storage +# @type: boolean +bom.upload.storage.provider.database.enabled=true + +# Whether the local BOM upload storage provider shall be enabled. +# +# @category: Storage +# @type: boolean +bom.upload.storage.provider.local.enabled=false + +# Defines the local directory where uploaded BOMs shall be stored. +# Has no effect unless bom.upload.storage.provider.local.enabled is `true`. +# +# @category: Storage +# @default: ${java.io.tmpdir}/bom-uploads +# @type: string +# bom.upload.storage.provider.local.directory= + +# Whether the S3 BOM upload storage provider shall be enabled. +# +# @category: Storage +# @type: boolean +bom.upload.storage.provider.s3.enabled=false + +# Defines the S3 endpoint URL for storing uploaded BOMs. +# Has no effect unless bom.upload.storage.provider.s3.enabled is `true`. +# +# @category: Storage +# @type: string +# bom.upload.storage.provider.s3.endpoint= + +# Defines the S3 bucket to use for storing uploaded BOMs. +# The existence of the bucket will be verified during startup, +# even when S3 is not configured as default provider. +# Has no effect unless bom.upload.storage.provider.s3.enabled is `true`. +# +# @category: Storage +# @type: string +# bom.upload.storage.provider.s3.bucket= + +# Defines the S3 access key / username. +# Has no effect unless bom.upload.storage.provider.s3.enabled is `true`. +# +# @category: Storage +# @type: string +# bom.upload.storage.provider.s3.access.key= + +# Defines the S3 secret key / password. +# Has no effect unless bom.upload.storage.provider.s3.enabled is `true`. +# +# @category: Storage +# @type: string +# bom.upload.storage.provider.s3.secret.key= + +# Defines the region of the S3 bucket. +# Has no effect unless bom.upload.storage.provider.s3.enabled is `true`. +# +# @category: Storage +# @type: string +# bom.upload.storage.provider.s3.region= \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 392ed5cb7..257e2df37 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -37,6 +37,9 @@ alpine.server.persistence.PersistenceManagerFactory + + org.dependencytrack.plugin.PluginInitializer + org.dependencytrack.health.HealthCheckInitializer diff --git a/src/test/java/org/dependencytrack/PersistenceCapableTest.java b/src/test/java/org/dependencytrack/PersistenceCapableTest.java index 648649c94..2a6938546 100644 --- a/src/test/java/org/dependencytrack/PersistenceCapableTest.java +++ b/src/test/java/org/dependencytrack/PersistenceCapableTest.java @@ -38,6 +38,9 @@ import java.sql.Statement; import java.util.Properties; +import static org.dependencytrack.plugin.PluginManagerTestUtil.initPluginManager; +import static org.dependencytrack.plugin.PluginManagerTestUtil.tearDownPluginManager; + public abstract class PersistenceCapableTest { @Rule @@ -53,6 +56,8 @@ public static void init() { postgresContainer = new PostgresTestContainer(); postgresContainer.start(); + + initPluginManager(); } @Before @@ -83,6 +88,8 @@ public void after() { @AfterClass public static void tearDownClass() { + tearDownPluginManager(); + if (postgresContainer != null) { postgresContainer.stopWhenNotReusing(); } diff --git a/src/test/java/org/dependencytrack/ResourceTest.java b/src/test/java/org/dependencytrack/ResourceTest.java index 2920907b7..c2cffbe74 100644 --- a/src/test/java/org/dependencytrack/ResourceTest.java +++ b/src/test/java/org/dependencytrack/ResourceTest.java @@ -43,6 +43,8 @@ import static org.dependencytrack.PersistenceCapableTest.configurePmf; import static org.dependencytrack.PersistenceCapableTest.truncateTables; +import static org.dependencytrack.plugin.PluginManagerTestUtil.initPluginManager; +import static org.dependencytrack.plugin.PluginManagerTestUtil.tearDownPluginManager; public abstract class ResourceTest { @@ -63,6 +65,7 @@ public abstract class ResourceTest { protected final String V1_NOTIFICATION_RULE = "/v1/notification/rule"; protected final String V1_OIDC = "/v1/oidc"; protected final String V1_PERMISSION = "/v1/permission"; + protected final String V1_PLUGIN = "/v1/plugin"; protected final String V1_OSV_ECOSYSTEM = "/v1/integration/osv/ecosystem"; protected final String V1_POLICY = "/v1/policy"; protected final String V1_POLICY_VIOLATION = "/v1/violation"; @@ -105,6 +108,8 @@ public static void init() { postgresContainer = new PostgresTestContainer(); postgresContainer.start(); + + initPluginManager(); } @Before @@ -136,6 +141,8 @@ public void after() { @AfterClass public static void tearDownClass() { + tearDownPluginManager(); + if (postgresContainer != null) { postgresContainer.stopWhenNotReusing(); } diff --git a/src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java b/src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java new file mode 100644 index 000000000..37d3d86a4 --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java @@ -0,0 +1,73 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +import alpine.model.IConfigProperty.PropertyType; +import org.dependencytrack.PersistenceCapableTest; +import org.junit.Rule; +import org.junit.Test; +import org.junit.contrib.java.lang.system.EnvironmentVariables; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ConfigRegistryTest extends PersistenceCapableTest { + + @Rule + public EnvironmentVariables environmentVariables = new EnvironmentVariables(); + + @Test + public void testGetRuntimeProperty() { + qm.createConfigProperty( + /* groupName */ "plugin", + /* propertyName */ "foo.provider.bar.baz", + /* propertyValue */ "qux", + PropertyType.STRING, + /* description */ null + ); + + final var configRegistry = new ConfigRegistry("foo", "bar"); + final Optional optionalProperty = configRegistry.getRuntimeProperty("baz"); + assertThat(optionalProperty).contains("qux"); + } + + @Test + public void testGetRuntimePropertyThatDoesNotExist() { + final var configRegistry = new ConfigRegistry("foo", "bar"); + final Optional optionalProperty = configRegistry.getRuntimeProperty("baz"); + assertThat(optionalProperty).isNotPresent(); + } + + @Test + public void testDeploymentProperty() { + environmentVariables.set("FOO_PROVIDER_BAR_BAZ", "qux"); + final var configRegistry = new ConfigRegistry("foo", "bar"); + final Optional optionalProperty = configRegistry.getDeploymentProperty("baz"); + assertThat(optionalProperty).contains("qux"); + } + + @Test + public void testDeploymentPropertyThatDoesNotExist() { + final var configRegistry = new ConfigRegistry("foo", "bar"); + final Optional optionalProperty = configRegistry.getDeploymentProperty("baz"); + assertThat(optionalProperty).isNotPresent(); + } + +} \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java b/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java new file mode 100644 index 000000000..913f60fd9 --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java @@ -0,0 +1,78 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +import org.dependencytrack.PersistenceCapableTest; +import org.dependencytrack.storage.BomUploadStorageProvider; +import org.junit.Test; + +import java.util.List; +import java.util.SortedSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +public class PluginManagerTest extends PersistenceCapableTest { + + interface InvalidProvider extends Provider { + } + + @Test + public void testGetLoadedPlugins() { + final List loadedPlugins = PluginManager.getInstance().getLoadedPlugins(); + assertThat(loadedPlugins).hasSize(1); + assertThat(loadedPlugins).isUnmodifiable(); + } + + @Test + public void testGetFactory() { + final ProviderFactory factory = + PluginManager.getInstance().getFactory(BomUploadStorageProvider.class); + assertThat(factory).isNotNull(); + } + + @Test + public void testGetFactoryForInvalidProvider() { + final ProviderFactory factory = + PluginManager.getInstance().getFactory(InvalidProvider.class); + assertThat(factory).isNull(); + } + + @Test + public void testGetFactories() { + final SortedSet> factories = + PluginManager.getInstance().getFactories(BomUploadStorageProvider.class); + assertThat(factories).hasSize(1); + } + + @Test + public void testGetFactoriesForInvalidProvider() { + final SortedSet> factories = + PluginManager.getInstance().getFactories(InvalidProvider.class); + assertThat(factories).isEmpty(); + } + + @Test + public void testLoadPluginsRepeatedly() { + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> PluginManager.getInstance().loadPlugins()) + .withMessage("Plugins were already loaded; Unload them first"); + } + +} \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/plugin/PluginManagerTestUtil.java b/src/test/java/org/dependencytrack/plugin/PluginManagerTestUtil.java new file mode 100644 index 000000000..f5690e767 --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/PluginManagerTestUtil.java @@ -0,0 +1,31 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +public class PluginManagerTestUtil { + + public static void initPluginManager() { + PluginManager.getInstance().loadPlugins(); + } + + public static void tearDownPluginManager() { + PluginManager.getInstance().unloadPlugins(); + } + +} diff --git a/src/test/java/org/dependencytrack/resources/v1/ConfigPropertyResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ConfigPropertyResourceTest.java index 62fd6e827..22e0a12bb 100644 --- a/src/test/java/org/dependencytrack/resources/v1/ConfigPropertyResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/ConfigPropertyResourceTest.java @@ -37,7 +37,6 @@ import javax.ws.rs.core.Response; import java.util.Arrays; -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; import static org.dependencytrack.model.ConfigPropertyConstants.CUSTOM_RISK_SCORE_CRITICAL; import static org.dependencytrack.model.ConfigPropertyConstants.CUSTOM_RISK_SCORE_HIGH; @@ -240,96 +239,6 @@ public void updateConfigPropertyReadOnlyTest() { assertThat(getPlainTextBody(response)).isEqualTo("The property internal.cluster.id can not be modified"); } - @Test - public void updateConfigPropertyBomStorageProviderTest() { - qm.createConfigProperty( - ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER.getGroupName(), - ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER.getPropertyName(), - ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER.getDefaultPropertyValue(), - ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER.getPropertyType(), - ConfigPropertyConstants.BOM_UPLOAD_STORAGE_PROVIDER.getDescription() - ); - - Response response = jersey.target(V1_CONFIG_PROPERTY).request() - .header(X_API_KEY, apiKey) - .post(Entity.entity(""" - { - "groupName": "artifact", - "propertyName": "bom.upload.storage.provider", - "propertyValue": "foobar" - } - """, MediaType.APPLICATION_JSON)); - - assertThat(response.getStatus()).isEqualTo(400); - assertThat(getPlainTextBody(response)).isEqualTo("foobar is not a known storage provider"); - - response = jersey.target(V1_CONFIG_PROPERTY).request() - .header(X_API_KEY, apiKey) - .post(Entity.entity(""" - { - "groupName": "artifact", - "propertyName": "bom.upload.storage.provider", - "propertyValue": "org.dependencytrack.storage.LocalBomUploadStorageProvider" - } - """, MediaType.APPLICATION_JSON)); - - assertThat(response.getStatus()).isEqualTo(200); - assertThatJson(getPlainTextBody(response)).isEqualTo(""" - { - "groupName": "artifact", - "propertyName": "bom.upload.storage.provider", - "propertyValue": "org.dependencytrack.storage.LocalBomUploadStorageProvider", - "propertyType": "STRING", - "description": "" - } - """); - } - - @Test - public void updateConfigPropertyBomStorageCompressionLevelTest() { - qm.createConfigProperty( - ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL.getGroupName(), - ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL.getPropertyName(), - ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL.getDefaultPropertyValue(), - ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL.getPropertyType(), - ConfigPropertyConstants.BOM_UPLOAD_STORAGE_COMPRESSION_LEVEL.getDescription() - ); - - Response response = jersey.target(V1_CONFIG_PROPERTY).request() - .header(X_API_KEY, apiKey) - .post(Entity.entity(""" - { - "groupName": "artifact", - "propertyName": "bom.upload.storage.compression.level", - "propertyValue": "999" - } - """, MediaType.APPLICATION_JSON)); - - assertThat(response.getStatus()).isEqualTo(400); - assertThat(getPlainTextBody(response)).isEqualTo("Compression level 999 is out of the valid [1..22] range"); - - response = jersey.target(V1_CONFIG_PROPERTY).request() - .header(X_API_KEY, apiKey) - .post(Entity.entity(""" - { - "groupName": "artifact", - "propertyName": "bom.upload.storage.compression.level", - "propertyValue": "11" - } - """, MediaType.APPLICATION_JSON)); - - assertThat(response.getStatus()).isEqualTo(200); - assertThatJson(getPlainTextBody(response)).isEqualTo(""" - { - "groupName": "artifact", - "propertyName": "bom.upload.storage.compression.level", - "propertyValue": "11", - "propertyType": "INTEGER", - "description": "" - } - """); - } - @Test public void testRiskScoreInvalid(){ qm.createConfigProperty( diff --git a/src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java new file mode 100644 index 000000000..31a9a1cce --- /dev/null +++ b/src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java @@ -0,0 +1,61 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1; + +import alpine.server.filters.ApiFilter; +import alpine.server.filters.AuthenticationFilter; +import org.dependencytrack.JerseyTestRule; +import org.dependencytrack.ResourceTest; +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.ClassRule; +import org.junit.Test; + +import javax.ws.rs.core.Response; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; + +public class PluginResourceTest extends ResourceTest { + + @ClassRule + public static JerseyTestRule jersey = new JerseyTestRule( + new ResourceConfig(PluginResource.class) + .register(ApiFilter.class) + .register(AuthenticationFilter.class)); + + @Test + public void getAllLoadedPluginsTest() { + final Response response = jersey.target(V1_PLUGIN).request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "defaultProvider": "database", + "name": "bom.upload.storage", + "providers": [ + "database" + ] + } + ] + """); + } + +} \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderTest.java b/src/test/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderTest.java index 17e490706..fe46874cf 100644 --- a/src/test/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderTest.java +++ b/src/test/java/org/dependencytrack/storage/DatabaseBomUploadStorageProviderTest.java @@ -19,68 +19,65 @@ package org.dependencytrack.storage; import org.dependencytrack.PersistenceCapableTest; -import org.jdbi.v3.core.statement.UnableToExecuteStatementException; +import org.dependencytrack.plugin.ConfigRegistry; import org.junit.Test; -import org.postgresql.util.PSQLException; -import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle; +import static org.assertj.core.api.Assertions.assertThatNoException; public class DatabaseBomUploadStorageProviderTest extends PersistenceCapableTest { - private final DatabaseBomUploadStorageProvider storageProvider = new DatabaseBomUploadStorageProvider(); - @Test - public void testStoreBom() { - final var token = UUID.randomUUID(); - storageProvider.storeBom(token, "foo".getBytes()); - - final Optional result = withJdbiHandle(handle -> handle - .createQuery("SELECT 1 FROM \"BOM_UPLOAD\" WHERE \"TOKEN\" = :token") - .bind("token", token) - .mapTo(Integer.class) - .findOne()); - assertThat(result).isPresent(); + public void testFactoryName() { + try (final var providerFactory = new DatabaseBomUploadStorageProviderFactory()) { + assertThat(providerFactory.providerName()).isEqualTo("database"); + } } @Test - public void testStoreBomDuplicate() { - final var token = UUID.randomUUID(); - storageProvider.storeBom(token, "foo".getBytes()); - - assertThatExceptionOfType(UnableToExecuteStatementException.class) - .isThrownBy(() -> storageProvider.storeBom(token, "bar".getBytes())) - .withRootCauseInstanceOf(PSQLException.class); + public void testFactoryPriority() { + try (final var providerFactory = new DatabaseBomUploadStorageProviderFactory()) { + assertThat(providerFactory.priority()).isEqualTo(100); + } } @Test - public void testGetBom() { - final var token = UUID.randomUUID(); - storageProvider.storeBom(token, "foo".getBytes()); - assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("foo"); + public void testStoreAndGetAndDeleteBom() throws Exception { + try (final var providerFactory = new DatabaseBomUploadStorageProviderFactory()) { + providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, DatabaseBomUploadStorageProvider.NAME)); + final BomUploadStorageProvider storageProvider = providerFactory.create(); + + final var token = UUID.randomUUID(); + assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes())); + assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("foo"); + assertThat(storageProvider.deleteBomByToken(token)).isTrue(); + assertThat(storageProvider.getBomByToken(token)).isNull(); + } } @Test - public void testDeleteBom() { - final var token = UUID.randomUUID(); - storageProvider.storeBom(token, "foo".getBytes()); - assertThat(storageProvider.deleteBomByToken(token)).isTrue(); + public void testStoreBomDuplicate() throws Exception { + try (final var providerFactory = new DatabaseBomUploadStorageProviderFactory()) { + providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, DatabaseBomUploadStorageProvider.NAME)); + final BomUploadStorageProvider storageProvider = providerFactory.create(); - final Optional result = withJdbiHandle(handle -> handle - .createQuery("SELECT 1 FROM \"BOM_UPLOAD\" WHERE \"TOKEN\" = :token") - .bind("token", token) - .mapTo(Integer.class) - .findOne()); - assertThat(result).isNotPresent(); + final var token = UUID.randomUUID(); + assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes())); + assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "bar".getBytes())); + assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("bar"); + } } @Test - public void testDeleteNonExistentBom() { - assertThat(storageProvider.deleteBomByToken(UUID.randomUUID())).isFalse(); + public void testDeleteNonExistentBom() throws Exception { + try (final var providerFactory = new DatabaseBomUploadStorageProviderFactory()) { + providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME)); + final BomUploadStorageProvider storageProvider = providerFactory.create(); + + assertThat(storageProvider.deleteBomByToken(UUID.randomUUID())).isFalse(); + } } } \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/storage/LocalBomUploadStorageProviderTest.java b/src/test/java/org/dependencytrack/storage/LocalBomUploadStorageProviderTest.java index 460de9f0c..0d60cb9fa 100644 --- a/src/test/java/org/dependencytrack/storage/LocalBomUploadStorageProviderTest.java +++ b/src/test/java/org/dependencytrack/storage/LocalBomUploadStorageProviderTest.java @@ -18,53 +18,101 @@ */ package org.dependencytrack.storage; +import org.dependencytrack.plugin.ConfigRegistry; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.contrib.java.lang.system.EnvironmentVariables; import java.nio.file.Files; import java.nio.file.Path; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; public class LocalBomUploadStorageProviderTest { + @Rule + public EnvironmentVariables environmentVariables = new EnvironmentVariables(); + private Path tempDirPath; - private LocalBomUploadStorageProvider storageProvider; @Before - public void setUp() throws Exception { + public void before() throws Exception { tempDirPath = Files.createTempDirectory(null); tempDirPath.toFile().deleteOnExit(); + } - storageProvider = new LocalBomUploadStorageProvider(tempDirPath); + @Test + public void testFactoryName() { + try (final var providerFactory = new LocalBomUploadStorageProviderFactory()) { + assertThat(providerFactory.providerName()).isEqualTo("local"); + } } @Test - public void testStoreBom() throws Exception { - final var token = UUID.randomUUID(); - storageProvider.storeBom(token, "foo".getBytes()); - assertThat(tempDirPath.resolve(token.toString())).exists(); + public void testFactoryPriority() { + try (final var providerFactory = new LocalBomUploadStorageProviderFactory()) { + assertThat(providerFactory.priority()).isEqualTo(110); + } } @Test - public void testGetBom() throws Exception { - final var token = UUID.randomUUID(); - storageProvider.storeBom(token, "foo".getBytes()); - assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("foo"); + public void testStoreAndGetAndDeleteBom() throws Exception { + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_LOCAL_DIRECTORY", tempDirPath.toString()); + + try (final var providerFactory = new LocalBomUploadStorageProviderFactory()) { + providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, LocalBomUploadStorageProvider.NAME)); + final BomUploadStorageProvider storageProvider = providerFactory.create(); + + final var token = UUID.randomUUID(); + assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes())); + assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("foo"); + assertThat(storageProvider.deleteBomByToken(token)).isTrue(); + assertThat(storageProvider.getBomByToken(token)).isNull(); + } } @Test - public void testDeleteBom() throws Exception { - final var token = UUID.randomUUID(); - storageProvider.storeBom(token, "foo".getBytes()); - assertThat(storageProvider.deleteBomByToken(token)).isTrue(); - assertThat(tempDirPath.resolve(token.toString())).doesNotExist(); + public void testStoreAndGetAndDeleteBomWithDefaultDirectory() throws Exception { + try (final var providerFactory = new LocalBomUploadStorageProviderFactory()) { + providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, LocalBomUploadStorageProvider.NAME)); + final BomUploadStorageProvider storageProvider = providerFactory.create(); + + final var token = UUID.randomUUID(); + assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes())); + assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("foo"); + assertThat(storageProvider.deleteBomByToken(token)).isTrue(); + assertThat(storageProvider.getBomByToken(token)).isNull(); + } + } + + @Test + public void testStoreBomDuplicate() throws Exception { + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_LOCAL_DIRECTORY", tempDirPath.toString()); + + try (final var providerFactory = new LocalBomUploadStorageProviderFactory()) { + providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, LocalBomUploadStorageProvider.NAME)); + final BomUploadStorageProvider storageProvider = providerFactory.create(); + + final var token = UUID.randomUUID(); + assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes())); + assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "bar".getBytes())); + assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("bar"); + } } @Test public void testDeleteNonExistentBom() throws Exception { - assertThat(storageProvider.deleteBomByToken(UUID.randomUUID())).isFalse(); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_LOCAL_DIRECTORY", tempDirPath.toString()); + + try (final var providerFactory = new LocalBomUploadStorageProviderFactory()) { + providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, LocalBomUploadStorageProvider.NAME)); + final BomUploadStorageProvider storageProvider = providerFactory.create(); + + assertThat(storageProvider.deleteBomByToken(UUID.randomUUID())).isFalse(); + } } } \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/storage/S3BomUploadStorageProviderTest.java b/src/test/java/org/dependencytrack/storage/S3BomUploadStorageProviderTest.java new file mode 100644 index 000000000..437992156 --- /dev/null +++ b/src/test/java/org/dependencytrack/storage/S3BomUploadStorageProviderTest.java @@ -0,0 +1,231 @@ +/* + * This file is part of Dependency-Track. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.storage; + +import io.minio.MakeBucketArgs; +import io.minio.MinioClient; +import org.dependencytrack.plugin.ConfigRegistry; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.contrib.java.lang.system.EnvironmentVariables; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; + +public class S3BomUploadStorageProviderTest { + + @Rule + public EnvironmentVariables environmentVariables = new EnvironmentVariables(); + + private MinIOContainer minioContainer; + private MinioClient s3Client; + + @Before + public void before() throws Exception { + minioContainer = new MinIOContainer(DockerImageName.parse("minio/minio:RELEASE.2023-12-14T18-51-57Z")); + minioContainer.start(); + + s3Client = MinioClient.builder() + .endpoint(minioContainer.getS3URL()) + .credentials(minioContainer.getUserName(), minioContainer.getPassword()) + .build(); + + s3Client.makeBucket(MakeBucketArgs.builder() + .bucket("test") + .build()); + } + + @After + public void after() throws Exception { + if (s3Client != null) { + s3Client.close(); + } + if (minioContainer != null) { + minioContainer.stop(); + } + } + + @Test + public void testFactoryName() { + try (final var providerFactory = new S3BomUploadStorageProviderFactory()) { + assertThat(providerFactory.providerName()).isEqualTo("s3"); + } + } + + @Test + public void testFactoryPriority() { + try (final var providerFactory = new S3BomUploadStorageProviderFactory()) { + assertThat(providerFactory.priority()).isEqualTo(120); + } + } + + @Test + public void testFactoryInitWhenBucketDoesNotExist() { + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "not-not-exist"); + + try (final var providerFactory = new S3BomUploadStorageProviderFactory()) { + final var configRegistry = new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME); + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> providerFactory.init(configRegistry)) + .withMessage("Bucket not-not-exist does not exist"); + } + } + + @Test + public void testFactoryInitWithErrorOnBucketExistsCheck() { + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "not-not-exist"); + + try (final var providerFactory = new S3BomUploadStorageProviderFactory()) { + final var configRegistry = new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME); + + minioContainer.stop(); + + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> providerFactory.init(configRegistry)) + .withMessage("Failed to determine if bucket not-not-exist exists"); + } + } + + @Test + public void testStoreAndGetAndDeleteBom() throws Exception { + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "test"); + + try (final var providerFactory = new S3BomUploadStorageProviderFactory()) { + providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME)); + final BomUploadStorageProvider storageProvider = providerFactory.create(); + + final var token = UUID.randomUUID(); + assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes())); + assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("foo"); + assertThat(storageProvider.deleteBomByToken(token)).isTrue(); + assertThat(storageProvider.getBomByToken(token)).isNull(); + } + } + + @Test + public void testStoreBomDuplicate() throws Exception { + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "test"); + + try (final var providerFactory = new S3BomUploadStorageProviderFactory()) { + providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME)); + final BomUploadStorageProvider storageProvider = providerFactory.create(); + + final var token = UUID.randomUUID(); + assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes())); + assertThatNoException().isThrownBy(() -> storageProvider.storeBom(token, "bar".getBytes())); + assertThat(storageProvider.getBomByToken(token)).asString().isEqualTo("bar"); + } + } + + @Test + public void testStoreBomError() { + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "test"); + + try (final var providerFactory = new S3BomUploadStorageProviderFactory()) { + providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME)); + final BomUploadStorageProvider storageProvider = providerFactory.create(); + + minioContainer.stop(); + + final var token = UUID.randomUUID(); + assertThatExceptionOfType(IOException.class) + .isThrownBy(() -> storageProvider.storeBom(token, "foo".getBytes())) + .withMessage("Failed to store BOM for token %s".formatted(token)); + } + } + + @Test + public void testGetBomError() { + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "test"); + + try (final var providerFactory = new S3BomUploadStorageProviderFactory()) { + providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME)); + final BomUploadStorageProvider storageProvider = providerFactory.create(); + + minioContainer.stop(); + + final var token = UUID.randomUUID(); + assertThatExceptionOfType(IOException.class) + .isThrownBy(() -> storageProvider.getBomByToken(token)) + .withMessage("Failed to get BOM for token %s".formatted(token)); + } + } + + @Test + public void testDeleteNonExistentBom() throws Exception { + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "test"); + + try (final var providerFactory = new S3BomUploadStorageProviderFactory()) { + providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME)); + final BomUploadStorageProvider storageProvider = providerFactory.create(); + + assertThat(storageProvider.deleteBomByToken(UUID.randomUUID())).isTrue(); + } + } + + @Test + public void testDeleteBomError() { + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ENDPOINT", minioContainer.getS3URL()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_ACCESS_KEY", minioContainer.getUserName()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_SECRET_KEY", minioContainer.getPassword()); + environmentVariables.set("BOM_UPLOAD_STORAGE_PROVIDER_S3_BUCKET", "test"); + + try (final var providerFactory = new S3BomUploadStorageProviderFactory()) { + providerFactory.init(new ConfigRegistry(BomUploadStoragePlugin.PLUGIN_NAME, S3BomUploadStorageProvider.NAME)); + final BomUploadStorageProvider storageProvider = providerFactory.create(); + + minioContainer.stop(); + + final var token = UUID.randomUUID(); + assertThatExceptionOfType(IOException.class) + .isThrownBy(() -> storageProvider.deleteBomByToken(token)) + .withMessage("Failed to delete BOM for token %s".formatted(token)); + } + } + +} \ No newline at end of file