From d814150d396f5d904fb76234db9755755aa77fc7 Mon Sep 17 00:00:00 2001 From: nscuro Date: Sat, 27 Jul 2024 21:50:24 +0200 Subject: [PATCH 1/5] Introduce plugin system to deal with provider config and lifecycle Signed-off-by: nscuro --- .../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 ++++ .../resources/v1/PluginResource.java | 79 +++++ .../v1/vo/LoadedPluginListResponseItem.java | 35 +++ .../org.dependencytrack.plugin.Plugin | 0 src/main/webapp/WEB-INF/web.xml | 6 +- .../PersistenceCapableTest.java | 5 + .../org/dependencytrack/ResourceTest.java | 6 + .../plugin/ConfigRegistryTest.java | 73 +++++ .../dependencytrack/plugin/DummyPlugin.java | 43 +++ .../dependencytrack/plugin/DummyProvider.java | 22 ++ .../plugin/DummyProviderFactory.java | 22 ++ .../plugin/PluginManagerTest.java | 77 +++++ .../plugin/PluginManagerTestUtil.java | 31 ++ .../plugin/TestDummyProviderFactory.java | 43 +++ .../resources/v1/PluginResourceTest.java | 61 ++++ ...ependencytrack.plugin.DummyProviderFactory | 1 + .../org.dependencytrack.plugin.Plugin | 1 + 22 files changed, 1081 insertions(+), 3 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/resources/META-INF/services/org.dependencytrack.plugin.Plugin create mode 100644 src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java create mode 100644 src/test/java/org/dependencytrack/plugin/DummyPlugin.java create mode 100644 src/test/java/org/dependencytrack/plugin/DummyProvider.java create mode 100644 src/test/java/org/dependencytrack/plugin/DummyProviderFactory.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/plugin/TestDummyProviderFactory.java create mode 100644 src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java create mode 100644 src/test/resources/META-INF/services/org.dependencytrack.plugin.DummyProviderFactory create mode 100644 src/test/resources/META-INF/services/org.dependencytrack.plugin.Plugin 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 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/PluginResource.java b/src/main/java/org/dependencytrack/resources/v1/PluginResource.java new file mode 100644 index 000000000..02c5e380b --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/PluginResource.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.resources.v1; + +import alpine.server.auth.PermissionRequired; +import alpine.server.resources.AlpineResource; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; +import io.swagger.annotations.Authorization; +import org.dependencytrack.auth.Permissions; +import org.dependencytrack.plugin.PluginManager; +import org.dependencytrack.plugin.Provider; +import org.dependencytrack.plugin.ProviderFactory; +import org.dependencytrack.resources.v1.vo.LoadedPluginListResponseItem; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.util.List; +import java.util.SortedSet; + +@Path("/v1/plugin") +@Api(value = "plugin", authorizations = @Authorization(value = "X-Api-Key")) +public class PluginResource extends AlpineResource { + + @GET + @Produces(MediaType.APPLICATION_JSON) + @ApiOperation( + value = "Returns a list of all loaded plugins", + response = LoadedPluginListResponseItem.class, + responseContainer = "List", + notes = "

Requires permission SYSTEM_CONFIGURATION

" + ) + @ApiResponses(value = { + @ApiResponse(code = 401, message = "Unauthorized") + }) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + public Response getAllLoadedPlugins() { + final var providerManager = PluginManager.getInstance(); + + final 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/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..e69de29bb diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 91bc50a79..fb15dbff8 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -29,15 +29,15 @@ alpine.server.metrics.MetricsInitializer - - alpine.server.persistence.PersistenceInitializer - org.dependencytrack.persistence.migration.MigrationInitializer 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 c785aaecb..022a6aeec 100644 --- a/src/test/java/org/dependencytrack/PersistenceCapableTest.java +++ b/src/test/java/org/dependencytrack/PersistenceCapableTest.java @@ -25,6 +25,7 @@ import org.datanucleus.api.jdo.JDOPersistenceManagerFactory; import org.dependencytrack.event.kafka.KafkaProducerInitializer; import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.plugin.PluginManagerTestUtil; import org.junit.After; import org.junit.AfterClass; import org.junit.Before; @@ -48,6 +49,8 @@ public static void init() { postgresContainer = new PostgresTestContainer(); postgresContainer.start(); + + PluginManagerTestUtil.loadPlugins(); } @Before @@ -77,6 +80,8 @@ public void after() { @AfterClass public static void tearDownClass() { + PluginManagerTestUtil.unloadPlugins(); + if (postgresContainer != null) { postgresContainer.stopWhenNotReusing(); } diff --git a/src/test/java/org/dependencytrack/ResourceTest.java b/src/test/java/org/dependencytrack/ResourceTest.java index 85358e146..2af1d619e 100644 --- a/src/test/java/org/dependencytrack/ResourceTest.java +++ b/src/test/java/org/dependencytrack/ResourceTest.java @@ -27,6 +27,7 @@ import org.dependencytrack.auth.Permissions; import org.dependencytrack.event.kafka.KafkaProducerInitializer; import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.plugin.PluginManagerTestUtil; import org.junit.After; import org.junit.AfterClass; import org.junit.Before; @@ -63,6 +64,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 +107,8 @@ public static void init() { postgresContainer = new PostgresTestContainer(); postgresContainer.start(); + + PluginManagerTestUtil.loadPlugins(); } @Before @@ -136,6 +140,8 @@ public void after() { @AfterClass public static void tearDownClass() { + PluginManagerTestUtil.unloadPlugins(); + 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/DummyPlugin.java b/src/test/java/org/dependencytrack/plugin/DummyPlugin.java new file mode 100644 index 000000000..ffb523e5d --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/DummyPlugin.java @@ -0,0 +1,43 @@ +/* + * 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 DummyPlugin implements Plugin { + + @Override + public String name() { + return "dummy"; + } + + @Override + public boolean required() { + return false; + } + + @Override + public Class> providerFactoryClass() { + return DummyProviderFactory.class; + } + + @Override + public Class providerClass() { + return DummyProvider.class; + } + +} diff --git a/src/test/java/org/dependencytrack/plugin/DummyProvider.java b/src/test/java/org/dependencytrack/plugin/DummyProvider.java new file mode 100644 index 000000000..dea4a3cea --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/DummyProvider.java @@ -0,0 +1,22 @@ +/* + * 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 interface DummyProvider extends Provider { +} diff --git a/src/test/java/org/dependencytrack/plugin/DummyProviderFactory.java b/src/test/java/org/dependencytrack/plugin/DummyProviderFactory.java new file mode 100644 index 000000000..99752b61c --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/DummyProviderFactory.java @@ -0,0 +1,22 @@ +/* + * 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 interface DummyProviderFactory extends ProviderFactory { +} 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..c29a7f959 --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java @@ -0,0 +1,77 @@ +/* + * 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.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(DummyProvider.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(DummyProvider.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..02cf9a1fc --- /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 loadPlugins() { + PluginManager.getInstance().loadPlugins(); + } + + public static void unloadPlugins() { + PluginManager.getInstance().unloadPlugins(); + } + +} diff --git a/src/test/java/org/dependencytrack/plugin/TestDummyProviderFactory.java b/src/test/java/org/dependencytrack/plugin/TestDummyProviderFactory.java new file mode 100644 index 000000000..4ee7c1cac --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/TestDummyProviderFactory.java @@ -0,0 +1,43 @@ +/* + * 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 TestDummyProviderFactory implements DummyProviderFactory { + + @Override + public String providerName() { + return "test"; + } + + @Override + public int priority() { + return 0; + } + + @Override + public void init(final ConfigRegistry ignored) { + // Nothing to do. + } + + @Override + public DummyProvider create() { + return null; + } + +} 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..0277a1127 --- /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": "test", + "name": "dummy", + "providers": [ + "test" + ] + } + ] + """); + } + +} \ No newline at end of file diff --git a/src/test/resources/META-INF/services/org.dependencytrack.plugin.DummyProviderFactory b/src/test/resources/META-INF/services/org.dependencytrack.plugin.DummyProviderFactory new file mode 100644 index 000000000..2d760f271 --- /dev/null +++ b/src/test/resources/META-INF/services/org.dependencytrack.plugin.DummyProviderFactory @@ -0,0 +1 @@ +org.dependencytrack.plugin.TestDummyProviderFactory \ No newline at end of file diff --git a/src/test/resources/META-INF/services/org.dependencytrack.plugin.Plugin b/src/test/resources/META-INF/services/org.dependencytrack.plugin.Plugin new file mode 100644 index 000000000..e44ebf020 --- /dev/null +++ b/src/test/resources/META-INF/services/org.dependencytrack.plugin.Plugin @@ -0,0 +1 @@ +org.dependencytrack.plugin.DummyPlugin \ No newline at end of file From c3394a5eab4b743c7b3e15d223afdd0a2cd4b187 Mon Sep 17 00:00:00 2001 From: nscuro Date: Sun, 28 Jul 2024 19:38:50 +0200 Subject: [PATCH 2/5] Decouple concepts of "plugin" from "extension point" It doesn't make sense that plugins are defined by DT, and cannot be provided by users. Instead, DT defines "extension points", for which plugins can provide implementations ("extensions"). This change also enabled plugins to provide multiple extensions, not just one. Signed-off-by: nscuro --- .../plugin/ConfigRegistry.java | 41 +-- ...iderFactory.java => ExtensionFactory.java} | 17 +- .../{Provider.java => ExtensionPoint.java} | 4 +- .../plugin/ExtensionPointMetadata.java} | 22 +- .../org/dependencytrack/plugin/Plugin.java | 18 +- .../dependencytrack/plugin/PluginManager.java | 288 ++++++++++++------ .../resources/v1/PluginResource.java | 79 ----- .../v1/vo/LoadedPluginListResponseItem.java | 35 --- ...endencytrack.plugin.ExtensionPointMetadata | 0 .../PersistenceCapableTest.java | 8 +- .../org/dependencytrack/ResourceTest.java | 8 +- .../plugin/ConfigRegistryTest.java | 6 +- .../dependencytrack/plugin/DummyPlugin.java | 19 +- .../plugin/DummyTestExtension.java | 38 +++ .../plugin/DummyTestExtensionFactory.java | 53 ++++ .../plugin/PluginManagerTest.java | 95 +++++- ...yProvider.java => TestExtensionPoint.java} | 5 +- ...y.java => TestExtensionPointMetadata.java} | 17 +- .../resources/v1/PluginResourceTest.java | 61 ---- ...ependencytrack.plugin.DummyProviderFactory | 1 - ...endencytrack.plugin.ExtensionPointMetadata | 1 + 21 files changed, 451 insertions(+), 365 deletions(-) rename src/main/java/org/dependencytrack/plugin/{ProviderFactory.java => ExtensionFactory.java} (75%) rename src/main/java/org/dependencytrack/plugin/{Provider.java => ExtensionPoint.java} (92%) rename src/{test/java/org/dependencytrack/plugin/DummyProviderFactory.java => main/java/org/dependencytrack/plugin/ExtensionPointMetadata.java} (58%) delete mode 100644 src/main/java/org/dependencytrack/resources/v1/PluginResource.java delete mode 100644 src/main/java/org/dependencytrack/resources/v1/vo/LoadedPluginListResponseItem.java create mode 100644 src/main/resources/META-INF/services/org.dependencytrack.plugin.ExtensionPointMetadata create mode 100644 src/test/java/org/dependencytrack/plugin/DummyTestExtension.java create mode 100644 src/test/java/org/dependencytrack/plugin/DummyTestExtensionFactory.java rename src/test/java/org/dependencytrack/plugin/{DummyProvider.java => TestExtensionPoint.java} (90%) rename src/test/java/org/dependencytrack/plugin/{TestDummyProviderFactory.java => TestExtensionPointMetadata.java} (72%) delete mode 100644 src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java delete mode 100644 src/test/resources/META-INF/services/org.dependencytrack.plugin.DummyProviderFactory create mode 100644 src/test/resources/META-INF/services/org.dependencytrack.plugin.ExtensionPointMetadata diff --git a/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java b/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java index a18eccdbe..aa2e4f4f3 100644 --- a/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java +++ b/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java @@ -29,14 +29,14 @@ * 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. + * to prevent {@link ExtensionPoint}s from accessing values + * belonging to the core application, or other extension points. *

- * Namespacing is based on the plugin's, and the provider's name. - * Provider {@code foo} of plugin {@code bar} can access: + * Namespacing is based on the extension point's, and the extension's name. + * Extension {@code bar} of extension point {@code foo} 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 with {@code groupName} of {@code foo} and {@code propertyName} prefixed with {@code extension.bar}
  • + *
  • Deployment properties prefixed with {@code foo.extension.bar}
  • *
*

* Runtime properties are sourced from the {@code CONFIGPROPERTY} database table. @@ -46,12 +46,12 @@ */ public class ConfigRegistry { - private final String pluginName; - private final String providerName; + private final String extensionPointName; + private final String extensionName; - public ConfigRegistry(final String pluginName, final String providerName) { - this.pluginName = requireNonNull(pluginName); - this.providerName = requireNonNull(providerName); + public ConfigRegistry(final String extensionPointName, final String extensionName) { + this.extensionPointName = requireNonNull(extensionPointName); + this.extensionName = requireNonNull(extensionName); } /** @@ -59,14 +59,15 @@ public ConfigRegistry(final String pluginName, final String providerName) { * @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); + final String namespacedPropertyName = "extension.%s.%s".formatted(extensionName, propertyName); return withJdbiHandle(handle -> handle.createQuery(""" SELECT "PROPERTYVALUE" FROM "CONFIGPROPERTY" - WHERE "GROUPNAME" = 'plugin' + WHERE "GROUPNAME" = :extensionPointName AND "PROPERTYNAME" = :propertyName """) + .bind("extensionPointName", extensionPointName) .bind("propertyName", namespacedPropertyName) .mapTo(String.class) .findOne()); @@ -77,23 +78,23 @@ public Optional getRuntimeProperty(final String propertyName) { * @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); + final var key = new DeploymentConfigKey(extensionPointName, extensionName, propertyName); return Optional.ofNullable(Config.getInstance().getProperty(key)); } - record DeploymentConfigKey(String pluginName, String providerName, String name) implements Config.Key { + record DeploymentConfigKey(String extensionPointName, String extensionName, String name) implements Config.Key { - DeploymentConfigKey(final String pluginName, final String name) { - this(pluginName, null, name); + DeploymentConfigKey(final String extensionPointName, final String name) { + this(extensionPointName, null, name); } @Override public String getPropertyName() { - if (providerName == null) { - return "%s.%s".formatted(pluginName, name); + if (extensionName == null) { + return "%s.%s".formatted(extensionPointName, name); } - return "%s.provider.%s.%s".formatted(pluginName, providerName, name); + return "%s.extension.%s.%s".formatted(extensionPointName, extensionName, name); } @Override diff --git a/src/main/java/org/dependencytrack/plugin/ProviderFactory.java b/src/main/java/org/dependencytrack/plugin/ExtensionFactory.java similarity index 75% rename from src/main/java/org/dependencytrack/plugin/ProviderFactory.java rename to src/main/java/org/dependencytrack/plugin/ExtensionFactory.java index 74eee2d07..d75327be1 100644 --- a/src/main/java/org/dependencytrack/plugin/ProviderFactory.java +++ b/src/main/java/org/dependencytrack/plugin/ExtensionFactory.java @@ -18,21 +18,28 @@ */ package org.dependencytrack.plugin; +import java.io.Closeable; + /** * @since 5.6.0 */ -public interface ProviderFactory extends AutoCloseable { +public interface ExtensionFactory extends Closeable { int PRIORITY_HIGHEST = 0; int PRIORITY_LOWEST = Integer.MAX_VALUE; /** - * @return Name of the provider. Can contain lowercase letters, numbers, and periods. + * @return Name of the extension. Can contain lowercase letters, numbers, and periods. + */ + String extensionName(); + + /** + * @return {@link Class} of the extension. */ - String providerName(); + Class extensionClass(); /** - * @return Priority of the provider. Must be a value between {@value #PRIORITY_HIGHEST} + * @return Priority of the extension. Must be a value between {@value #PRIORITY_HIGHEST} * (highest priority) and {@value #PRIORITY_LOWEST} (lowest priority). */ int priority(); @@ -45,7 +52,7 @@ public interface ProviderFactory extends AutoCloseable { void init(final ConfigRegistry configRegistry); /** - * @return An instance of {@link T}. + * @return An extension instance. */ T create(); diff --git a/src/main/java/org/dependencytrack/plugin/Provider.java b/src/main/java/org/dependencytrack/plugin/ExtensionPoint.java similarity index 92% rename from src/main/java/org/dependencytrack/plugin/Provider.java rename to src/main/java/org/dependencytrack/plugin/ExtensionPoint.java index c7f144f16..475bf86aa 100644 --- a/src/main/java/org/dependencytrack/plugin/Provider.java +++ b/src/main/java/org/dependencytrack/plugin/ExtensionPoint.java @@ -18,10 +18,12 @@ */ package org.dependencytrack.plugin; +import java.io.Closeable; + /** * @since 5.6.0 */ -public interface Provider extends AutoCloseable { +public interface ExtensionPoint extends Closeable { /** * {@inheritDoc} diff --git a/src/test/java/org/dependencytrack/plugin/DummyProviderFactory.java b/src/main/java/org/dependencytrack/plugin/ExtensionPointMetadata.java similarity index 58% rename from src/test/java/org/dependencytrack/plugin/DummyProviderFactory.java rename to src/main/java/org/dependencytrack/plugin/ExtensionPointMetadata.java index 99752b61c..58056b20f 100644 --- a/src/test/java/org/dependencytrack/plugin/DummyProviderFactory.java +++ b/src/main/java/org/dependencytrack/plugin/ExtensionPointMetadata.java @@ -18,5 +18,25 @@ */ package org.dependencytrack.plugin; -public interface DummyProviderFactory extends ProviderFactory { +/** + * @since 5.6.0 + */ +public interface ExtensionPointMetadata { + + /** + * @return The name of the {@link ExtensionPoint}. Can contain lowercase letters, numbers, and periods. + */ + String name(); + + /** + * @return Whether the {@link ExtensionPoint} is required. + * Required extension points must have at least one active implementation. + */ + boolean required(); + + /** + * @return The {@link Class} of the {@link ExtensionPoint}. + */ + Class extensionPointClass(); + } diff --git a/src/main/java/org/dependencytrack/plugin/Plugin.java b/src/main/java/org/dependencytrack/plugin/Plugin.java index 311e6600b..bca4f5368 100644 --- a/src/main/java/org/dependencytrack/plugin/Plugin.java +++ b/src/main/java/org/dependencytrack/plugin/Plugin.java @@ -18,29 +18,21 @@ */ package org.dependencytrack.plugin; +import java.util.Collection; + /** * @since 5.6.0 */ public interface Plugin { /** - * @return The name of the plugin. Can contain lowercase letters, numbers, and periods. + * @return The name of the plugin. */ 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} + * @return The {@link ExtensionFactory}s provided by the plugin. */ - Class providerClass(); + Collection> extensionFactories(); } diff --git a/src/main/java/org/dependencytrack/plugin/PluginManager.java b/src/main/java/org/dependencytrack/plugin/PluginManager.java index 6e3df3684..efa9c7e50 100644 --- a/src/main/java/org/dependencytrack/plugin/PluginManager.java +++ b/src/main/java/org/dependencytrack/plugin/PluginManager.java @@ -22,7 +22,9 @@ import alpine.common.logging.Logger; import org.dependencytrack.plugin.ConfigRegistry.DeploymentConfigKey; +import java.lang.reflect.Modifier; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -37,41 +39,44 @@ 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; +import static org.dependencytrack.plugin.ExtensionFactory.PRIORITY_HIGHEST; +import static org.dependencytrack.plugin.ExtensionFactory.PRIORITY_LOWEST; /** * @since 5.6.0 */ public class PluginManager { - private record ProviderIdentity(Class clazz, String name) { + private record ExtensionIdentity(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 Pattern EXTENSION_POINT_NAME_PATTERN = PLUGIN_NAME_PATTERN; + private static final Pattern EXTENSION_NAME_PATTERN = PLUGIN_NAME_PATTERN; + private static final String PROPERTY_EXTENSION_ENABLED = "enabled"; + private static final String PROPERTY_DEFAULT_EXTENSION = "default.extension"; 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 Map>> factoriesByPlugin; + private final Map, ExtensionPointMetadata> metadataByExtensionPointClass; + private final Map, Set> extensionNamesByExtensionPointClass; + private final Map> factoryByExtensionIdentity; + private final Map, ExtensionFactory> defaultFactoryByExtensionPointClass; + private final Comparator> factoryComparator; 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.factoriesByPlugin = new HashMap<>(); + this.metadataByExtensionPointClass = new HashMap<>(); + this.extensionNamesByExtensionPointClass = new HashMap<>(); + this.factoryByExtensionIdentity = new HashMap<>(); + this.defaultFactoryByExtensionPointClass = new HashMap<>(); + this.factoryComparator = Comparator + .>comparingInt(ExtensionFactory::priority) + .thenComparing(ExtensionFactory::extensionName); this.lock = new ReentrantLock(); } @@ -84,8 +89,18 @@ public List getLoadedPlugins() { } @SuppressWarnings("unchecked") - public > U getFactory(final Class providerClass) { - final ProviderFactory factory = defaultFactoryByProviderClass.get(providerClass); + public T getExtension(final Class extensionPointClass) { + final ExtensionFactory factory = defaultFactoryByExtensionPointClass.get(extensionPointClass); + if (factory == null) { + return null; + } + + return (T) factory.create(); + } + + @SuppressWarnings("unchecked") + public > U getFactory(final Class extensionPointClass) { + final ExtensionFactory factory = defaultFactoryByExtensionPointClass.get(extensionPointClass); if (factory == null) { return null; } @@ -94,16 +109,16 @@ public > U getFactory(final Cla } @SuppressWarnings("unchecked") - public > SortedSet getFactories(final Class providerClass) { - final Set providerNames = providerNamesByProviderClass.get(providerClass); - if (providerNames == null) { + public > SortedSet getFactories(final Class extensionPointClass) { + final Set extensionNames = extensionNamesByExtensionPointClass.get(extensionPointClass); + if (extensionNames == 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); + final var factories = new TreeSet(factoryComparator); + for (final String extensionName : extensionNames) { + final var extensionIdentity = new ExtensionIdentity(extensionPointClass, extensionName); + final ExtensionFactory factory = factoryByExtensionIdentity.get(extensionIdentity); if (factory != null) { factories.add((U) factory); } @@ -131,120 +146,184 @@ void loadPlugins() { private void loadPluginsLocked() { assert lock.isHeldByCurrentThread() : "Lock is not held by current thread"; + LOGGER.debug("Discovering extension points"); + final var extensionPointMetadataLoader = ServiceLoader.load(ExtensionPointMetadata.class); + for (final ExtensionPointMetadata metadata : extensionPointMetadataLoader) { + if (!EXTENSION_POINT_NAME_PATTERN.matcher(metadata.name()).matches()) { + throw new IllegalStateException("%s is not a valid extension point name".formatted(metadata.name())); + } + + LOGGER.debug("Discovered extension point %s".formatted(metadata.name())); + metadataByExtensionPointClass.put(metadata.extensionPointClass(), metadata); + } + LOGGER.debug("Discovering plugins"); - final var pluginServiceLoader = ServiceLoader.load(Plugin.class); - for (final Plugin plugin : pluginServiceLoader) { + final var pluginLoader = ServiceLoader.load(Plugin.class); + for (final Plugin plugin : pluginLoader) { if (!PLUGIN_NAME_PATTERN.matcher(plugin.name()).matches()) { throw new IllegalStateException("%s is not a valid plugin name".formatted(plugin.name())); } - loadProvidersForPlugin(plugin); + loadExtensionsForPlugin(plugin); - LOGGER.debug("Loaded plugin %s".formatted(plugin.name())); + LOGGER.info("Loaded plugin %s".formatted(plugin.name())); loadedPlugins.add(plugin); } - determineDefaultProviders(); + determineDefaultExtensions(); - assertRequiredPlugins(); + assertRequiredExtensionPoints(); } - 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())); + private void loadExtensionsForPlugin(final Plugin plugin) { + final Collection> extensionFactories = plugin.extensionFactories(); + if (extensionFactories == null || extensionFactories.isEmpty()) { + return; + } + + LOGGER.debug("Discovering extensions for plugin %s".formatted(plugin.name())); + for (final ExtensionFactory extensionFactory : extensionFactories) { + if (extensionFactory.extensionName() == null + || !EXTENSION_NAME_PATTERN.matcher(extensionFactory.extensionName()).matches()) { + throw new IllegalStateException("%s is not a valid extension name".formatted(extensionFactory.extensionName())); + } + + if (extensionFactory.extensionClass() == null) { + throw new IllegalStateException("Extension %s from plugin %s does not define an extension class" + .formatted(extensionFactory.extensionName(), plugin.name())); } - 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); + // Prevent plugins from registering their extensions as non-concrete classes. + // The purpose of tracking extension classes is to differentiate them from another, + // which would be impossible if we allowed interfaces or abstract classes. + if (extensionFactory.extensionClass().isInterface() + || Modifier.isAbstract(extensionFactory.extensionClass().getModifiers())) { + throw new IllegalStateException(""" + Class %s of extension %s from plugin %s is either abstract or an interface; \ + Extension classes must be concrete""".formatted(extensionFactory.extensionClass().getName(), + extensionFactory.extensionName(), plugin.name())); + } + + final ExtensionPointMetadata extensionPointMetadata = + assertKnownExtensionPoint(extensionFactory.extensionClass()); + + LOGGER.debug("Discovered extension %s/%s from plugin %s" + .formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), plugin.name())); + final var configRegistry = new ConfigRegistry(extensionPointMetadata.name(), extensionFactory.extensionName()); + final boolean isEnabled = configRegistry.getDeploymentProperty(PROPERTY_EXTENSION_ENABLED).map(Boolean::parseBoolean).orElse(true); if (!isEnabled) { - LOGGER.debug("Provider %s for plugin %s is disabled; Skipping".formatted(providerFactory.providerName(), plugin.name())); + LOGGER.debug("Extension %s/%s from plugin %s is disabled; Skipping" + .formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), plugin.name())); continue; } - if (providerFactory.priority() < PRIORITY_HIGHEST) { + if (extensionFactory.priority() < PRIORITY_HIGHEST) { throw new IllegalStateException(""" - Provider %s has an invalid priority of %d; \ + Extension %s/%s from plugin %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) + """.formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), + plugin.name(), extensionFactory.priority(), PRIORITY_HIGHEST, PRIORITY_LOWEST) ); } - LOGGER.debug("Initializing provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name())); + LOGGER.info("Initializing extension %s/%s from plugin %s" + .formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), plugin.name())); try { - providerFactory.init(configRegistry); + extensionFactory.init(configRegistry); } catch (RuntimeException e) { - LOGGER.warn("Failed to initialize provider %s for plugin %s; Skipping".formatted(providerFactory.providerName(), plugin.name()), e); - continue; + throw new IllegalStateException("Failed to initialize extension %s/%s from plugin %s" + .formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), plugin.name()), e); } - pluginByProviderClass.put(plugin.providerClass(), plugin); - - providerNamesByProviderClass.compute(plugin.providerClass(), (ignored, providerNames) -> { - if (providerNames == null) { - return new HashSet<>(Set.of(providerFactory.providerName())); + factoriesByPlugin.compute(plugin, (ignored, factories) -> { + if (factories == null) { + return new ArrayList<>(List.of(extensionFactory)); } - providerNames.add(providerFactory.providerName()); - return providerNames; + factories.add(extensionFactory); + return factories; }); - final var providerIdentity = new ProviderIdentity(plugin.providerClass(), providerFactory.providerName()); - factoryByProviderKey.put(providerIdentity, providerFactory); + extensionNamesByExtensionPointClass.compute( + extensionPointMetadata.extensionPointClass(), + (ignored, extensionNames) -> { + if (extensionNames == null) { + return new HashSet<>(Set.of(extensionFactory.extensionName())); + } + + extensionNames.add(extensionFactory.extensionName()); + return extensionNames; + } + ); + + final var extensionIdentity = new ExtensionIdentity( + extensionPointMetadata.extensionPointClass(), + extensionFactory.extensionName() + ); + factoryByExtensionIdentity.put(extensionIdentity, extensionFactory); } } - private void determineDefaultProviders() { - for (final Class providerClass : providerNamesByProviderClass.keySet()) { - final SortedSet> factories = getFactories(providerClass); + private void determineDefaultExtensions() { + for (final Class extensionPointClass : extensionNamesByExtensionPointClass.keySet()) { + final SortedSet> factories = getFactories(extensionPointClass); if (factories == null || factories.isEmpty()) { - LOGGER.debug("No factories available for provider class %s; Skipping".formatted(providerClass.getName())); + LOGGER.debug("No factories available for extension point class %s; Skipping".formatted(extensionPointClass.getName())); continue; } - final Plugin plugin = pluginByProviderClass.get(providerClass); - if (plugin == null) { + final ExtensionPointMetadata extensionPointMetadata = metadataByExtensionPointClass.get(extensionPointClass); + if (extensionPointMetadata == null) { throw new IllegalStateException(""" - No plugin exists for provider class %s; \ + No metadata exists for extension point class %s; \ This is likely a logic error in the plugin loading procedure\ - """.formatted(providerClass)); + """.formatted(extensionPointClass)); } - 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())); + final ExtensionFactory extensionFactory; + final var defaultProviderConfigKey = new DeploymentConfigKey(extensionPointMetadata.name(), PROPERTY_DEFAULT_EXTENSION); + final String defaultExtensionName = Config.getInstance().getProperty(defaultProviderConfigKey); + if (defaultExtensionName == null) { + LOGGER.warn(""" + No default extension configured for extension point %s; \ + Choosing based on priority""".formatted(extensionPointMetadata.name())); + extensionFactory = factories.first(); + LOGGER.info("Chose extension %s with priority %d as default for extension point %s" + .formatted(extensionFactory.extensionName(), extensionFactory.priority(), extensionPointMetadata.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)) + LOGGER.info("Using configured default extension %s for extension point %s" + .formatted(defaultExtensionName, extensionPointMetadata.name())); + extensionFactory = factories.stream() + .filter(factory -> factory.extensionName().equals(defaultExtensionName)) .findFirst() .orElseThrow(() -> new NoSuchElementException(""" - No provider named %s exists for plugin %s\ - """.formatted(providerName, plugin.name()))); + No extension named %s exists for extension point %s\ + """.formatted(defaultExtensionName, extensionPointMetadata.name()))); } - defaultFactoryByProviderClass.put(providerClass, providerFactory); + defaultFactoryByExtensionPointClass.put(extensionPointClass, extensionFactory); } } - private void assertRequiredPlugins() { - for (final Plugin plugin : loadedPlugins) { - if (!plugin.required()) { + private ExtensionPointMetadata assertKnownExtensionPoint(final Class concreteExtensionClass) { + for (final Class knownExtensionPoint : metadataByExtensionPointClass.keySet()) { + if (knownExtensionPoint.isAssignableFrom(concreteExtensionClass)) { + return metadataByExtensionPointClass.get(knownExtensionPoint); + } + } + + throw new IllegalStateException("Extension %s does not implement any known extension point" + .formatted(concreteExtensionClass.getName())); + } + + private void assertRequiredExtensionPoints() { + for (final ExtensionPointMetadata metadata : metadataByExtensionPointClass.values()) { + if (!metadata.required()) { continue; } - if (getFactory(plugin.providerClass()) == null) { - throw new IllegalStateException("Plugin %s is required, but no provider is active".formatted(plugin.name())); + if (getFactory(metadata.extensionPointClass()) == null) { + throw new IllegalStateException("Extension point %s is required, but no extension is active".formatted(metadata.name())); } } } @@ -253,10 +332,10 @@ void unloadPlugins() { lock.lock(); try { unloadPluginsLocked(); - defaultFactoryByProviderClass.clear(); - factoryByProviderKey.clear(); - providerNamesByProviderClass.clear(); - pluginByProviderClass.clear(); + defaultFactoryByExtensionPointClass.clear(); + factoryByExtensionIdentity.clear(); + extensionNamesByExtensionPointClass.clear(); + metadataByExtensionPointClass.clear(); loadedPlugins.clear(); } finally { lock.unlock(); @@ -266,16 +345,29 @@ void unloadPlugins() { 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())); + // Unload plugins in reverse order in which they were loaded. + for (final Plugin plugin : loadedPlugins.reversed()) { + LOGGER.info("Unloading plugin %s".formatted(plugin.name())); + + final List> factories = factoriesByPlugin.get(plugin); + if (factories == null || factories.isEmpty()) { + LOGGER.debug("No extensions were loaded for plugin %s; Skipping".formatted(plugin.name())); + continue; + } + + // Close factories in reverse order in which they were initialized. + for (final ExtensionFactory extensionFactory : factories.reversed()) { + final ExtensionPointMetadata extensionPointMetadata = + assertKnownExtensionPoint(extensionFactory.extensionClass()); - for (ProviderFactory providerFactory : getFactories(plugin.providerClass())) { - LOGGER.debug("Closing provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name())); + LOGGER.info("Closing extension %s/%s for plugin %s" + .formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), plugin.name())); try { - providerFactory.close(); + extensionFactory.close(); } catch (RuntimeException e) { - LOGGER.warn("Failed to close provider %s for plugin %s".formatted(providerFactory.providerName(), plugin.name()), e); + LOGGER.warn("Failed to close extension %s/%s for plugin %s" + .formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), plugin.name()), e); } } diff --git a/src/main/java/org/dependencytrack/resources/v1/PluginResource.java b/src/main/java/org/dependencytrack/resources/v1/PluginResource.java deleted file mode 100644 index 02c5e380b..000000000 --- a/src/main/java/org/dependencytrack/resources/v1/PluginResource.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * 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.auth.PermissionRequired; -import alpine.server.resources.AlpineResource; -import io.swagger.annotations.Api; -import io.swagger.annotations.ApiOperation; -import io.swagger.annotations.ApiResponse; -import io.swagger.annotations.ApiResponses; -import io.swagger.annotations.Authorization; -import org.dependencytrack.auth.Permissions; -import org.dependencytrack.plugin.PluginManager; -import org.dependencytrack.plugin.Provider; -import org.dependencytrack.plugin.ProviderFactory; -import org.dependencytrack.resources.v1.vo.LoadedPluginListResponseItem; - -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import java.util.List; -import java.util.SortedSet; - -@Path("/v1/plugin") -@Api(value = "plugin", authorizations = @Authorization(value = "X-Api-Key")) -public class PluginResource extends AlpineResource { - - @GET - @Produces(MediaType.APPLICATION_JSON) - @ApiOperation( - value = "Returns a list of all loaded plugins", - response = LoadedPluginListResponseItem.class, - responseContainer = "List", - notes = "

Requires permission SYSTEM_CONFIGURATION

" - ) - @ApiResponses(value = { - @ApiResponse(code = 401, message = "Unauthorized") - }) - @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) - public Response getAllLoadedPlugins() { - final var providerManager = PluginManager.getInstance(); - - final 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 deleted file mode 100644 index 1288a2f8e..000000000 --- a/src/main/java/org/dependencytrack/resources/v1/vo/LoadedPluginListResponseItem.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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/resources/META-INF/services/org.dependencytrack.plugin.ExtensionPointMetadata b/src/main/resources/META-INF/services/org.dependencytrack.plugin.ExtensionPointMetadata new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/org/dependencytrack/PersistenceCapableTest.java b/src/test/java/org/dependencytrack/PersistenceCapableTest.java index 022a6aeec..609efc29c 100644 --- a/src/test/java/org/dependencytrack/PersistenceCapableTest.java +++ b/src/test/java/org/dependencytrack/PersistenceCapableTest.java @@ -49,8 +49,6 @@ public static void init() { postgresContainer = new PostgresTestContainer(); postgresContainer.start(); - - PluginManagerTestUtil.loadPlugins(); } @Before @@ -61,10 +59,14 @@ public void before() throws Exception { qm = new QueryManager(); this.kafkaMockProducer = (MockProducer) KafkaProducerInitializer.getProducer(); + + PluginManagerTestUtil.loadPlugins(); } @After public void after() { + PluginManagerTestUtil.unloadPlugins(); + // PersistenceManager will refuse to close when there's an active transaction // that was neither committed nor rolled back. Unfortunately some areas of the // code base can leave such a broken state behind if they run into unexpected @@ -80,8 +82,6 @@ public void after() { @AfterClass public static void tearDownClass() { - PluginManagerTestUtil.unloadPlugins(); - if (postgresContainer != null) { postgresContainer.stopWhenNotReusing(); } diff --git a/src/test/java/org/dependencytrack/ResourceTest.java b/src/test/java/org/dependencytrack/ResourceTest.java index 2af1d619e..582d34c20 100644 --- a/src/test/java/org/dependencytrack/ResourceTest.java +++ b/src/test/java/org/dependencytrack/ResourceTest.java @@ -64,7 +64,6 @@ 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"; @@ -107,8 +106,6 @@ public static void init() { postgresContainer = new PostgresTestContainer(); postgresContainer.start(); - - PluginManagerTestUtil.loadPlugins(); } @Before @@ -118,6 +115,7 @@ public void before() throws Exception { // Add a test user and team with API key. Optional if this is used, but its available to all tests. this.qm = new QueryManager(); + PluginManagerTestUtil.loadPlugins(); this.kafkaMockProducer = (MockProducer) KafkaProducerInitializer.getProducer(); team = qm.createTeam("Test Users", true); this.apiKey = team.getApiKeys().get(0).getKey(); @@ -125,6 +123,8 @@ public void before() throws Exception { @After public void after() { + PluginManagerTestUtil.unloadPlugins(); + // PersistenceManager will refuse to close when there's an active transaction // that was neither committed nor rolled back. Unfortunately some areas of the // code base can leave such a broken state behind if they run into unexpected @@ -140,8 +140,6 @@ public void after() { @AfterClass public static void tearDownClass() { - PluginManagerTestUtil.unloadPlugins(); - 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 index 37d3d86a4..7b339d01e 100644 --- a/src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java +++ b/src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java @@ -36,8 +36,8 @@ public class ConfigRegistryTest extends PersistenceCapableTest { @Test public void testGetRuntimeProperty() { qm.createConfigProperty( - /* groupName */ "plugin", - /* propertyName */ "foo.provider.bar.baz", + /* groupName */ "foo", + /* propertyName */ "extension.bar.baz", /* propertyValue */ "qux", PropertyType.STRING, /* description */ null @@ -57,7 +57,7 @@ public void testGetRuntimePropertyThatDoesNotExist() { @Test public void testDeploymentProperty() { - environmentVariables.set("FOO_PROVIDER_BAR_BAZ", "qux"); + environmentVariables.set("FOO_EXTENSION_BAR_BAZ", "qux"); final var configRegistry = new ConfigRegistry("foo", "bar"); final Optional optionalProperty = configRegistry.getDeploymentProperty("baz"); assertThat(optionalProperty).contains("qux"); diff --git a/src/test/java/org/dependencytrack/plugin/DummyPlugin.java b/src/test/java/org/dependencytrack/plugin/DummyPlugin.java index ffb523e5d..cc46c2db1 100644 --- a/src/test/java/org/dependencytrack/plugin/DummyPlugin.java +++ b/src/test/java/org/dependencytrack/plugin/DummyPlugin.java @@ -18,26 +18,19 @@ */ package org.dependencytrack.plugin; +import java.util.Collection; +import java.util.List; + public class DummyPlugin implements Plugin { @Override public String name() { - return "dummy"; - } - - @Override - public boolean required() { - return false; - } - - @Override - public Class> providerFactoryClass() { - return DummyProviderFactory.class; + return "dummy123"; } @Override - public Class providerClass() { - return DummyProvider.class; + public Collection> extensionFactories() { + return List.of(new DummyTestExtensionFactory()); } } diff --git a/src/test/java/org/dependencytrack/plugin/DummyTestExtension.java b/src/test/java/org/dependencytrack/plugin/DummyTestExtension.java new file mode 100644 index 000000000..1f60fcd82 --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/DummyTestExtension.java @@ -0,0 +1,38 @@ +/* + * 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 DummyTestExtension implements TestExtensionPoint { + + static final String NAME = "dummy"; + + private final String runtimeConfigValue; + private final String deploymentConfigValue; + + DummyTestExtension(final String runtimeConfigValue, final String deploymentConfigValue) { + this.runtimeConfigValue = runtimeConfigValue; + this.deploymentConfigValue = deploymentConfigValue; + } + + @Override + public String test() { + return "%s-%s".formatted(runtimeConfigValue, deploymentConfigValue); + } + +} diff --git a/src/test/java/org/dependencytrack/plugin/DummyTestExtensionFactory.java b/src/test/java/org/dependencytrack/plugin/DummyTestExtensionFactory.java new file mode 100644 index 000000000..88d9e5e23 --- /dev/null +++ b/src/test/java/org/dependencytrack/plugin/DummyTestExtensionFactory.java @@ -0,0 +1,53 @@ +/* + * 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 DummyTestExtensionFactory implements ExtensionFactory { + + private ConfigRegistry configRegistry; + + @Override + public String extensionName() { + return DummyTestExtension.NAME; + } + + @Override + public Class extensionClass() { + return DummyTestExtension.class; + } + + @Override + public int priority() { + return PRIORITY_LOWEST; + } + + @Override + public void init(final ConfigRegistry configRegistry) { + this.configRegistry = configRegistry; + } + + @Override + public DummyTestExtension create() { + return new DummyTestExtension( + configRegistry.getRuntimeProperty("foo").orElse(null), + configRegistry.getDeploymentProperty("bar").orElse(null) + ); + } + +} diff --git a/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java b/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java index c29a7f959..a51588070 100644 --- a/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java +++ b/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java @@ -18,10 +18,14 @@ */ package org.dependencytrack.plugin; +import alpine.model.IConfigProperty; import org.dependencytrack.PersistenceCapableTest; +import org.junit.Rule; import org.junit.Test; +import org.junit.contrib.java.lang.system.EnvironmentVariables; import java.util.List; +import java.util.NoSuchElementException; import java.util.SortedSet; import static org.assertj.core.api.Assertions.assertThat; @@ -29,41 +33,78 @@ public class PluginManagerTest extends PersistenceCapableTest { - interface InvalidProvider extends Provider { + @Rule + public EnvironmentVariables environmentVariables = new EnvironmentVariables(); + + interface UnknownExtensionPoint extends ExtensionPoint { } @Test public void testGetLoadedPlugins() { final List loadedPlugins = PluginManager.getInstance().getLoadedPlugins(); - assertThat(loadedPlugins).hasSize(1); + assertThat(loadedPlugins).satisfiesExactly(plugin -> assertThat(plugin.name()).isEqualTo("dummy123")); assertThat(loadedPlugins).isUnmodifiable(); } + @Test + public void testGetExtension() { + final TestExtensionPoint extension = + PluginManager.getInstance().getExtension(TestExtensionPoint.class); + assertThat(extension).isNotNull(); + assertThat(extension.test()).isEqualTo("null-null"); + } + + @Test + public void testGetExtensionWithConfig() { + qm.createConfigProperty( + /* groupName */ "test", + /* propertyName */ "extension.dummy.foo", + /* propertyValue */ "baz", + IConfigProperty.PropertyType.STRING, + /* description */ null + ); + + environmentVariables.set("TEST_EXTENSION_DUMMY_BAR", "qux"); + + final TestExtensionPoint extension = + PluginManager.getInstance().getExtension(TestExtensionPoint.class); + assertThat(extension).isNotNull(); + assertThat(extension.test()).isEqualTo("baz-qux"); + } + + @Test + public void testGetExtensionWithImplementationClass() { + final DummyTestExtension extension = + PluginManager.getInstance().getExtension(DummyTestExtension.class); + assertThat(extension).isNull(); + } + @Test public void testGetFactory() { - final ProviderFactory factory = - PluginManager.getInstance().getFactory(DummyProvider.class); - assertThat(factory).isNotNull(); + final ExtensionFactory factory = + PluginManager.getInstance().getFactory(TestExtensionPoint.class); + assertThat(factory).isExactlyInstanceOf(DummyTestExtensionFactory.class); } @Test - public void testGetFactoryForInvalidProvider() { - final ProviderFactory factory = - PluginManager.getInstance().getFactory(InvalidProvider.class); + public void testGetFactoryForUnknownExtensionPoint() { + final ExtensionFactory factory = + PluginManager.getInstance().getFactory(UnknownExtensionPoint.class); assertThat(factory).isNull(); } @Test public void testGetFactories() { - final SortedSet> factories = - PluginManager.getInstance().getFactories(DummyProvider.class); - assertThat(factories).hasSize(1); + final SortedSet> factories = + PluginManager.getInstance().getFactories(TestExtensionPoint.class); + assertThat(factories).satisfiesExactly(factory -> + assertThat(factory).isExactlyInstanceOf(DummyTestExtensionFactory.class)); } @Test - public void testGetFactoriesForInvalidProvider() { - final SortedSet> factories = - PluginManager.getInstance().getFactories(InvalidProvider.class); + public void testGetFactoriesForUnknownExtensionPoint() { + final SortedSet> factories = + PluginManager.getInstance().getFactories(UnknownExtensionPoint.class); assertThat(factories).isEmpty(); } @@ -74,4 +115,30 @@ public void testLoadPluginsRepeatedly() { .withMessage("Plugins were already loaded; Unload them first"); } + @Test + public void testDisabledExtension() { + final PluginManager pluginManager = PluginManager.getInstance(); + + pluginManager.unloadPlugins(); + + environmentVariables.set("TEST_EXTENSION_DUMMY_ENABLED", "false"); + + pluginManager.loadPlugins(); + + assertThat(pluginManager.getExtension(TestExtensionPoint.class)).isNull(); + } + + @Test + public void testDefaultExtensionNotLoaded() { + final PluginManager pluginManager = PluginManager.getInstance(); + + pluginManager.unloadPlugins(); + + environmentVariables.set("TEST_DEFAULT_EXTENSION", "does.not.exist"); + + assertThatExceptionOfType(NoSuchElementException.class) + .isThrownBy(pluginManager::loadPlugins) + .withMessage("No extension named does.not.exist exists for extension point test"); + } + } \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/plugin/DummyProvider.java b/src/test/java/org/dependencytrack/plugin/TestExtensionPoint.java similarity index 90% rename from src/test/java/org/dependencytrack/plugin/DummyProvider.java rename to src/test/java/org/dependencytrack/plugin/TestExtensionPoint.java index dea4a3cea..5ec4da826 100644 --- a/src/test/java/org/dependencytrack/plugin/DummyProvider.java +++ b/src/test/java/org/dependencytrack/plugin/TestExtensionPoint.java @@ -18,5 +18,8 @@ */ package org.dependencytrack.plugin; -public interface DummyProvider extends Provider { +public interface TestExtensionPoint extends ExtensionPoint { + + String test(); + } diff --git a/src/test/java/org/dependencytrack/plugin/TestDummyProviderFactory.java b/src/test/java/org/dependencytrack/plugin/TestExtensionPointMetadata.java similarity index 72% rename from src/test/java/org/dependencytrack/plugin/TestDummyProviderFactory.java rename to src/test/java/org/dependencytrack/plugin/TestExtensionPointMetadata.java index 4ee7c1cac..697659e23 100644 --- a/src/test/java/org/dependencytrack/plugin/TestDummyProviderFactory.java +++ b/src/test/java/org/dependencytrack/plugin/TestExtensionPointMetadata.java @@ -18,26 +18,21 @@ */ package org.dependencytrack.plugin; -public class TestDummyProviderFactory implements DummyProviderFactory { +public class TestExtensionPointMetadata implements ExtensionPointMetadata { @Override - public String providerName() { + public String name() { return "test"; } @Override - public int priority() { - return 0; + public boolean required() { + return false; } @Override - public void init(final ConfigRegistry ignored) { - // Nothing to do. - } - - @Override - public DummyProvider create() { - return null; + public Class extensionPointClass() { + return TestExtensionPoint.class; } } diff --git a/src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java deleted file mode 100644 index 0277a1127..000000000 --- a/src/test/java/org/dependencytrack/resources/v1/PluginResourceTest.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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": "test", - "name": "dummy", - "providers": [ - "test" - ] - } - ] - """); - } - -} \ No newline at end of file diff --git a/src/test/resources/META-INF/services/org.dependencytrack.plugin.DummyProviderFactory b/src/test/resources/META-INF/services/org.dependencytrack.plugin.DummyProviderFactory deleted file mode 100644 index 2d760f271..000000000 --- a/src/test/resources/META-INF/services/org.dependencytrack.plugin.DummyProviderFactory +++ /dev/null @@ -1 +0,0 @@ -org.dependencytrack.plugin.TestDummyProviderFactory \ No newline at end of file diff --git a/src/test/resources/META-INF/services/org.dependencytrack.plugin.ExtensionPointMetadata b/src/test/resources/META-INF/services/org.dependencytrack.plugin.ExtensionPointMetadata new file mode 100644 index 000000000..368d79d71 --- /dev/null +++ b/src/test/resources/META-INF/services/org.dependencytrack.plugin.ExtensionPointMetadata @@ -0,0 +1 @@ +org.dependencytrack.plugin.TestExtensionPointMetadata \ No newline at end of file From 8f3b2ed6810e26ee420dead3139f0c61d2430660 Mon Sep 17 00:00:00 2001 From: nscuro Date: Mon, 29 Jul 2024 22:45:26 +0200 Subject: [PATCH 3/5] Separate plugin API from implementation Making it easier to eventually move the API to a separate module altogether. Signed-off-by: nscuro --- ...gRegistry.java => ConfigRegistryImpl.java} | 15 +++---- .../dependencytrack/plugin/PluginManager.java | 12 ++++-- .../plugin/api/ConfigRegistry.java | 40 +++++++++++++++++++ .../plugin/{ => api}/ExtensionFactory.java | 2 +- .../plugin/{ => api}/ExtensionPoint.java | 2 +- .../{ => api}/ExtensionPointMetadata.java | 2 +- .../plugin/{ => api}/Plugin.java | 2 +- ...cytrack.plugin.api.ExtensionPointMetadata} | 0 ... => org.dependencytrack.plugin.api.Plugin} | 0 ...yTest.java => ConfigRegistryImplTest.java} | 11 ++--- .../dependencytrack/plugin/DummyPlugin.java | 6 ++- .../plugin/DummyTestExtensionFactory.java | 3 ++ .../plugin/PluginManagerTest.java | 5 ++- .../plugin/TestExtensionPoint.java | 2 + .../plugin/TestExtensionPointMetadata.java | 2 + ...cytrack.plugin.api.ExtensionPointMetadata} | 0 ... => org.dependencytrack.plugin.api.Plugin} | 0 17 files changed, 79 insertions(+), 25 deletions(-) rename src/main/java/org/dependencytrack/plugin/{ConfigRegistry.java => ConfigRegistryImpl.java} (88%) create mode 100644 src/main/java/org/dependencytrack/plugin/api/ConfigRegistry.java rename src/main/java/org/dependencytrack/plugin/{ => api}/ExtensionFactory.java (97%) rename src/main/java/org/dependencytrack/plugin/{ => api}/ExtensionPoint.java (96%) rename src/main/java/org/dependencytrack/plugin/{ => api}/ExtensionPointMetadata.java (96%) rename src/main/java/org/dependencytrack/plugin/{ => api}/Plugin.java (96%) rename src/main/resources/META-INF/services/{org.dependencytrack.plugin.ExtensionPointMetadata => org.dependencytrack.plugin.api.ExtensionPointMetadata} (100%) rename src/main/resources/META-INF/services/{org.dependencytrack.plugin.Plugin => org.dependencytrack.plugin.api.Plugin} (100%) rename src/test/java/org/dependencytrack/plugin/{ConfigRegistryTest.java => ConfigRegistryImplTest.java} (83%) rename src/test/resources/META-INF/services/{org.dependencytrack.plugin.ExtensionPointMetadata => org.dependencytrack.plugin.api.ExtensionPointMetadata} (100%) rename src/test/resources/META-INF/services/{org.dependencytrack.plugin.Plugin => org.dependencytrack.plugin.api.Plugin} (100%) diff --git a/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java b/src/main/java/org/dependencytrack/plugin/ConfigRegistryImpl.java similarity index 88% rename from src/main/java/org/dependencytrack/plugin/ConfigRegistry.java rename to src/main/java/org/dependencytrack/plugin/ConfigRegistryImpl.java index aa2e4f4f3..cc6a2e254 100644 --- a/src/main/java/org/dependencytrack/plugin/ConfigRegistry.java +++ b/src/main/java/org/dependencytrack/plugin/ConfigRegistryImpl.java @@ -19,6 +19,7 @@ package org.dependencytrack.plugin; import alpine.Config; +import org.dependencytrack.plugin.api.ExtensionPoint; import java.util.Optional; @@ -44,20 +45,17 @@ * * @since 5.6.0 */ -public class ConfigRegistry { +class ConfigRegistryImpl implements org.dependencytrack.plugin.api.ConfigRegistry { private final String extensionPointName; private final String extensionName; - public ConfigRegistry(final String extensionPointName, final String extensionName) { + public ConfigRegistryImpl(final String extensionPointName, final String extensionName) { this.extensionPointName = requireNonNull(extensionPointName); this.extensionName = requireNonNull(extensionName); } - /** - * @param propertyName Name of the runtime property. - * @return An {@link Optional} holding the property value, or {@link Optional#empty()}. - */ + @Override public Optional getRuntimeProperty(final String propertyName) { final String namespacedPropertyName = "extension.%s.%s".formatted(extensionName, propertyName); @@ -73,10 +71,7 @@ public Optional getRuntimeProperty(final String propertyName) { .findOne()); } - /** - * @param propertyName Name of the deployment property. - * @return An {@link Optional} holding the property value, or {@link Optional#empty()}. - */ + @Override public Optional getDeploymentProperty(final String propertyName) { final var key = new DeploymentConfigKey(extensionPointName, extensionName, propertyName); return Optional.ofNullable(Config.getInstance().getProperty(key)); diff --git a/src/main/java/org/dependencytrack/plugin/PluginManager.java b/src/main/java/org/dependencytrack/plugin/PluginManager.java index efa9c7e50..442c4ab20 100644 --- a/src/main/java/org/dependencytrack/plugin/PluginManager.java +++ b/src/main/java/org/dependencytrack/plugin/PluginManager.java @@ -20,7 +20,11 @@ import alpine.Config; import alpine.common.logging.Logger; -import org.dependencytrack.plugin.ConfigRegistry.DeploymentConfigKey; +import org.dependencytrack.plugin.ConfigRegistryImpl.DeploymentConfigKey; +import org.dependencytrack.plugin.api.ExtensionFactory; +import org.dependencytrack.plugin.api.ExtensionPoint; +import org.dependencytrack.plugin.api.ExtensionPointMetadata; +import org.dependencytrack.plugin.api.Plugin; import java.lang.reflect.Modifier; import java.util.ArrayList; @@ -39,8 +43,8 @@ import java.util.concurrent.locks.ReentrantLock; import java.util.regex.Pattern; -import static org.dependencytrack.plugin.ExtensionFactory.PRIORITY_HIGHEST; -import static org.dependencytrack.plugin.ExtensionFactory.PRIORITY_LOWEST; +import static org.dependencytrack.plugin.api.ExtensionFactory.PRIORITY_HIGHEST; +import static org.dependencytrack.plugin.api.ExtensionFactory.PRIORITY_LOWEST; /** * @since 5.6.0 @@ -209,7 +213,7 @@ private void loadExtensionsForPlugin(final Plugin plugin) { LOGGER.debug("Discovered extension %s/%s from plugin %s" .formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), plugin.name())); - final var configRegistry = new ConfigRegistry(extensionPointMetadata.name(), extensionFactory.extensionName()); + final var configRegistry = new ConfigRegistryImpl(extensionPointMetadata.name(), extensionFactory.extensionName()); final boolean isEnabled = configRegistry.getDeploymentProperty(PROPERTY_EXTENSION_ENABLED).map(Boolean::parseBoolean).orElse(true); if (!isEnabled) { LOGGER.debug("Extension %s/%s from plugin %s is disabled; Skipping" diff --git a/src/main/java/org/dependencytrack/plugin/api/ConfigRegistry.java b/src/main/java/org/dependencytrack/plugin/api/ConfigRegistry.java new file mode 100644 index 000000000..616598f53 --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/api/ConfigRegistry.java @@ -0,0 +1,40 @@ +/* + * 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.api; + +import java.util.Optional; + +/** + * @since 5.6.0 + */ +public interface ConfigRegistry { + + /** + * @param propertyName Name of the runtime property. + * @return An {@link Optional} holding the property value, or {@link Optional#empty()}. + */ + Optional getRuntimeProperty(final String propertyName); + + /** + * @param propertyName Name of the deployment property. + * @return An {@link Optional} holding the property value, or {@link Optional#empty()}. + */ + Optional getDeploymentProperty(final String propertyName); + +} diff --git a/src/main/java/org/dependencytrack/plugin/ExtensionFactory.java b/src/main/java/org/dependencytrack/plugin/api/ExtensionFactory.java similarity index 97% rename from src/main/java/org/dependencytrack/plugin/ExtensionFactory.java rename to src/main/java/org/dependencytrack/plugin/api/ExtensionFactory.java index d75327be1..9b18387bc 100644 --- a/src/main/java/org/dependencytrack/plugin/ExtensionFactory.java +++ b/src/main/java/org/dependencytrack/plugin/api/ExtensionFactory.java @@ -16,7 +16,7 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright (c) OWASP Foundation. All Rights Reserved. */ -package org.dependencytrack.plugin; +package org.dependencytrack.plugin.api; import java.io.Closeable; diff --git a/src/main/java/org/dependencytrack/plugin/ExtensionPoint.java b/src/main/java/org/dependencytrack/plugin/api/ExtensionPoint.java similarity index 96% rename from src/main/java/org/dependencytrack/plugin/ExtensionPoint.java rename to src/main/java/org/dependencytrack/plugin/api/ExtensionPoint.java index 475bf86aa..819490d04 100644 --- a/src/main/java/org/dependencytrack/plugin/ExtensionPoint.java +++ b/src/main/java/org/dependencytrack/plugin/api/ExtensionPoint.java @@ -16,7 +16,7 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright (c) OWASP Foundation. All Rights Reserved. */ -package org.dependencytrack.plugin; +package org.dependencytrack.plugin.api; import java.io.Closeable; diff --git a/src/main/java/org/dependencytrack/plugin/ExtensionPointMetadata.java b/src/main/java/org/dependencytrack/plugin/api/ExtensionPointMetadata.java similarity index 96% rename from src/main/java/org/dependencytrack/plugin/ExtensionPointMetadata.java rename to src/main/java/org/dependencytrack/plugin/api/ExtensionPointMetadata.java index 58056b20f..1cca5568d 100644 --- a/src/main/java/org/dependencytrack/plugin/ExtensionPointMetadata.java +++ b/src/main/java/org/dependencytrack/plugin/api/ExtensionPointMetadata.java @@ -16,7 +16,7 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright (c) OWASP Foundation. All Rights Reserved. */ -package org.dependencytrack.plugin; +package org.dependencytrack.plugin.api; /** * @since 5.6.0 diff --git a/src/main/java/org/dependencytrack/plugin/Plugin.java b/src/main/java/org/dependencytrack/plugin/api/Plugin.java similarity index 96% rename from src/main/java/org/dependencytrack/plugin/Plugin.java rename to src/main/java/org/dependencytrack/plugin/api/Plugin.java index bca4f5368..f377efd25 100644 --- a/src/main/java/org/dependencytrack/plugin/Plugin.java +++ b/src/main/java/org/dependencytrack/plugin/api/Plugin.java @@ -16,7 +16,7 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright (c) OWASP Foundation. All Rights Reserved. */ -package org.dependencytrack.plugin; +package org.dependencytrack.plugin.api; import java.util.Collection; diff --git a/src/main/resources/META-INF/services/org.dependencytrack.plugin.ExtensionPointMetadata b/src/main/resources/META-INF/services/org.dependencytrack.plugin.api.ExtensionPointMetadata similarity index 100% rename from src/main/resources/META-INF/services/org.dependencytrack.plugin.ExtensionPointMetadata rename to src/main/resources/META-INF/services/org.dependencytrack.plugin.api.ExtensionPointMetadata diff --git a/src/main/resources/META-INF/services/org.dependencytrack.plugin.Plugin b/src/main/resources/META-INF/services/org.dependencytrack.plugin.api.Plugin similarity index 100% rename from src/main/resources/META-INF/services/org.dependencytrack.plugin.Plugin rename to src/main/resources/META-INF/services/org.dependencytrack.plugin.api.Plugin diff --git a/src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java b/src/test/java/org/dependencytrack/plugin/ConfigRegistryImplTest.java similarity index 83% rename from src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java rename to src/test/java/org/dependencytrack/plugin/ConfigRegistryImplTest.java index 7b339d01e..c96d46bfd 100644 --- a/src/test/java/org/dependencytrack/plugin/ConfigRegistryTest.java +++ b/src/test/java/org/dependencytrack/plugin/ConfigRegistryImplTest.java @@ -20,6 +20,7 @@ import alpine.model.IConfigProperty.PropertyType; import org.dependencytrack.PersistenceCapableTest; +import org.dependencytrack.plugin.api.ConfigRegistry; import org.junit.Rule; import org.junit.Test; import org.junit.contrib.java.lang.system.EnvironmentVariables; @@ -28,7 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat; -public class ConfigRegistryTest extends PersistenceCapableTest { +public class ConfigRegistryImplTest extends PersistenceCapableTest { @Rule public EnvironmentVariables environmentVariables = new EnvironmentVariables(); @@ -43,14 +44,14 @@ public void testGetRuntimeProperty() { /* description */ null ); - final var configRegistry = new ConfigRegistry("foo", "bar"); + final ConfigRegistry configRegistry = new ConfigRegistryImpl("foo", "bar"); final Optional optionalProperty = configRegistry.getRuntimeProperty("baz"); assertThat(optionalProperty).contains("qux"); } @Test public void testGetRuntimePropertyThatDoesNotExist() { - final var configRegistry = new ConfigRegistry("foo", "bar"); + final ConfigRegistry configRegistry = new ConfigRegistryImpl("foo", "bar"); final Optional optionalProperty = configRegistry.getRuntimeProperty("baz"); assertThat(optionalProperty).isNotPresent(); } @@ -58,14 +59,14 @@ public void testGetRuntimePropertyThatDoesNotExist() { @Test public void testDeploymentProperty() { environmentVariables.set("FOO_EXTENSION_BAR_BAZ", "qux"); - final var configRegistry = new ConfigRegistry("foo", "bar"); + final ConfigRegistry configRegistry = new ConfigRegistryImpl("foo", "bar"); final Optional optionalProperty = configRegistry.getDeploymentProperty("baz"); assertThat(optionalProperty).contains("qux"); } @Test public void testDeploymentPropertyThatDoesNotExist() { - final var configRegistry = new ConfigRegistry("foo", "bar"); + final ConfigRegistry configRegistry = new ConfigRegistryImpl("foo", "bar"); final Optional optionalProperty = configRegistry.getDeploymentProperty("baz"); assertThat(optionalProperty).isNotPresent(); } diff --git a/src/test/java/org/dependencytrack/plugin/DummyPlugin.java b/src/test/java/org/dependencytrack/plugin/DummyPlugin.java index cc46c2db1..a556fa4dc 100644 --- a/src/test/java/org/dependencytrack/plugin/DummyPlugin.java +++ b/src/test/java/org/dependencytrack/plugin/DummyPlugin.java @@ -18,6 +18,10 @@ */ package org.dependencytrack.plugin; +import org.dependencytrack.plugin.api.ExtensionFactory; +import org.dependencytrack.plugin.api.ExtensionPoint; +import org.dependencytrack.plugin.api.Plugin; + import java.util.Collection; import java.util.List; @@ -25,7 +29,7 @@ public class DummyPlugin implements Plugin { @Override public String name() { - return "dummy123"; + return "dummy"; } @Override diff --git a/src/test/java/org/dependencytrack/plugin/DummyTestExtensionFactory.java b/src/test/java/org/dependencytrack/plugin/DummyTestExtensionFactory.java index 88d9e5e23..eb5884feb 100644 --- a/src/test/java/org/dependencytrack/plugin/DummyTestExtensionFactory.java +++ b/src/test/java/org/dependencytrack/plugin/DummyTestExtensionFactory.java @@ -18,6 +18,9 @@ */ package org.dependencytrack.plugin; +import org.dependencytrack.plugin.api.ConfigRegistry; +import org.dependencytrack.plugin.api.ExtensionFactory; + public class DummyTestExtensionFactory implements ExtensionFactory { private ConfigRegistry configRegistry; diff --git a/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java b/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java index a51588070..d9311e176 100644 --- a/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java +++ b/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java @@ -20,6 +20,9 @@ import alpine.model.IConfigProperty; import org.dependencytrack.PersistenceCapableTest; +import org.dependencytrack.plugin.api.ExtensionFactory; +import org.dependencytrack.plugin.api.ExtensionPoint; +import org.dependencytrack.plugin.api.Plugin; import org.junit.Rule; import org.junit.Test; import org.junit.contrib.java.lang.system.EnvironmentVariables; @@ -42,7 +45,7 @@ interface UnknownExtensionPoint extends ExtensionPoint { @Test public void testGetLoadedPlugins() { final List loadedPlugins = PluginManager.getInstance().getLoadedPlugins(); - assertThat(loadedPlugins).satisfiesExactly(plugin -> assertThat(plugin.name()).isEqualTo("dummy123")); + assertThat(loadedPlugins).satisfiesExactly(plugin -> assertThat(plugin.name()).isEqualTo("dummy")); assertThat(loadedPlugins).isUnmodifiable(); } diff --git a/src/test/java/org/dependencytrack/plugin/TestExtensionPoint.java b/src/test/java/org/dependencytrack/plugin/TestExtensionPoint.java index 5ec4da826..2eb611d39 100644 --- a/src/test/java/org/dependencytrack/plugin/TestExtensionPoint.java +++ b/src/test/java/org/dependencytrack/plugin/TestExtensionPoint.java @@ -18,6 +18,8 @@ */ package org.dependencytrack.plugin; +import org.dependencytrack.plugin.api.ExtensionPoint; + public interface TestExtensionPoint extends ExtensionPoint { String test(); diff --git a/src/test/java/org/dependencytrack/plugin/TestExtensionPointMetadata.java b/src/test/java/org/dependencytrack/plugin/TestExtensionPointMetadata.java index 697659e23..25746cafe 100644 --- a/src/test/java/org/dependencytrack/plugin/TestExtensionPointMetadata.java +++ b/src/test/java/org/dependencytrack/plugin/TestExtensionPointMetadata.java @@ -18,6 +18,8 @@ */ package org.dependencytrack.plugin; +import org.dependencytrack.plugin.api.ExtensionPointMetadata; + public class TestExtensionPointMetadata implements ExtensionPointMetadata { @Override diff --git a/src/test/resources/META-INF/services/org.dependencytrack.plugin.ExtensionPointMetadata b/src/test/resources/META-INF/services/org.dependencytrack.plugin.api.ExtensionPointMetadata similarity index 100% rename from src/test/resources/META-INF/services/org.dependencytrack.plugin.ExtensionPointMetadata rename to src/test/resources/META-INF/services/org.dependencytrack.plugin.api.ExtensionPointMetadata diff --git a/src/test/resources/META-INF/services/org.dependencytrack.plugin.Plugin b/src/test/resources/META-INF/services/org.dependencytrack.plugin.api.Plugin similarity index 100% rename from src/test/resources/META-INF/services/org.dependencytrack.plugin.Plugin rename to src/test/resources/META-INF/services/org.dependencytrack.plugin.api.Plugin From 6e14bc581c39f28238090916ce541d7531e10178 Mon Sep 17 00:00:00 2001 From: nscuro Date: Mon, 29 Jul 2024 23:54:07 +0200 Subject: [PATCH 4/5] Improve logging in `PluginManager` Plugin and extension names are not necessarily unique, but in combination with their classes, they should be. So always include both the name and class in log statements to avoid any ambiguity. Make use of MDC to ensure consistent inclusion of contextual information in log statement. Signed-off-by: nscuro --- .../org/dependencytrack/common/MdcKeys.java | 5 + .../plugin/ConfigRegistryImpl.java | 3 +- .../plugin/PluginInitializer.java | 5 +- .../dependencytrack/plugin/PluginManager.java | 299 +++++++++++------- .../dependencytrack/plugin/api/Plugin.java | 5 - .../dependencytrack/plugin/DummyPlugin.java | 5 - .../plugin/PluginManagerTest.java | 6 +- 7 files changed, 189 insertions(+), 139 deletions(-) diff --git a/src/main/java/org/dependencytrack/common/MdcKeys.java b/src/main/java/org/dependencytrack/common/MdcKeys.java index 34fcb40bd..d9c300f8c 100644 --- a/src/main/java/org/dependencytrack/common/MdcKeys.java +++ b/src/main/java/org/dependencytrack/common/MdcKeys.java @@ -29,10 +29,15 @@ public final class MdcKeys { public static final String MDC_BOM_UPLOAD_TOKEN = "bomUploadToken"; public static final String MDC_BOM_VERSION = "bomVersion"; public static final String MDC_COMPONENT_UUID = "componentUuid"; + public static final String MDC_EXTENSION = "extension"; + public static final String MDC_EXTENSION_NAME = "extensionName"; + public static final String MDC_EXTENSION_POINT = "extensionPoint"; + public static final String MDC_EXTENSION_POINT_NAME = "extensionPointName"; public static final String MDC_KAFKA_RECORD_TOPIC = "kafkaRecordTopic"; public static final String MDC_KAFKA_RECORD_PARTITION = "kafkaRecordPartition"; public static final String MDC_KAFKA_RECORD_OFFSET = "kafkaRecordOffset"; public static final String MDC_KAFKA_RECORD_KEY = "kafkaRecordKey"; + public static final String MDC_PLUGIN = "plugin"; public static final String MDC_PROJECT_NAME = "projectName"; public static final String MDC_PROJECT_UUID = "projectUuid"; public static final String MDC_PROJECT_VERSION = "projectVersion"; diff --git a/src/main/java/org/dependencytrack/plugin/ConfigRegistryImpl.java b/src/main/java/org/dependencytrack/plugin/ConfigRegistryImpl.java index cc6a2e254..eb51405fe 100644 --- a/src/main/java/org/dependencytrack/plugin/ConfigRegistryImpl.java +++ b/src/main/java/org/dependencytrack/plugin/ConfigRegistryImpl.java @@ -19,6 +19,7 @@ package org.dependencytrack.plugin; import alpine.Config; +import org.dependencytrack.plugin.api.ConfigRegistry; import org.dependencytrack.plugin.api.ExtensionPoint; import java.util.Optional; @@ -45,7 +46,7 @@ * * @since 5.6.0 */ -class ConfigRegistryImpl implements org.dependencytrack.plugin.api.ConfigRegistry { +class ConfigRegistryImpl implements ConfigRegistry { private final String extensionPointName; private final String extensionName; diff --git a/src/main/java/org/dependencytrack/plugin/PluginInitializer.java b/src/main/java/org/dependencytrack/plugin/PluginInitializer.java index e9ca7f08b..285fe2089 100644 --- a/src/main/java/org/dependencytrack/plugin/PluginInitializer.java +++ b/src/main/java/org/dependencytrack/plugin/PluginInitializer.java @@ -19,9 +19,8 @@ package org.dependencytrack.plugin; import alpine.common.logging.Logger; - -import javax.servlet.ServletContextEvent; -import javax.servlet.ServletContextListener; +import jakarta.servlet.ServletContextEvent; +import jakarta.servlet.ServletContextListener; /** * @since 5.6.0 diff --git a/src/main/java/org/dependencytrack/plugin/PluginManager.java b/src/main/java/org/dependencytrack/plugin/PluginManager.java index 442c4ab20..c7453d3a8 100644 --- a/src/main/java/org/dependencytrack/plugin/PluginManager.java +++ b/src/main/java/org/dependencytrack/plugin/PluginManager.java @@ -25,6 +25,7 @@ import org.dependencytrack.plugin.api.ExtensionPoint; import org.dependencytrack.plugin.api.ExtensionPointMetadata; import org.dependencytrack.plugin.api.Plugin; +import org.slf4j.MDC; import java.lang.reflect.Modifier; import java.util.ArrayList; @@ -33,9 +34,11 @@ import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; +import java.util.SequencedMap; import java.util.ServiceLoader; import java.util.Set; import java.util.SortedSet; @@ -43,6 +46,11 @@ import java.util.concurrent.locks.ReentrantLock; import java.util.regex.Pattern; +import static org.dependencytrack.common.MdcKeys.MDC_EXTENSION; +import static org.dependencytrack.common.MdcKeys.MDC_EXTENSION_NAME; +import static org.dependencytrack.common.MdcKeys.MDC_EXTENSION_POINT; +import static org.dependencytrack.common.MdcKeys.MDC_EXTENSION_POINT_NAME; +import static org.dependencytrack.common.MdcKeys.MDC_PLUGIN; import static org.dependencytrack.plugin.api.ExtensionFactory.PRIORITY_HIGHEST; import static org.dependencytrack.plugin.api.ExtensionFactory.PRIORITY_LOWEST; @@ -51,18 +59,22 @@ */ public class PluginManager { - private record ExtensionIdentity(Class clazz, String name) { + /** + * @param pointClass The {@link Class} of the {@link ExtensionPoint} the extension implements. + * @param name The name of the extension. + */ + private record ExtensionIdentity(Class pointClass, 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 EXTENSION_POINT_NAME_PATTERN = PLUGIN_NAME_PATTERN; - private static final Pattern EXTENSION_NAME_PATTERN = PLUGIN_NAME_PATTERN; + private static final Pattern EXTENSION_POINT_NAME_PATTERN = Pattern.compile("^[a-z0-9.]+$"); + private static final Pattern EXTENSION_NAME_PATTERN = EXTENSION_POINT_NAME_PATTERN; private static final String PROPERTY_EXTENSION_ENABLED = "enabled"; private static final String PROPERTY_DEFAULT_EXTENSION = "default.extension"; private static final PluginManager INSTANCE = new PluginManager(); - private final List loadedPlugins; + private final SequencedMap, Plugin> loadedPluginByClass; + private final Map pluginByExtensionIdentity; private final Map>> factoriesByPlugin; private final Map, ExtensionPointMetadata> metadataByExtensionPointClass; private final Map, Set> extensionNamesByExtensionPointClass; @@ -72,7 +84,8 @@ private record ExtensionIdentity(Class clazz, String n private final ReentrantLock lock; private PluginManager() { - this.loadedPlugins = new ArrayList<>(); + this.loadedPluginByClass = new LinkedHashMap<>(); + this.pluginByExtensionIdentity = new HashMap<>(); this.factoriesByPlugin = new HashMap<>(); this.metadataByExtensionPointClass = new HashMap<>(); this.extensionNamesByExtensionPointClass = new HashMap<>(); @@ -89,7 +102,7 @@ public static PluginManager getInstance() { } public List getLoadedPlugins() { - return List.copyOf(loadedPlugins); + return List.copyOf(loadedPluginByClass.sequencedValues()); } @SuppressWarnings("unchecked") @@ -134,7 +147,7 @@ public > SortedSet ge void loadPlugins() { lock.lock(); try { - if (!loadedPlugins.isEmpty()) { + if (!loadedPluginByClass.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. @@ -164,14 +177,13 @@ private void loadPluginsLocked() { LOGGER.debug("Discovering plugins"); final var pluginLoader = ServiceLoader.load(Plugin.class); for (final Plugin plugin : pluginLoader) { - if (!PLUGIN_NAME_PATTERN.matcher(plugin.name()).matches()) { - throw new IllegalStateException("%s is not a valid plugin name".formatted(plugin.name())); - } - - loadExtensionsForPlugin(plugin); + try (var ignoredMdcPlugin = MDC.putCloseable(MDC_PLUGIN, plugin.getClass().getName())) { + LOGGER.info("Loading plugin"); + loadExtensionsForPlugin(plugin); - LOGGER.info("Loaded plugin %s".formatted(plugin.name())); - loadedPlugins.add(plugin); + LOGGER.info("Plugin loaded successfully"); + loadedPluginByClass.put(plugin.getClass(), plugin); + } } determineDefaultExtensions(); @@ -182,130 +194,159 @@ private void loadPluginsLocked() { private void loadExtensionsForPlugin(final Plugin plugin) { final Collection> extensionFactories = plugin.extensionFactories(); if (extensionFactories == null || extensionFactories.isEmpty()) { + LOGGER.debug("Plugin does not define any extensions; Skipping"); return; } - LOGGER.debug("Discovering extensions for plugin %s".formatted(plugin.name())); for (final ExtensionFactory extensionFactory : extensionFactories) { - if (extensionFactory.extensionName() == null - || !EXTENSION_NAME_PATTERN.matcher(extensionFactory.extensionName()).matches()) { - throw new IllegalStateException("%s is not a valid extension name".formatted(extensionFactory.extensionName())); + if (extensionFactory.extensionName() == null) { + throw new IllegalStateException("%s does not define an extension name" + .formatted(extensionFactory.getClass().getName())); + } + if (!EXTENSION_NAME_PATTERN.matcher(extensionFactory.extensionName()).matches()) { + throw new IllegalStateException("%s defines an invalid extension name: %s" + .formatted(extensionFactory.getClass().getName(), extensionFactory.extensionName())); } - if (extensionFactory.extensionClass() == null) { - throw new IllegalStateException("Extension %s from plugin %s does not define an extension class" - .formatted(extensionFactory.extensionName(), plugin.name())); + throw new IllegalStateException("%s does not define an extension class" + .formatted(extensionFactory.getClass().getName())); } // Prevent plugins from registering their extensions as non-concrete classes. // The purpose of tracking extension classes is to differentiate them from another, // which would be impossible if we allowed interfaces or abstract classes. if (extensionFactory.extensionClass().isInterface() - || Modifier.isAbstract(extensionFactory.extensionClass().getModifiers())) { + || Modifier.isAbstract(extensionFactory.extensionClass().getModifiers())) { throw new IllegalStateException(""" Class %s of extension %s from plugin %s is either abstract or an interface; \ Extension classes must be concrete""".formatted(extensionFactory.extensionClass().getName(), - extensionFactory.extensionName(), plugin.name())); + extensionFactory.extensionName(), MDC.get(MDC_PLUGIN))); } final ExtensionPointMetadata extensionPointMetadata = assertKnownExtensionPoint(extensionFactory.extensionClass()); - LOGGER.debug("Discovered extension %s/%s from plugin %s" - .formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), plugin.name())); - final var configRegistry = new ConfigRegistryImpl(extensionPointMetadata.name(), extensionFactory.extensionName()); - final boolean isEnabled = configRegistry.getDeploymentProperty(PROPERTY_EXTENSION_ENABLED).map(Boolean::parseBoolean).orElse(true); - if (!isEnabled) { - LOGGER.debug("Extension %s/%s from plugin %s is disabled; Skipping" - .formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), plugin.name())); - continue; - } + final String extensionPointClassName = extensionPointMetadata.extensionPointClass().getName(); + final String extensionPointName = extensionPointMetadata.name(); + final String extensionClassName = extensionFactory.extensionClass().getName(); + final String extensionName = extensionFactory.extensionName(); - if (extensionFactory.priority() < PRIORITY_HIGHEST) { - throw new IllegalStateException(""" - Extension %s/%s from plugin %s has an invalid priority of %d; \ - Allowed range is [%d..%d] (highest to lowest priority)\ - """.formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), - plugin.name(), extensionFactory.priority(), PRIORITY_HIGHEST, PRIORITY_LOWEST) - ); - } - - LOGGER.info("Initializing extension %s/%s from plugin %s" - .formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), plugin.name())); - try { - extensionFactory.init(configRegistry); - } catch (RuntimeException e) { - throw new IllegalStateException("Failed to initialize extension %s/%s from plugin %s" - .formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), plugin.name()), e); + try (var ignoredMdcExtensionPointName = MDC.putCloseable(MDC_EXTENSION_POINT_NAME, extensionPointName); + var ignoredMdcExtensionPoint = MDC.putCloseable(MDC_EXTENSION_POINT, extensionPointClassName); + var ignoredMdcExtensionName = MDC.putCloseable(MDC_EXTENSION_NAME, extensionName); + var ignoredMdcExtension = MDC.putCloseable(MDC_EXTENSION, extensionClassName)) { + loadExtension(plugin, extensionFactory, extensionPointMetadata); } + } + } - factoriesByPlugin.compute(plugin, (ignored, factories) -> { - if (factories == null) { - return new ArrayList<>(List.of(extensionFactory)); - } - - factories.add(extensionFactory); - return factories; - }); + private void loadExtension( + final Plugin plugin, + final ExtensionFactory extensionFactory, + final ExtensionPointMetadata extensionPointMetadata + ) { + final var extensionIdentity = new ExtensionIdentity( + extensionPointMetadata.extensionPointClass(), + extensionFactory.extensionName() + ); + + // Prevent the same extension from being loaded from multiple plugins. + if (pluginByExtensionIdentity.containsKey(extensionIdentity)) { + final Plugin conflictingPlugin = pluginByExtensionIdentity.get(extensionIdentity); + throw new IllegalStateException("Extension was already loaded from plugin %s" + .formatted(conflictingPlugin.getClass().getName())); + } - extensionNamesByExtensionPointClass.compute( - extensionPointMetadata.extensionPointClass(), - (ignored, extensionNames) -> { - if (extensionNames == null) { - return new HashSet<>(Set.of(extensionFactory.extensionName())); - } + final var configRegistry = new ConfigRegistryImpl(extensionPointMetadata.name(), extensionIdentity.name()); + final boolean isEnabled = configRegistry.getDeploymentProperty(PROPERTY_EXTENSION_ENABLED).map(Boolean::parseBoolean).orElse(true); + if (!isEnabled) { + LOGGER.debug("Extension is disabled; Skipping"); + return; + } - extensionNames.add(extensionFactory.extensionName()); - return extensionNames; - } + if (extensionFactory.priority() < PRIORITY_HIGHEST) { + throw new IllegalStateException(""" + Extension %s from plugin %s has an invalid priority of %d; \ + Allowed range is [%d..%d] (highest to lowest priority)\ + """.formatted(MDC.get(MDC_EXTENSION), MDC.get(MDC_PLUGIN), + extensionFactory.priority(), PRIORITY_HIGHEST, PRIORITY_LOWEST) ); + } - final var extensionIdentity = new ExtensionIdentity( - extensionPointMetadata.extensionPointClass(), - extensionFactory.extensionName() - ); - factoryByExtensionIdentity.put(extensionIdentity, extensionFactory); + LOGGER.info("Initializing extension"); + try { + extensionFactory.init(configRegistry); + } catch (RuntimeException e) { + throw new IllegalStateException("Failed to initialize extension %s from plugin %s" + .formatted(MDC.get(MDC_EXTENSION), MDC.get(MDC_PLUGIN)), e); } + + factoriesByPlugin.compute(plugin, (ignored, factories) -> { + if (factories == null) { + return new ArrayList<>(List.of(extensionFactory)); + } + + factories.add(extensionFactory); + return factories; + }); + extensionNamesByExtensionPointClass.compute(extensionIdentity.pointClass(), (ignored, extensionNames) -> { + if (extensionNames == null) { + return new HashSet<>(Set.of(extensionFactory.extensionName())); + } + + extensionNames.add(extensionFactory.extensionName()); + return extensionNames; + }); + factoryByExtensionIdentity.put(extensionIdentity, extensionFactory); + pluginByExtensionIdentity.put(extensionIdentity, plugin); } private void determineDefaultExtensions() { for (final Class extensionPointClass : extensionNamesByExtensionPointClass.keySet()) { - final SortedSet> factories = getFactories(extensionPointClass); - if (factories == null || factories.isEmpty()) { - LOGGER.debug("No factories available for extension point class %s; Skipping".formatted(extensionPointClass.getName())); - continue; - } - - final ExtensionPointMetadata extensionPointMetadata = metadataByExtensionPointClass.get(extensionPointClass); + final ExtensionPointMetadata extensionPointMetadata = + metadataByExtensionPointClass.get(extensionPointClass); if (extensionPointMetadata == null) { throw new IllegalStateException(""" - No metadata exists for extension point class %s; \ + No metadata exists for extension point %s; \ This is likely a logic error in the plugin loading procedure\ - """.formatted(extensionPointClass)); + """.formatted(extensionPointClass.getName())); } - final ExtensionFactory extensionFactory; - final var defaultProviderConfigKey = new DeploymentConfigKey(extensionPointMetadata.name(), PROPERTY_DEFAULT_EXTENSION); - final String defaultExtensionName = Config.getInstance().getProperty(defaultProviderConfigKey); - if (defaultExtensionName == null) { - LOGGER.warn(""" - No default extension configured for extension point %s; \ - Choosing based on priority""".formatted(extensionPointMetadata.name())); - extensionFactory = factories.first(); - LOGGER.info("Chose extension %s with priority %d as default for extension point %s" - .formatted(extensionFactory.extensionName(), extensionFactory.priority(), extensionPointMetadata.name())); - } else { - LOGGER.info("Using configured default extension %s for extension point %s" - .formatted(defaultExtensionName, extensionPointMetadata.name())); - extensionFactory = factories.stream() - .filter(factory -> factory.extensionName().equals(defaultExtensionName)) - .findFirst() - .orElseThrow(() -> new NoSuchElementException(""" - No extension named %s exists for extension point %s\ - """.formatted(defaultExtensionName, extensionPointMetadata.name()))); - } + final String extensionPointClassName = extensionPointClass.getName(); + final String extensionPointName = extensionPointMetadata.name(); + + try (var ignoredMdcExtensionPoint = MDC.putCloseable(MDC_EXTENSION_POINT, extensionPointClassName); + var ignoredMdcExtensionPointName = MDC.putCloseable(MDC_EXTENSION_POINT_NAME, extensionPointName)) { + LOGGER.info("Determining default extension"); - defaultFactoryByExtensionPointClass.put(extensionPointClass, extensionFactory); + final SortedSet> factories = getFactories(extensionPointClass); + if (factories == null || factories.isEmpty()) { + LOGGER.warn("No extension available; Skipping"); + continue; + } + + final var defaultExtensionConfigKey = new DeploymentConfigKey(extensionPointMetadata.name(), PROPERTY_DEFAULT_EXTENSION); + final String defaultExtensionName = Config.getInstance().getProperty(defaultExtensionConfigKey); + + final ExtensionFactory extensionFactory; + if (defaultExtensionName == null) { + LOGGER.warn("No default extension configured; Choosing based on priority"); + extensionFactory = factories.first(); + LOGGER.info("Chose extension %s (%s) with priority %d as default" + .formatted(extensionFactory.extensionName(), extensionFactory.extensionClass().getName(), extensionFactory.priority())); + } else { + extensionFactory = factories.stream() + .filter(factory -> factory.extensionName().equals(defaultExtensionName)) + .findFirst() + .orElseThrow(() -> new NoSuchElementException(""" + No extension named %s exists for extension point %s (%s)""" + .formatted(defaultExtensionName, MDC.get(MDC_EXTENSION_POINT_NAME), MDC.get(MDC_EXTENSION_POINT)))); + LOGGER.info("Using extension %s (%s) as default" + .formatted(extensionFactory.extensionName(), extensionFactory.extensionClass().getName())); + } + + defaultFactoryByExtensionPointClass.put(extensionPointClass, extensionFactory); + } } } @@ -327,7 +368,9 @@ private void assertRequiredExtensionPoints() { } if (getFactory(metadata.extensionPointClass()) == null) { - throw new IllegalStateException("Extension point %s is required, but no extension is active".formatted(metadata.name())); + throw new IllegalStateException(""" + Extension point %s (%s) is required, but no extension is active\ + """.formatted(metadata.name(), metadata.extensionPointClass().getName())); } } } @@ -340,7 +383,9 @@ void unloadPlugins() { factoryByExtensionIdentity.clear(); extensionNamesByExtensionPointClass.clear(); metadataByExtensionPointClass.clear(); - loadedPlugins.clear(); + factoriesByPlugin.clear(); + pluginByExtensionIdentity.clear(); + loadedPluginByClass.clear(); } finally { lock.unlock(); } @@ -350,32 +395,40 @@ private void unloadPluginsLocked() { assert lock.isHeldByCurrentThread() : "Lock is not held by current thread"; // Unload plugins in reverse order in which they were loaded. - for (final Plugin plugin : loadedPlugins.reversed()) { - LOGGER.info("Unloading plugin %s".formatted(plugin.name())); + for (final Plugin plugin : loadedPluginByClass.sequencedValues().reversed()) { + try (var ignoredMdcPlugin = MDC.putCloseable(MDC_PLUGIN, plugin.getClass().getName())) { + LOGGER.info("Unloading plugin"); + unloadPlugin(plugin); - final List> factories = factoriesByPlugin.get(plugin); - if (factories == null || factories.isEmpty()) { - LOGGER.debug("No extensions were loaded for plugin %s; Skipping".formatted(plugin.name())); - continue; + LOGGER.info("Plugin unloaded"); } + } + } + + private void unloadPlugin(final Plugin plugin) { + final List> factories = factoriesByPlugin.get(plugin); + if (factories == null || factories.isEmpty()) { + LOGGER.debug("No extensions were loaded; Skipping"); + return; + } - // Close factories in reverse order in which they were initialized. - for (final ExtensionFactory extensionFactory : factories.reversed()) { - final ExtensionPointMetadata extensionPointMetadata = - assertKnownExtensionPoint(extensionFactory.extensionClass()); + // Close factories in reverse order in which they were initialized. + for (final ExtensionFactory extensionFactory : factories.reversed()) { + final ExtensionPointMetadata extensionPointMetadata = + assertKnownExtensionPoint(extensionFactory.extensionClass()); - LOGGER.info("Closing extension %s/%s for plugin %s" - .formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), plugin.name())); + final String extensionPointClassName = extensionPointMetadata.extensionPointClass().getName(); + final String extensionClassName = extensionFactory.extensionClass().getName(); - try { - extensionFactory.close(); - } catch (RuntimeException e) { - LOGGER.warn("Failed to close extension %s/%s for plugin %s" - .formatted(extensionPointMetadata.name(), extensionFactory.extensionName(), plugin.name()), e); - } - } + try (var ignoredMdcExtensionPoint = MDC.putCloseable(MDC_EXTENSION_POINT, extensionPointClassName); + var ignoredMdcExtension = MDC.putCloseable(MDC_EXTENSION, extensionClassName)) { + LOGGER.debug("Closing extension"); + extensionFactory.close(); - LOGGER.debug("Unloaded plugin %s".formatted(plugin.name())); + LOGGER.debug("Extension closed successfully"); + } catch (RuntimeException e) { + LOGGER.warn("Failed to close extension", e); + } } } diff --git a/src/main/java/org/dependencytrack/plugin/api/Plugin.java b/src/main/java/org/dependencytrack/plugin/api/Plugin.java index f377efd25..cb79d7c12 100644 --- a/src/main/java/org/dependencytrack/plugin/api/Plugin.java +++ b/src/main/java/org/dependencytrack/plugin/api/Plugin.java @@ -25,11 +25,6 @@ */ public interface Plugin { - /** - * @return The name of the plugin. - */ - String name(); - /** * @return The {@link ExtensionFactory}s provided by the plugin. */ diff --git a/src/test/java/org/dependencytrack/plugin/DummyPlugin.java b/src/test/java/org/dependencytrack/plugin/DummyPlugin.java index a556fa4dc..41ae71ed4 100644 --- a/src/test/java/org/dependencytrack/plugin/DummyPlugin.java +++ b/src/test/java/org/dependencytrack/plugin/DummyPlugin.java @@ -27,11 +27,6 @@ public class DummyPlugin implements Plugin { - @Override - public String name() { - return "dummy"; - } - @Override public Collection> extensionFactories() { return List.of(new DummyTestExtensionFactory()); diff --git a/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java b/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java index d9311e176..916b1d938 100644 --- a/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java +++ b/src/test/java/org/dependencytrack/plugin/PluginManagerTest.java @@ -45,7 +45,7 @@ interface UnknownExtensionPoint extends ExtensionPoint { @Test public void testGetLoadedPlugins() { final List loadedPlugins = PluginManager.getInstance().getLoadedPlugins(); - assertThat(loadedPlugins).satisfiesExactly(plugin -> assertThat(plugin.name()).isEqualTo("dummy")); + assertThat(loadedPlugins).satisfiesExactly(plugin -> assertThat(plugin).isOfAnyClassIn(DummyPlugin.class)); assertThat(loadedPlugins).isUnmodifiable(); } @@ -141,7 +141,9 @@ public void testDefaultExtensionNotLoaded() { assertThatExceptionOfType(NoSuchElementException.class) .isThrownBy(pluginManager::loadPlugins) - .withMessage("No extension named does.not.exist exists for extension point test"); + .withMessage(""" + No extension named does.not.exist exists for extension point \ + test (org.dependencytrack.plugin.TestExtensionPoint)"""); } } \ No newline at end of file From cd64d167ed465e77b301d736b6f4f4bfb2a8cf58 Mon Sep 17 00:00:00 2001 From: nscuro Date: Mon, 5 Aug 2024 12:51:28 +0200 Subject: [PATCH 5/5] Introduce `ConfigDefinition` for more unified configuration access Signed-off-by: nscuro --- .../plugin/ConfigRegistryImpl.java | 145 ++++++++++++++---- .../dependencytrack/plugin/PluginManager.java | 25 +-- .../plugin/api/ConfigDefinition.java | 34 ++++ .../plugin/api/ConfigRegistry.java | 15 +- .../plugin/api/ConfigSource.java | 32 ++++ .../plugin/ConfigRegistryImplTest.java | 79 ++++++++-- .../plugin/DummyTestExtensionFactory.java | 9 +- 7 files changed, 276 insertions(+), 63 deletions(-) create mode 100644 src/main/java/org/dependencytrack/plugin/api/ConfigDefinition.java create mode 100644 src/main/java/org/dependencytrack/plugin/api/ConfigSource.java diff --git a/src/main/java/org/dependencytrack/plugin/ConfigRegistryImpl.java b/src/main/java/org/dependencytrack/plugin/ConfigRegistryImpl.java index eb51405fe..cb680b37d 100644 --- a/src/main/java/org/dependencytrack/plugin/ConfigRegistryImpl.java +++ b/src/main/java/org/dependencytrack/plugin/ConfigRegistryImpl.java @@ -19,9 +19,16 @@ package org.dependencytrack.plugin; import alpine.Config; +import alpine.model.ConfigProperty; +import alpine.model.IConfigProperty.PropertyType; +import org.apache.commons.lang3.tuple.Pair; +import org.dependencytrack.plugin.api.ConfigDefinition; import org.dependencytrack.plugin.api.ConfigRegistry; import org.dependencytrack.plugin.api.ExtensionPoint; +import org.dependencytrack.util.DebugDataEncryption; +import org.jdbi.v3.core.mapper.reflect.BeanMapper; +import java.util.Objects; import java.util.Optional; import static java.util.Objects.requireNonNull; @@ -51,46 +58,47 @@ class ConfigRegistryImpl implements ConfigRegistry { private final String extensionPointName; private final String extensionName; - public ConfigRegistryImpl(final String extensionPointName, final String extensionName) { - this.extensionPointName = requireNonNull(extensionPointName); - this.extensionName = requireNonNull(extensionName); + private ConfigRegistryImpl(final String extensionPointName, final String extensionName) { + this.extensionPointName = extensionPointName; + this.extensionName = extensionName; } - @Override - public Optional getRuntimeProperty(final String propertyName) { - final String namespacedPropertyName = "extension.%s.%s".formatted(extensionName, propertyName); + /** + * Create a {@link ConfigRegistryImpl} for accessing extension point configuration. + * + * @param extensionPointName Name of the extension point. + * @return A {@link ConfigRegistryImpl} scoped to {@code extensionPointName}. + */ + static ConfigRegistryImpl forExtensionPoint(final String extensionPointName) { + return new ConfigRegistryImpl(requireNonNull(extensionPointName), null); + } - return withJdbiHandle(handle -> handle.createQuery(""" - SELECT "PROPERTYVALUE" - FROM "CONFIGPROPERTY" - WHERE "GROUPNAME" = :extensionPointName - AND "PROPERTYNAME" = :propertyName - """) - .bind("extensionPointName", extensionPointName) - .bind("propertyName", namespacedPropertyName) - .mapTo(String.class) - .findOne()); + /** + * Create a {@link ConfigRegistryImpl} for accessing extension configuration. + * + * @param extensionPointName Name of the extension point. + * @param extensionName Name of the extension. + * @return A {@link ConfigRegistryImpl} scoped to {@code extensionPointName} and {@code extensionName}. + */ + static ConfigRegistryImpl forExtension(final String extensionPointName, final String extensionName) { + return new ConfigRegistryImpl(requireNonNull(extensionPointName), Objects.requireNonNull(extensionName)); } @Override - public Optional getDeploymentProperty(final String propertyName) { - final var key = new DeploymentConfigKey(extensionPointName, extensionName, propertyName); - return Optional.ofNullable(Config.getInstance().getProperty(key)); + public Optional getOptionalValue(final ConfigDefinition config) { + return switch (config.source()) { + case DEPLOYMENT -> getDeploymentConfigValue(config); + case RUNTIME -> getRuntimeConfigValue(config); + case ANY -> getDeploymentConfigValue(config).or(() -> getRuntimeConfigValue(config)); + case null -> throw new IllegalArgumentException("No config source specified"); + }; } - record DeploymentConfigKey(String extensionPointName, String extensionName, String name) implements Config.Key { - - DeploymentConfigKey(final String extensionPointName, final String name) { - this(extensionPointName, null, name); - } + private record DeploymentConfigKey(String name) implements Config.Key { @Override public String getPropertyName() { - if (extensionName == null) { - return "%s.%s".formatted(extensionPointName, name); - } - - return "%s.extension.%s.%s".formatted(extensionPointName, extensionName, name); + return name; } @Override @@ -100,4 +108,83 @@ public Object getDefaultValue() { } + private Optional getDeploymentConfigValue(final ConfigDefinition config) { + final var key = new DeploymentConfigKey(namespacedDeploymentConfigName(config)); + + final String value = Config.getInstance().getProperty(key); + if (value == null) { + if (config.isRequired()) { + throw new IllegalStateException(""" + Config %s is defined as required, but no value has been found\ + """.formatted(config.name())); + } + + return Optional.empty(); + } + + return Optional.of(value); + } + + private Optional getRuntimeConfigValue(final ConfigDefinition config) { + final Pair groupAndName = namespacedRuntimeConfigGroupAndName(config); + final String groupName = groupAndName.getLeft(); + final String propertyName = groupAndName.getRight(); + + final ConfigProperty property = withJdbiHandle(handle -> handle.createQuery(""" + SELECT "PROPERTYVALUE" + , "PROPERTYTYPE" + FROM "CONFIGPROPERTY" + WHERE "GROUPNAME" = :groupName + AND "PROPERTYNAME" = :propertyName + """) + .bind("groupName", groupName) + .bind("propertyName", propertyName) + .map(BeanMapper.of(ConfigProperty.class)) + .findOne() + .orElse(null)); + if (property == null || property.getPropertyValue() == null) { + if (config.isRequired()) { + throw new IllegalStateException(""" + Config %s is defined as required, but no value has been found\ + """.formatted(config.name())); + } + + return Optional.empty(); + } + + if (!config.isSecret()) { + return Optional.of(property.getPropertyValue()); + } + + final boolean isEncrypted = property.getPropertyType() == PropertyType.ENCRYPTEDSTRING; + if (!isEncrypted) { + throw new IllegalStateException(""" + Config %s is defined as secret, but its value is not encrypted\ + """.formatted(config.name())); + } + + try { + final String decryptedValue = DebugDataEncryption.decryptAsString(property.getPropertyValue()); + return Optional.of(decryptedValue); + } catch (Exception e) { + throw new IllegalStateException("Failed to decrypt value of config %s".formatted(config.name()), e); + } + } + + private String namespacedDeploymentConfigName(final ConfigDefinition config) { + if (extensionName == null) { + return "%s.%s".formatted(extensionPointName, config.name()); + } + + return "%s.extension.%s.%s".formatted(extensionPointName, extensionName, config.name()); + } + + private Pair namespacedRuntimeConfigGroupAndName(final ConfigDefinition config) { + if (extensionName == null) { + return Pair.of(extensionPointName, config.name()); + } + + return Pair.of(extensionPointName, "extension.%s.%s".formatted(extensionName, config.name())); + } + } diff --git a/src/main/java/org/dependencytrack/plugin/PluginManager.java b/src/main/java/org/dependencytrack/plugin/PluginManager.java index c7453d3a8..464c03154 100644 --- a/src/main/java/org/dependencytrack/plugin/PluginManager.java +++ b/src/main/java/org/dependencytrack/plugin/PluginManager.java @@ -18,9 +18,9 @@ */ package org.dependencytrack.plugin; -import alpine.Config; import alpine.common.logging.Logger; -import org.dependencytrack.plugin.ConfigRegistryImpl.DeploymentConfigKey; +import org.dependencytrack.plugin.api.ConfigDefinition; +import org.dependencytrack.plugin.api.ConfigSource; import org.dependencytrack.plugin.api.ExtensionFactory; import org.dependencytrack.plugin.api.ExtensionPoint; import org.dependencytrack.plugin.api.ExtensionPointMetadata; @@ -38,6 +38,7 @@ import java.util.List; import java.util.Map; import java.util.NoSuchElementException; +import java.util.Optional; import java.util.SequencedMap; import java.util.ServiceLoader; import java.util.Set; @@ -69,8 +70,8 @@ private record ExtensionIdentity(Class pointClass, Str private static final Logger LOGGER = Logger.getLogger(PluginManager.class); private static final Pattern EXTENSION_POINT_NAME_PATTERN = Pattern.compile("^[a-z0-9.]+$"); private static final Pattern EXTENSION_NAME_PATTERN = EXTENSION_POINT_NAME_PATTERN; - private static final String PROPERTY_EXTENSION_ENABLED = "enabled"; - private static final String PROPERTY_DEFAULT_EXTENSION = "default.extension"; + private static final ConfigDefinition CONFIG_EXTENSION_ENABLED = new ConfigDefinition("enabled", ConfigSource.DEPLOYMENT, false, false); + private static final ConfigDefinition CONFIG_DEFAULT_EXTENSION = new ConfigDefinition("default.extension", ConfigSource.DEPLOYMENT, false, false); private static final PluginManager INSTANCE = new PluginManager(); private final SequencedMap, Plugin> loadedPluginByClass; @@ -216,7 +217,7 @@ private void loadExtensionsForPlugin(final Plugin plugin) { // The purpose of tracking extension classes is to differentiate them from another, // which would be impossible if we allowed interfaces or abstract classes. if (extensionFactory.extensionClass().isInterface() - || Modifier.isAbstract(extensionFactory.extensionClass().getModifiers())) { + || Modifier.isAbstract(extensionFactory.extensionClass().getModifiers())) { throw new IllegalStateException(""" Class %s of extension %s from plugin %s is either abstract or an interface; \ Extension classes must be concrete""".formatted(extensionFactory.extensionClass().getName(), @@ -257,8 +258,8 @@ private void loadExtension( .formatted(conflictingPlugin.getClass().getName())); } - final var configRegistry = new ConfigRegistryImpl(extensionPointMetadata.name(), extensionIdentity.name()); - final boolean isEnabled = configRegistry.getDeploymentProperty(PROPERTY_EXTENSION_ENABLED).map(Boolean::parseBoolean).orElse(true); + final var configRegistry = ConfigRegistryImpl.forExtension(extensionPointMetadata.name(), extensionIdentity.name()); + final boolean isEnabled = configRegistry.getOptionalValue(CONFIG_EXTENSION_ENABLED).map(Boolean::parseBoolean).orElse(true); if (!isEnabled) { LOGGER.debug("Extension is disabled; Skipping"); return; @@ -325,22 +326,22 @@ private void determineDefaultExtensions() { continue; } - final var defaultExtensionConfigKey = new DeploymentConfigKey(extensionPointMetadata.name(), PROPERTY_DEFAULT_EXTENSION); - final String defaultExtensionName = Config.getInstance().getProperty(defaultExtensionConfigKey); + final var configRegistry = ConfigRegistryImpl.forExtensionPoint(extensionPointName); + final Optional defaultExtensionName = configRegistry.getOptionalValue(CONFIG_DEFAULT_EXTENSION); final ExtensionFactory extensionFactory; - if (defaultExtensionName == null) { + if (defaultExtensionName.isEmpty()) { LOGGER.warn("No default extension configured; Choosing based on priority"); extensionFactory = factories.first(); LOGGER.info("Chose extension %s (%s) with priority %d as default" .formatted(extensionFactory.extensionName(), extensionFactory.extensionClass().getName(), extensionFactory.priority())); } else { extensionFactory = factories.stream() - .filter(factory -> factory.extensionName().equals(defaultExtensionName)) + .filter(factory -> factory.extensionName().equals(defaultExtensionName.get())) .findFirst() .orElseThrow(() -> new NoSuchElementException(""" No extension named %s exists for extension point %s (%s)""" - .formatted(defaultExtensionName, MDC.get(MDC_EXTENSION_POINT_NAME), MDC.get(MDC_EXTENSION_POINT)))); + .formatted(defaultExtensionName.get(), MDC.get(MDC_EXTENSION_POINT_NAME), MDC.get(MDC_EXTENSION_POINT)))); LOGGER.info("Using extension %s (%s) as default" .formatted(extensionFactory.extensionName(), extensionFactory.extensionClass().getName())); } diff --git a/src/main/java/org/dependencytrack/plugin/api/ConfigDefinition.java b/src/main/java/org/dependencytrack/plugin/api/ConfigDefinition.java new file mode 100644 index 000000000..e41d0854d --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/api/ConfigDefinition.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.api; + +/** + * @param name Name of the configuration. + * @param source Where the configuration shall be loaded from. + * @param isRequired Whether the configuration is mandatory. + * @param isSecret Whether the configuration is confidential. + * @since 5.6.0 + */ +public record ConfigDefinition( + String name, + ConfigSource source, + boolean isRequired, + boolean isSecret +) { +} diff --git a/src/main/java/org/dependencytrack/plugin/api/ConfigRegistry.java b/src/main/java/org/dependencytrack/plugin/api/ConfigRegistry.java index 616598f53..a43d8022a 100644 --- a/src/main/java/org/dependencytrack/plugin/api/ConfigRegistry.java +++ b/src/main/java/org/dependencytrack/plugin/api/ConfigRegistry.java @@ -18,6 +18,7 @@ */ package org.dependencytrack.plugin.api; +import java.util.NoSuchElementException; import java.util.Optional; /** @@ -25,16 +26,10 @@ */ public interface ConfigRegistry { - /** - * @param propertyName Name of the runtime property. - * @return An {@link Optional} holding the property value, or {@link Optional#empty()}. - */ - Optional getRuntimeProperty(final String propertyName); + Optional getOptionalValue(final ConfigDefinition config); - /** - * @param propertyName Name of the deployment property. - * @return An {@link Optional} holding the property value, or {@link Optional#empty()}. - */ - Optional getDeploymentProperty(final String propertyName); + default String getValue(final ConfigDefinition config) { + return getOptionalValue(config).orElseThrow(NoSuchElementException::new); + } } diff --git a/src/main/java/org/dependencytrack/plugin/api/ConfigSource.java b/src/main/java/org/dependencytrack/plugin/api/ConfigSource.java new file mode 100644 index 000000000..5a10d6e4e --- /dev/null +++ b/src/main/java/org/dependencytrack/plugin/api/ConfigSource.java @@ -0,0 +1,32 @@ +/* + * 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.api; + +/** + * @since 5.6.0 + */ +public enum ConfigSource { + + ANY, + + DEPLOYMENT, + + RUNTIME + +} diff --git a/src/test/java/org/dependencytrack/plugin/ConfigRegistryImplTest.java b/src/test/java/org/dependencytrack/plugin/ConfigRegistryImplTest.java index c96d46bfd..c7450ee2c 100644 --- a/src/test/java/org/dependencytrack/plugin/ConfigRegistryImplTest.java +++ b/src/test/java/org/dependencytrack/plugin/ConfigRegistryImplTest.java @@ -19,8 +19,11 @@ package org.dependencytrack.plugin; import alpine.model.IConfigProperty.PropertyType; +import alpine.security.crypto.DataEncryption; import org.dependencytrack.PersistenceCapableTest; +import org.dependencytrack.plugin.api.ConfigDefinition; import org.dependencytrack.plugin.api.ConfigRegistry; +import org.dependencytrack.plugin.api.ConfigSource; import org.junit.Rule; import org.junit.Test; import org.junit.contrib.java.lang.system.EnvironmentVariables; @@ -28,6 +31,7 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; public class ConfigRegistryImplTest extends PersistenceCapableTest { @@ -35,7 +39,7 @@ public class ConfigRegistryImplTest extends PersistenceCapableTest { public EnvironmentVariables environmentVariables = new EnvironmentVariables(); @Test - public void testGetRuntimeProperty() { + public void testGetRuntimeConfigValue() { qm.createConfigProperty( /* groupName */ "foo", /* propertyName */ "extension.bar.baz", @@ -44,31 +48,86 @@ public void testGetRuntimeProperty() { /* description */ null ); - final ConfigRegistry configRegistry = new ConfigRegistryImpl("foo", "bar"); - final Optional optionalProperty = configRegistry.getRuntimeProperty("baz"); + final ConfigRegistry configRegistry = ConfigRegistryImpl.forExtension("foo", "bar"); + final var configDef = new ConfigDefinition("baz", ConfigSource.RUNTIME, false, false); + final Optional optionalProperty = configRegistry.getOptionalValue(configDef); assertThat(optionalProperty).contains("qux"); } @Test - public void testGetRuntimePropertyThatDoesNotExist() { - final ConfigRegistry configRegistry = new ConfigRegistryImpl("foo", "bar"); - final Optional optionalProperty = configRegistry.getRuntimeProperty("baz"); + public void testGetRuntimeConfigValueThatDoesNotExist() { + final ConfigRegistry configRegistry = ConfigRegistryImpl.forExtension("foo", "bar"); + final var configDef = new ConfigDefinition("baz", ConfigSource.RUNTIME, false, false); + final Optional optionalProperty = configRegistry.getOptionalValue(configDef); assertThat(optionalProperty).isNotPresent(); } + @Test + public void testGetRequiredRuntimeConfigValueThatDoesNotExist() { + final ConfigRegistry configRegistry = ConfigRegistryImpl.forExtension("foo", "bar"); + final var configDef = new ConfigDefinition("baz", ConfigSource.RUNTIME, true, false); + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> configRegistry.getOptionalValue(configDef)) + .withMessage("Config baz is defined as required, but no value has been found"); + } + + @Test + public void testGetSecretRuntimeConfig() throws Exception { + qm.createConfigProperty( + /* groupName */ "foo", + /* propertyName */ "extension.bar.baz", + /* propertyValue */ DataEncryption.encryptAsString("qux"), + PropertyType.ENCRYPTEDSTRING, + /* description */ null + ); + + final ConfigRegistry configRegistry = ConfigRegistryImpl.forExtension("foo", "bar"); + final var configDef = new ConfigDefinition("baz", ConfigSource.RUNTIME, false, true); + final Optional optionalProperty = configRegistry.getOptionalValue(configDef); + assertThat(optionalProperty).contains("qux"); + } + + @Test + public void testGetSecretRuntimeConfigWhenNotEncrypted() { + qm.createConfigProperty( + /* groupName */ "foo", + /* propertyName */ "extension.bar.baz", + /* propertyValue */ "qux", + PropertyType.STRING, + /* description */ null + ); + + final ConfigRegistry configRegistry = ConfigRegistryImpl.forExtension("foo", "bar"); + final var configDef = new ConfigDefinition("baz", ConfigSource.RUNTIME, false, true); + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> configRegistry.getOptionalValue(configDef)) + .withMessage("Config baz is defined as secret, but its value is not encrypted"); + } + @Test public void testDeploymentProperty() { environmentVariables.set("FOO_EXTENSION_BAR_BAZ", "qux"); - final ConfigRegistry configRegistry = new ConfigRegistryImpl("foo", "bar"); - final Optional optionalProperty = configRegistry.getDeploymentProperty("baz"); + final ConfigRegistry configRegistry = ConfigRegistryImpl.forExtension("foo", "bar"); + final var configDef = new ConfigDefinition("baz", ConfigSource.DEPLOYMENT, false, false); + final Optional optionalProperty = configRegistry.getOptionalValue(configDef); assertThat(optionalProperty).contains("qux"); } @Test public void testDeploymentPropertyThatDoesNotExist() { - final ConfigRegistry configRegistry = new ConfigRegistryImpl("foo", "bar"); - final Optional optionalProperty = configRegistry.getDeploymentProperty("baz"); + final ConfigRegistry configRegistry = ConfigRegistryImpl.forExtension("foo", "bar"); + final var configDef = new ConfigDefinition("baz", ConfigSource.DEPLOYMENT, false, false); + final Optional optionalProperty = configRegistry.getOptionalValue(configDef); assertThat(optionalProperty).isNotPresent(); } + @Test + public void testGetRequiredDeploymentConfigValueThatDoesNotExist() { + final ConfigRegistry configRegistry = ConfigRegistryImpl.forExtension("foo", "bar"); + final var configDef = new ConfigDefinition("baz", ConfigSource.DEPLOYMENT, true, false); + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> configRegistry.getOptionalValue(configDef)) + .withMessage("Config baz is defined as required, but no value has been found"); + } + } \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/plugin/DummyTestExtensionFactory.java b/src/test/java/org/dependencytrack/plugin/DummyTestExtensionFactory.java index eb5884feb..30c55f3fb 100644 --- a/src/test/java/org/dependencytrack/plugin/DummyTestExtensionFactory.java +++ b/src/test/java/org/dependencytrack/plugin/DummyTestExtensionFactory.java @@ -18,11 +18,16 @@ */ package org.dependencytrack.plugin; +import org.dependencytrack.plugin.api.ConfigDefinition; import org.dependencytrack.plugin.api.ConfigRegistry; +import org.dependencytrack.plugin.api.ConfigSource; import org.dependencytrack.plugin.api.ExtensionFactory; public class DummyTestExtensionFactory implements ExtensionFactory { + private static final ConfigDefinition CONFIG_FOO = new ConfigDefinition("foo", ConfigSource.RUNTIME, false, false); + private static final ConfigDefinition CONFIG_BAR = new ConfigDefinition("bar", ConfigSource.DEPLOYMENT, false, false); + private ConfigRegistry configRegistry; @Override @@ -48,8 +53,8 @@ public void init(final ConfigRegistry configRegistry) { @Override public DummyTestExtension create() { return new DummyTestExtension( - configRegistry.getRuntimeProperty("foo").orElse(null), - configRegistry.getDeploymentProperty("bar").orElse(null) + configRegistry.getOptionalValue(CONFIG_FOO).orElse(null), + configRegistry.getOptionalValue(CONFIG_BAR).orElse(null) ); }