diff --git a/CHANGELOG.md b/CHANGELOG.md index 045968989cc..20833b7f2f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ - Expose bad block events via the BesuEvents plugin API [#6848](https://github.com/hyperledger/besu/pull/6848) - Add RPC errors metric [#6919](https://github.com/hyperledger/besu/pull/6919/) - Add `rlp decode` subcommand to decode IBFT/QBFT extraData to validator list [#6895](https://github.com/hyperledger/besu/pull/6895) +- Allow users to specify which plugins are registered [#6700](https://github.com/hyperledger/besu/pull/6700) + ### Bug fixes - Fix txpool dump/restore race condition [#6665](https://github.com/hyperledger/besu/pull/6665) diff --git a/acceptance-tests/dsl/src/main/java/org/hyperledger/besu/tests/acceptance/dsl/node/ThreadBesuNodeRunner.java b/acceptance-tests/dsl/src/main/java/org/hyperledger/besu/tests/acceptance/dsl/node/ThreadBesuNodeRunner.java index 5ab01326d70..e0805d57283 100644 --- a/acceptance-tests/dsl/src/main/java/org/hyperledger/besu/tests/acceptance/dsl/node/ThreadBesuNodeRunner.java +++ b/acceptance-tests/dsl/src/main/java/org/hyperledger/besu/tests/acceptance/dsl/node/ThreadBesuNodeRunner.java @@ -31,6 +31,7 @@ import org.hyperledger.besu.ethereum.api.ApiConfiguration; import org.hyperledger.besu.ethereum.api.graphql.GraphQLConfiguration; import org.hyperledger.besu.ethereum.core.ImmutableMiningParameters; +import org.hyperledger.besu.ethereum.core.plugins.PluginConfiguration; import org.hyperledger.besu.ethereum.eth.EthProtocolConfiguration; import org.hyperledger.besu.ethereum.eth.sync.SynchronizerConfiguration; import org.hyperledger.besu.ethereum.eth.transactions.ImmutableTransactionPoolConfiguration; @@ -141,7 +142,8 @@ private BesuPluginContextImpl buildPluginContext( besuPluginContext.addService(PermissioningService.class, permissioningService); besuPluginContext.addService(PrivacyPluginService.class, new PrivacyPluginServiceImpl()); - besuPluginContext.registerPlugins(pluginsPath); + besuPluginContext.registerPlugins(new PluginConfiguration(pluginsPath)); + commandLine.parseArgs(node.getConfiguration().getExtraCLIOptions().toArray(new String[0])); // register built-in plugins diff --git a/acceptance-tests/test-plugins/src/test/java/org/hyperledger/besu/services/BesuPluginContextImplTest.java b/acceptance-tests/test-plugins/src/test/java/org/hyperledger/besu/services/BesuPluginContextImplTest.java index 57797462739..c266eaf66a1 100644 --- a/acceptance-tests/test-plugins/src/test/java/org/hyperledger/besu/services/BesuPluginContextImplTest.java +++ b/acceptance-tests/test-plugins/src/test/java/org/hyperledger/besu/services/BesuPluginContextImplTest.java @@ -16,24 +16,31 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.hyperledger.besu.ethereum.core.plugins.PluginConfiguration; +import org.hyperledger.besu.ethereum.core.plugins.PluginInfo; import org.hyperledger.besu.plugin.BesuPlugin; +import org.hyperledger.besu.tests.acceptance.plugins.TestBesuEventsPlugin; import org.hyperledger.besu.tests.acceptance.plugins.TestPicoCLIPlugin; -import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.List; +import java.util.NoSuchElementException; import java.util.Optional; -import org.assertj.core.api.Assertions; import org.assertj.core.api.ThrowableAssert; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; public class BesuPluginContextImplTest { + private static final Path DEFAULT_PLUGIN_DIRECTORY = Paths.get("."); + private BesuPluginContextImpl contextImpl; @BeforeAll public static void createFakePluginDir() throws IOException { @@ -49,16 +56,20 @@ public void clearTestPluginState() { System.clearProperty("testPicoCLIPlugin.testOption"); } + @BeforeEach + void setup() { + contextImpl = new BesuPluginContextImpl(); + } + @Test public void verifyEverythingGoesSmoothly() { - final BesuPluginContextImpl contextImpl = new BesuPluginContextImpl(); + assertThat(contextImpl.getRegisteredPlugins()).isEmpty(); + contextImpl.registerPlugins(new PluginConfiguration(DEFAULT_PLUGIN_DIRECTORY)); + assertThat(contextImpl.getRegisteredPlugins()).isNotEmpty(); - assertThat(contextImpl.getPlugins()).isEmpty(); - contextImpl.registerPlugins(new File(".").toPath()); - assertThat(contextImpl.getPlugins()).isNotEmpty(); - - final Optional testPluginOptional = findTestPlugin(contextImpl.getPlugins()); - Assertions.assertThat(testPluginOptional).isPresent(); + final Optional testPluginOptional = + findTestPlugin(contextImpl.getRegisteredPlugins(), TestPicoCLIPlugin.class); + assertThat(testPluginOptional).isPresent(); final TestPicoCLIPlugin testPicoCLIPlugin = testPluginOptional.get(); assertThat(testPicoCLIPlugin.getState()).isEqualTo("registered"); @@ -72,33 +83,34 @@ public void verifyEverythingGoesSmoothly() { @Test public void registrationErrorsHandledSmoothly() { - final BesuPluginContextImpl contextImpl = new BesuPluginContextImpl(); System.setProperty("testPicoCLIPlugin.testOption", "FAILREGISTER"); - assertThat(contextImpl.getPlugins()).isEmpty(); - contextImpl.registerPlugins(new File(".").toPath()); - assertThat(contextImpl.getPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); + assertThat(contextImpl.getRegisteredPlugins()).isEmpty(); + contextImpl.registerPlugins(new PluginConfiguration(DEFAULT_PLUGIN_DIRECTORY)); + assertThat(contextImpl.getRegisteredPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); contextImpl.beforeExternalServices(); - assertThat(contextImpl.getPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); + assertThat(contextImpl.getRegisteredPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); contextImpl.startPlugins(); - assertThat(contextImpl.getPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); + assertThat(contextImpl.getRegisteredPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); contextImpl.stopPlugins(); - assertThat(contextImpl.getPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); + assertThat(contextImpl.getRegisteredPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); } @Test public void startErrorsHandledSmoothly() { - final BesuPluginContextImpl contextImpl = new BesuPluginContextImpl(); System.setProperty("testPicoCLIPlugin.testOption", "FAILSTART"); - assertThat(contextImpl.getPlugins()).isEmpty(); - contextImpl.registerPlugins(new File(".").toPath()); - assertThat(contextImpl.getPlugins()).extracting("class").contains(TestPicoCLIPlugin.class); + assertThat(contextImpl.getRegisteredPlugins()).isEmpty(); + contextImpl.registerPlugins(new PluginConfiguration(DEFAULT_PLUGIN_DIRECTORY)); + assertThat(contextImpl.getRegisteredPlugins()) + .extracting("class") + .contains(TestPicoCLIPlugin.class); - final Optional testPluginOptional = findTestPlugin(contextImpl.getPlugins()); + final Optional testPluginOptional = + findTestPlugin(contextImpl.getRegisteredPlugins(), TestPicoCLIPlugin.class); assertThat(testPluginOptional).isPresent(); final TestPicoCLIPlugin testPicoCLIPlugin = testPluginOptional.get(); assertThat(testPicoCLIPlugin.getState()).isEqualTo("registered"); @@ -106,22 +118,24 @@ public void startErrorsHandledSmoothly() { contextImpl.beforeExternalServices(); contextImpl.startPlugins(); assertThat(testPicoCLIPlugin.getState()).isEqualTo("failstart"); - assertThat(contextImpl.getPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); + assertThat(contextImpl.getRegisteredPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); contextImpl.stopPlugins(); - assertThat(contextImpl.getPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); + assertThat(contextImpl.getRegisteredPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); } @Test public void stopErrorsHandledSmoothly() { - final BesuPluginContextImpl contextImpl = new BesuPluginContextImpl(); System.setProperty("testPicoCLIPlugin.testOption", "FAILSTOP"); - assertThat(contextImpl.getPlugins()).isEmpty(); - contextImpl.registerPlugins(new File(".").toPath()); - assertThat(contextImpl.getPlugins()).extracting("class").contains(TestPicoCLIPlugin.class); + assertThat(contextImpl.getRegisteredPlugins()).isEmpty(); + contextImpl.registerPlugins(new PluginConfiguration(DEFAULT_PLUGIN_DIRECTORY)); + assertThat(contextImpl.getRegisteredPlugins()) + .extracting("class") + .contains(TestPicoCLIPlugin.class); - final Optional testPluginOptional = findTestPlugin(contextImpl.getPlugins()); + final Optional testPluginOptional = + findTestPlugin(contextImpl.getRegisteredPlugins(), TestPicoCLIPlugin.class); assertThat(testPluginOptional).isPresent(); final TestPicoCLIPlugin testPicoCLIPlugin = testPluginOptional.get(); assertThat(testPicoCLIPlugin.getState()).isEqualTo("registered"); @@ -136,9 +150,8 @@ public void stopErrorsHandledSmoothly() { @Test public void lifecycleExceptions() throws Throwable { - final BesuPluginContextImpl contextImpl = new BesuPluginContextImpl(); final ThrowableAssert.ThrowingCallable registerPlugins = - () -> contextImpl.registerPlugins(new File(".").toPath()); + () -> contextImpl.registerPlugins(new PluginConfiguration(DEFAULT_PLUGIN_DIRECTORY)); assertThatExceptionOfType(IllegalStateException.class).isThrownBy(contextImpl::startPlugins); assertThatExceptionOfType(IllegalStateException.class).isThrownBy(contextImpl::stopPlugins); @@ -158,9 +171,74 @@ public void lifecycleExceptions() throws Throwable { assertThatExceptionOfType(IllegalStateException.class).isThrownBy(contextImpl::stopPlugins); } - private Optional findTestPlugin(final List plugins) { + @Test + public void shouldRegisterAllPluginsWhenNoPluginsOption() { + final PluginConfiguration config = createConfigurationForAllPlugins(); + + assertThat(contextImpl.getRegisteredPlugins()).isEmpty(); + contextImpl.registerPlugins(config); + final Optional testPluginOptional = + findTestPlugin(contextImpl.getRegisteredPlugins(), TestPicoCLIPlugin.class); + assertThat(testPluginOptional).isPresent(); + final TestPicoCLIPlugin testPicoCLIPlugin = testPluginOptional.get(); + assertThat(testPicoCLIPlugin.getState()).isEqualTo("registered"); + } + + @Test + public void shouldRegisterOnlySpecifiedPluginWhenPluginsOptionIsSet() { + final PluginConfiguration config = createConfigurationForSpecificPlugin("TestPicoCLIPlugin"); + + assertThat(contextImpl.getRegisteredPlugins()).isEmpty(); + contextImpl.registerPlugins(config); + + final Optional requestedPlugin = + findTestPlugin(contextImpl.getRegisteredPlugins(), TestPicoCLIPlugin.class); + + assertThat(requestedPlugin).isPresent(); + assertThat(requestedPlugin.get().getState()).isEqualTo("registered"); + + final Optional nonRequestedPlugin = + findTestPlugin(contextImpl.getRegisteredPlugins(), TestBesuEventsPlugin.class); + + assertThat(nonRequestedPlugin).isEmpty(); + } + + @Test + public void shouldNotRegisterUnspecifiedPluginsWhenPluginsOptionIsSet() { + final PluginConfiguration config = createConfigurationForSpecificPlugin("TestPicoCLIPlugin"); + assertThat(contextImpl.getRegisteredPlugins()).isEmpty(); + contextImpl.registerPlugins(config); + + final Optional nonRequestedPlugin = + findTestPlugin(contextImpl.getRegisteredPlugins(), TestBesuEventsPlugin.class); + assertThat(nonRequestedPlugin).isEmpty(); + } + + @Test + void shouldThrowExceptionIfExplicitlySpecifiedPluginNotFound() { + PluginConfiguration config = createConfigurationForSpecificPlugin("NonExistentPlugin"); + + String exceptionMessage = + assertThrows(NoSuchElementException.class, () -> contextImpl.registerPlugins(config)) + .getMessage(); + final String expectedMessage = + "The following requested plugins were not found: NonExistentPlugin"; + assertThat(exceptionMessage).isEqualTo(expectedMessage); + assertThat(contextImpl.getRegisteredPlugins()).isEmpty(); + } + + private PluginConfiguration createConfigurationForAllPlugins() { + return new PluginConfiguration(null, DEFAULT_PLUGIN_DIRECTORY); + } + + private PluginConfiguration createConfigurationForSpecificPlugin(final String pluginName) { + return new PluginConfiguration(List.of(new PluginInfo(pluginName)), DEFAULT_PLUGIN_DIRECTORY); + } + + private Optional findTestPlugin( + final List plugins, final Class type) { return plugins.stream() - .filter(p -> p instanceof TestPicoCLIPlugin) + .filter(p -> type.equals(p.getClass())) .map(p -> (TestPicoCLIPlugin) p) .findFirst(); } diff --git a/besu/src/main/java/org/hyperledger/besu/cli/BesuCommand.java b/besu/src/main/java/org/hyperledger/besu/cli/BesuCommand.java index 9648122de3b..2d147be517b 100644 --- a/besu/src/main/java/org/hyperledger/besu/cli/BesuCommand.java +++ b/besu/src/main/java/org/hyperledger/besu/cli/BesuCommand.java @@ -58,6 +58,7 @@ import org.hyperledger.besu.cli.options.stable.NodePrivateKeyFileOption; import org.hyperledger.besu.cli.options.stable.P2PTLSConfigOptions; import org.hyperledger.besu.cli.options.stable.PermissionsOptions; +import org.hyperledger.besu.cli.options.stable.PluginsConfigurationOptions; import org.hyperledger.besu.cli.options.stable.RpcWebsocketOptions; import org.hyperledger.besu.cli.options.unstable.ChainPruningOptions; import org.hyperledger.besu.cli.options.unstable.DnsOptions; @@ -85,7 +86,7 @@ import org.hyperledger.besu.cli.subcommands.storage.StorageSubCommand; import org.hyperledger.besu.cli.util.BesuCommandCustomFactory; import org.hyperledger.besu.cli.util.CommandLineUtils; -import org.hyperledger.besu.cli.util.ConfigOptionSearchAndRunHandler; +import org.hyperledger.besu.cli.util.ConfigDefaultValueProviderStrategy; import org.hyperledger.besu.cli.util.VersionProvider; import org.hyperledger.besu.components.BesuComponent; import org.hyperledger.besu.config.CheckpointConfigOptions; @@ -121,6 +122,7 @@ import org.hyperledger.besu.ethereum.core.MiningParametersMetrics; import org.hyperledger.besu.ethereum.core.PrivacyParameters; import org.hyperledger.besu.ethereum.core.VersionMetadata; +import org.hyperledger.besu.ethereum.core.plugins.PluginConfiguration; import org.hyperledger.besu.ethereum.eth.sync.SyncMode; import org.hyperledger.besu.ethereum.eth.sync.SynchronizerConfiguration; import org.hyperledger.besu.ethereum.eth.transactions.ImmutableTransactionPoolConfiguration; @@ -876,6 +878,10 @@ static class MetricsOptionGroup { @Mixin private PkiBlockCreationOptions pkiBlockCreationOptions; + // Plugins Configuration Option Group + @CommandLine.ArgGroup(validate = false) + PluginsConfigurationOptions pluginsConfigurationOptions = new PluginsConfigurationOptions(); + private EthNetworkConfig ethNetworkConfig; private JsonRpcConfiguration jsonRpcConfiguration; private JsonRpcConfiguration engineJsonRpcConfiguration; @@ -1020,6 +1026,16 @@ protected BesuCommand( * @param args arguments to Besu command * @return success or failure exit code. */ + /** + * Parses command line arguments and configures the application accordingly. + * + * @param resultHandler The strategy to handle the execution result. + * @param parameterExceptionHandler Handler for exceptions related to command line parameters. + * @param executionExceptionHandler Handler for exceptions during command execution. + * @param in The input stream for commands. + * @param args The command line arguments. + * @return The execution result status code. + */ public int parse( final IExecutionStrategy resultHandler, final BesuParameterExceptionHandler parameterExceptionHandler, @@ -1027,9 +1043,24 @@ public int parse( final InputStream in, final String... args) { - toCommandLine(); + initializeCommandLineSettings(in); - // use terminal width for usage message + // Create the execution strategy chain. + final IExecutionStrategy executeTask = createExecuteTask(resultHandler); + final IExecutionStrategy pluginRegistrationTask = createPluginRegistrationTask(executeTask); + final IExecutionStrategy setDefaultValueProviderTask = + createDefaultValueProviderTask(pluginRegistrationTask); + + // 1- Config default value provider + // 2- Register plugins + // 3- Execute command + return executeCommandLine( + setDefaultValueProviderTask, parameterExceptionHandler, executionExceptionHandler, args); + } + + private void initializeCommandLineSettings(final InputStream in) { + toCommandLine(); + // Automatically adjust the width of usage messages to the terminal width. commandLine.getCommandSpec().usageMessage().autoWidth(true); handleStableOptions(); @@ -1037,11 +1068,51 @@ public int parse( registerConverters(); handleUnstableOptions(); preparePlugins(); + } - final int exitCode = - parse(resultHandler, executionExceptionHandler, parameterExceptionHandler, args); + private IExecutionStrategy createExecuteTask(final IExecutionStrategy nextStep) { + return parseResult -> { + commandLine.setExecutionStrategy(nextStep); + // At this point we don't allow unmatched options since plugins were already registered + commandLine.setUnmatchedArgumentsAllowed(false); + return commandLine.execute(parseResult.originalArgs().toArray(new String[0])); + }; + } - return exitCode; + private IExecutionStrategy createPluginRegistrationTask(final IExecutionStrategy nextStep) { + return parseResult -> { + PluginConfiguration configuration = + PluginsConfigurationOptions.fromCommandLine(parseResult.commandSpec().commandLine()); + besuPluginContext.registerPlugins(configuration); + commandLine.setExecutionStrategy(nextStep); + return commandLine.execute(parseResult.originalArgs().toArray(new String[0])); + }; + } + + private IExecutionStrategy createDefaultValueProviderTask(final IExecutionStrategy nextStep) { + return new ConfigDefaultValueProviderStrategy(nextStep, environment); + } + + /** + * Executes the command line with the provided execution strategy and exception handlers. + * + * @param executionStrategy The execution strategy to use. + * @param args The command line arguments. + * @return The execution result status code. + */ + private int executeCommandLine( + final IExecutionStrategy executionStrategy, + final BesuParameterExceptionHandler parameterExceptionHandler, + final BesuExecutionExceptionHandler executionExceptionHandler, + final String... args) { + return commandLine + .setExecutionStrategy(executionStrategy) + .setParameterExceptionHandler(parameterExceptionHandler) + .setExecutionExceptionHandler(executionExceptionHandler) + // As this happens before the plugins registration and plugins can add options, we must + // allow unmatched options + .setUnmatchedArgumentsAllowed(true) + .execute(args); } /** Used by Dagger to parse all options into a commandline instance. */ @@ -1208,8 +1279,6 @@ private void preparePlugins() { rocksDBPlugin.register(besuPluginContext); new InMemoryStoragePlugin().register(besuPluginContext); - besuPluginContext.registerPlugins(pluginsDir()); - metricCategoryRegistry .getMetricCategories() .forEach(metricCategoryConverter::addRegistryCategory); @@ -1235,26 +1304,6 @@ public KeyPair loadKeyPair(final File nodePrivateKeyFile) { return KeyPairUtil.loadKeyPair(resolveNodePrivateKeyFile(nodePrivateKeyFile)); } - private int parse( - final CommandLine.IExecutionStrategy resultHandler, - final BesuExecutionExceptionHandler besuExecutionExceptionHandler, - final BesuParameterExceptionHandler besuParameterExceptionHandler, - final String... args) { - // Create a handler that will search for a config file option and use it for - // default values - // and eventually it will run regular parsing of the remaining options. - - final ConfigOptionSearchAndRunHandler configParsingHandler = - new ConfigOptionSearchAndRunHandler( - resultHandler, besuParameterExceptionHandler, environment); - - return commandLine - .setExecutionStrategy(configParsingHandler) - .setParameterExceptionHandler(besuParameterExceptionHandler) - .setExecutionExceptionHandler(besuExecutionExceptionHandler) - .execute(args); - } - private void preSynchronization() { preSynchronizationTaskRunner.runTasks(besuController); } @@ -2382,15 +2431,6 @@ public Path dataDir() { return dataPath.toAbsolutePath(); } - private Path pluginsDir() { - final String pluginsDir = System.getProperty("besu.plugins.dir"); - if (pluginsDir == null) { - return new File(System.getProperty("besu.home", "."), "plugins").toPath(); - } else { - return new File(pluginsDir).toPath(); - } - } - private SecurityModule securityModule() { return securityModuleService .getByName(securityModuleName) diff --git a/besu/src/main/java/org/hyperledger/besu/cli/DefaultCommandValues.java b/besu/src/main/java/org/hyperledger/besu/cli/DefaultCommandValues.java index 62de356e5f2..84055db663c 100644 --- a/besu/src/main/java/org/hyperledger/besu/cli/DefaultCommandValues.java +++ b/besu/src/main/java/org/hyperledger/besu/cli/DefaultCommandValues.java @@ -125,6 +125,9 @@ public interface DefaultCommandValues { /** The Default tls protocols. */ List DEFAULT_TLS_PROTOCOLS = List.of("TLSv1.3", "TLSv1.2"); + /** The constant DEFAULT_PLUGINS_OPTION_NAME. */ + String DEFAULT_PLUGINS_OPTION_NAME = "--plugins"; + /** * Gets default besu data path. * diff --git a/besu/src/main/java/org/hyperledger/besu/cli/converter/PluginInfoConverter.java b/besu/src/main/java/org/hyperledger/besu/cli/converter/PluginInfoConverter.java new file mode 100644 index 00000000000..9dd5e21c8db --- /dev/null +++ b/besu/src/main/java/org/hyperledger/besu/cli/converter/PluginInfoConverter.java @@ -0,0 +1,53 @@ +/* + * Copyright Hyperledger Besu Contributors. + * + * 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 + */ +package org.hyperledger.besu.cli.converter; + +import org.hyperledger.besu.ethereum.core.plugins.PluginInfo; + +import java.util.List; +import java.util.stream.Stream; + +import picocli.CommandLine; + +/** + * Converts a comma-separated string into a list of {@link PluginInfo} objects. This converter is + * intended for use with PicoCLI to process command line arguments that specify plugin information. + */ +public class PluginInfoConverter implements CommandLine.ITypeConverter> { + + /** + * Converts a comma-separated string into a list of {@link PluginInfo}. + * + * @param value The comma-separated string representing plugin names. + * @return A list of {@link PluginInfo} objects created from the provided string. + */ + @Override + public List convert(final String value) { + if (value == null || value.isBlank()) { + return List.of(); + } + return Stream.of(value.split(",")).map(String::trim).map(this::toPluginInfo).toList(); + } + + /** + * Creates a {@link PluginInfo} object from a plugin name. + * + * @param pluginName The name of the plugin. + * @return A {@link PluginInfo} object representing the plugin. + */ + private PluginInfo toPluginInfo(final String pluginName) { + return new PluginInfo(pluginName); + } +} diff --git a/besu/src/main/java/org/hyperledger/besu/cli/options/stable/PluginsConfigurationOptions.java b/besu/src/main/java/org/hyperledger/besu/cli/options/stable/PluginsConfigurationOptions.java new file mode 100644 index 00000000000..abf9085b1f8 --- /dev/null +++ b/besu/src/main/java/org/hyperledger/besu/cli/options/stable/PluginsConfigurationOptions.java @@ -0,0 +1,63 @@ +/* + * Copyright Hyperledger Besu Contributors. + * + * 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 + */ +package org.hyperledger.besu.cli.options.stable; + +import static org.hyperledger.besu.cli.DefaultCommandValues.DEFAULT_PLUGINS_OPTION_NAME; + +import org.hyperledger.besu.cli.converter.PluginInfoConverter; +import org.hyperledger.besu.cli.options.CLIOptions; +import org.hyperledger.besu.cli.util.CommandLineUtils; +import org.hyperledger.besu.ethereum.core.plugins.PluginConfiguration; +import org.hyperledger.besu.ethereum.core.plugins.PluginInfo; + +import java.util.List; + +import picocli.CommandLine; + +/** The Plugins Options options. */ +public class PluginsConfigurationOptions implements CLIOptions { + @CommandLine.Option( + names = {DEFAULT_PLUGINS_OPTION_NAME}, + description = "Comma-separated list of plugin names", + split = ",", + hidden = true, + converter = PluginInfoConverter.class, + arity = "1..*") + private List plugins; + + @Override + public PluginConfiguration toDomainObject() { + return new PluginConfiguration(plugins); + } + + @Override + public List getCLIOptions() { + return CommandLineUtils.getCLIOptions(this, new PluginsConfigurationOptions()); + } + + /** + * Constructs a {@link PluginConfiguration} instance based on the command line options. + * + * @param commandLine The command line instance containing parsed options. + * @return A new {@link PluginConfiguration} instance. + */ + public static PluginConfiguration fromCommandLine(final CommandLine commandLine) { + List plugins = + CommandLineUtils.getOptionValueOrDefault( + commandLine, DEFAULT_PLUGINS_OPTION_NAME, new PluginInfoConverter()); + + return new PluginConfiguration(plugins); + } +} diff --git a/besu/src/main/java/org/hyperledger/besu/cli/util/CommandLineUtils.java b/besu/src/main/java/org/hyperledger/besu/cli/util/CommandLineUtils.java index 38418dd11df..710fbbcada8 100644 --- a/besu/src/main/java/org/hyperledger/besu/cli/util/CommandLineUtils.java +++ b/besu/src/main/java/org/hyperledger/besu/cli/util/CommandLineUtils.java @@ -264,4 +264,61 @@ public static boolean isOptionSet(final CommandLine commandLine, final String op .filter(optionSpec -> Arrays.stream(optionSpec.names()).anyMatch(optionName::equals)) .anyMatch(CommandLineUtils::isOptionSet); } + + /** + * Retrieves the value of a specified command line option, converting it to its appropriate type, + * or returns the default value if the option was not specified. + * + * @param The type of the option value. + * @param commandLine The {@link CommandLine} instance containing the parsed command line options. + * @param optionName The name of the option whose value is to be retrieved. + * @param converter A converter that converts the option's string value to its appropriate type. + * @return The value of the specified option converted to its type, or the default value if the + * option was not specified. Returns {@code null} if the option does not exist or if there is + * no default value and the option was not specified. + */ + public static T getOptionValueOrDefault( + final CommandLine commandLine, + final String optionName, + final CommandLine.ITypeConverter converter) { + + return commandLine + .getParseResult() + .matchedOptionValue(optionName, getDefaultOptionValue(commandLine, optionName, converter)); + } + + /** + * Retrieves the default value for a specified command line option, converting it to its + * appropriate type. + * + * @param The type of the option value. + * @param commandLine The {@link CommandLine} instance containing the parsed command line options. + * @param optionName The name of the option whose default value is to be retrieved. + * @param converter A converter that converts the option's default string value to its appropriate + * type. + * @return The default value of the specified option converted to its type, or {@code null} if the + * option does not exist, does not have a default value, or if an error occurs during + * conversion. + * @throws RuntimeException if there is an error converting the default value string to its type. + */ + private static T getDefaultOptionValue( + final CommandLine commandLine, + final String optionName, + final CommandLine.ITypeConverter converter) { + + CommandLine.Model.OptionSpec optionSpec = commandLine.getCommandSpec().findOption(optionName); + if (optionSpec == null || commandLine.getDefaultValueProvider() == null) { + return null; + } + + try { + String defaultValueString = commandLine.getDefaultValueProvider().defaultValue(optionSpec); + return defaultValueString != null + ? converter.convert(defaultValueString) + : optionSpec.getValue(); + } catch (Exception e) { + throw new RuntimeException( + "Failed to convert default value for option " + optionName + ": " + e.getMessage(), e); + } + } } diff --git a/besu/src/main/java/org/hyperledger/besu/cli/util/ConfigOptionSearchAndRunHandler.java b/besu/src/main/java/org/hyperledger/besu/cli/util/ConfigDefaultValueProviderStrategy.java similarity index 74% rename from besu/src/main/java/org/hyperledger/besu/cli/util/ConfigOptionSearchAndRunHandler.java rename to besu/src/main/java/org/hyperledger/besu/cli/util/ConfigDefaultValueProviderStrategy.java index 52b67652822..d3ff29dbc66 100644 --- a/besu/src/main/java/org/hyperledger/besu/cli/util/ConfigOptionSearchAndRunHandler.java +++ b/besu/src/main/java/org/hyperledger/besu/cli/util/ConfigDefaultValueProviderStrategy.java @@ -25,47 +25,37 @@ import picocli.CommandLine; import picocli.CommandLine.IDefaultValueProvider; import picocli.CommandLine.IExecutionStrategy; -import picocli.CommandLine.IParameterExceptionHandler; import picocli.CommandLine.ParameterException; import picocli.CommandLine.ParseResult; /** Custom Config option search and run handler. */ -public class ConfigOptionSearchAndRunHandler extends CommandLine.RunLast { +public class ConfigDefaultValueProviderStrategy implements IExecutionStrategy { private final IExecutionStrategy resultHandler; - private final IParameterExceptionHandler parameterExceptionHandler; private final Map environment; /** * Instantiates a new Config option search and run handler. * * @param resultHandler the result handler - * @param parameterExceptionHandler the parameter exception handler * @param environment the environment variables map */ - public ConfigOptionSearchAndRunHandler( - final IExecutionStrategy resultHandler, - final IParameterExceptionHandler parameterExceptionHandler, - final Map environment) { + public ConfigDefaultValueProviderStrategy( + final IExecutionStrategy resultHandler, final Map environment) { this.resultHandler = resultHandler; - this.parameterExceptionHandler = parameterExceptionHandler; this.environment = environment; } @Override - public List handle(final ParseResult parseResult) throws ParameterException { + public int execute(final ParseResult parseResult) + throws CommandLine.ExecutionException, ParameterException { final CommandLine commandLine = parseResult.commandSpec().commandLine(); - commandLine.setDefaultValueProvider( createDefaultValueProvider( commandLine, new ConfigFileFinder().findConfiguration(environment, parseResult), new ProfileFinder().findConfiguration(environment, parseResult))); - commandLine.setExecutionStrategy(resultHandler); - commandLine.setParameterExceptionHandler(parameterExceptionHandler); - commandLine.execute(parseResult.originalArgs().toArray(new String[0])); - - return new ArrayList<>(); + return commandLine.execute(parseResult.originalArgs().toArray(new String[0])); } /** @@ -73,10 +63,11 @@ public List handle(final ParseResult parseResult) throws ParameterExcept * * @param commandLine the command line * @param configFile the config file + * @param profile the profile file * @return the default value provider */ @VisibleForTesting - IDefaultValueProvider createDefaultValueProvider( + public IDefaultValueProvider createDefaultValueProvider( final CommandLine commandLine, final Optional configFile, final Optional profile) { @@ -94,9 +85,4 @@ IDefaultValueProvider createDefaultValueProvider( p -> providers.add(TomlConfigurationDefaultProvider.fromInputStream(commandLine, p))); return new CascadingDefaultProvider(providers); } - - @Override - public ConfigOptionSearchAndRunHandler self() { - return this; - } } diff --git a/besu/src/main/java/org/hyperledger/besu/services/BesuPluginContextImpl.java b/besu/src/main/java/org/hyperledger/besu/services/BesuPluginContextImpl.java index a35c3e0a87a..e79770f9428 100644 --- a/besu/src/main/java/org/hyperledger/besu/services/BesuPluginContextImpl.java +++ b/besu/src/main/java/org/hyperledger/besu/services/BesuPluginContextImpl.java @@ -17,6 +17,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkState; +import org.hyperledger.besu.ethereum.core.plugins.PluginConfiguration; import org.hyperledger.besu.plugin.BesuContext; import org.hyperledger.besu.plugin.BesuPlugin; import org.hyperledger.besu.plugin.services.BesuService; @@ -35,11 +36,13 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; import java.util.Optional; import java.util.ServiceLoader; -import java.util.function.Predicate; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.stream.StreamSupport; import com.google.common.annotations.VisibleForTesting; import org.slf4j.Logger; @@ -73,9 +76,13 @@ private enum Lifecycle { private Lifecycle state = Lifecycle.UNINITIALIZED; private final Map, ? super BesuService> serviceRegistry = new HashMap<>(); - private final List plugins = new ArrayList<>(); + + private List detectedPlugins = new ArrayList<>(); + private List requestedPlugins = new ArrayList<>(); + + private final List registeredPlugins = new ArrayList<>(); + private final List pluginVersions = new ArrayList<>(); - final List lines = new ArrayList<>(); /** * Add service. @@ -99,75 +106,96 @@ public Optional getService(final Class serviceType return Optional.ofNullable((T) serviceRegistry.get(serviceType)); } + private List detectPlugins(final PluginConfiguration config) { + ClassLoader pluginLoader = + pluginDirectoryLoader(config.getPluginsDir()).orElse(getClass().getClassLoader()); + ServiceLoader serviceLoader = ServiceLoader.load(BesuPlugin.class, pluginLoader); + return StreamSupport.stream(serviceLoader.spliterator(), false).toList(); + } + /** - * Register plugins. + * Registers plugins based on the provided {@link PluginConfiguration}. This method finds plugins + * according to the configuration settings, filters them if necessary and then registers the + * filtered or found plugins * - * @param pluginsDir the plugins dir + * @param config The configuration settings used to find and filter plugins for registration. The + * configuration includes the plugin directory and any configured plugin identifiers if + * applicable. + * @throws IllegalStateException if the system is not in the UNINITIALIZED state. */ - public void registerPlugins(final Path pluginsDir) { - lines.add("Plugins:"); + public void registerPlugins(final PluginConfiguration config) { checkState( state == Lifecycle.UNINITIALIZED, - "Besu plugins have already been registered. Cannot register additional plugins."); + "Besu plugins have already been registered. Cannot register additional plugins."); + state = Lifecycle.REGISTERING; - final ClassLoader pluginLoader = - pluginDirectoryLoader(pluginsDir).orElse(this.getClass().getClassLoader()); + detectedPlugins = detectPlugins(config); + if (!config.getRequestedPlugins().isEmpty()) { + // Register only the plugins that were explicitly requested and validated + requestedPlugins = config.getRequestedPlugins(); - state = Lifecycle.REGISTERING; + // Match and validate the requested plugins against the detected plugins + List registeringPlugins = + matchAndValidateRequestedPlugins(requestedPlugins, detectedPlugins); - final ServiceLoader serviceLoader = - ServiceLoader.load(BesuPlugin.class, pluginLoader); + registerPlugins(registeringPlugins); + } else { + // If no plugins were specified, register all detected plugins + registerPlugins(detectedPlugins); + } + } - int pluginsCount = 0; - for (final BesuPlugin plugin : serviceLoader) { - pluginsCount++; - try { - plugin.register(this); - LOG.info("Registered plugin of type {}.", plugin.getClass().getName()); - String pluginVersion = getPluginVersion(plugin); - pluginVersions.add(pluginVersion); - lines.add(String.format("%s (%s)", plugin.getClass().getSimpleName(), pluginVersion)); - } catch (final Exception e) { - LOG.error( - "Error registering plugin of type " - + plugin.getClass().getName() - + ", start and stop will not be called.", - e); - lines.add(String.format("ERROR %s", plugin.getClass().getSimpleName())); - continue; - } - plugins.add(plugin); + private List matchAndValidateRequestedPlugins( + final List requestedPluginNames, final List detectedPlugins) + throws NoSuchElementException { + + // Filter detected plugins to include only those that match the requested names + List matchingPlugins = + detectedPlugins.stream() + .filter(plugin -> requestedPluginNames.contains(plugin.getClass().getSimpleName())) + .toList(); + + // Check if all requested plugins were found among the detected plugins + if (matchingPlugins.size() != requestedPluginNames.size()) { + // Find which requested plugins were not matched to throw a detailed exception + Set matchedPluginNames = + matchingPlugins.stream() + .map(plugin -> plugin.getClass().getSimpleName()) + .collect(Collectors.toSet()); + String missingPlugins = + requestedPluginNames.stream() + .filter(name -> !matchedPluginNames.contains(name)) + .collect(Collectors.joining(", ")); + throw new NoSuchElementException( + "The following requested plugins were not found: " + missingPlugins); } + return matchingPlugins; + } - LOG.debug("Plugin registration complete."); - lines.add( - String.format( - "TOTAL = %d of %d plugins successfully loaded", plugins.size(), pluginsCount)); - lines.add(String.format("from %s", pluginsDir.toAbsolutePath())); + private void registerPlugins(final List pluginsToRegister) { + for (final BesuPlugin plugin : pluginsToRegister) { + if (registerPlugin(plugin)) { + registeredPlugins.add(plugin); + } + } state = Lifecycle.REGISTERED; } - /** - * get the summary log, as a list of string lines - * - * @return the summary - */ - public List getPluginsSummaryLog() { - return lines; - } - - private String getPluginVersion(final BesuPlugin plugin) { - final Package pluginPackage = plugin.getClass().getPackage(); - final String implTitle = - Optional.ofNullable(pluginPackage.getImplementationTitle()) - .filter(Predicate.not(String::isBlank)) - .orElse(plugin.getClass().getSimpleName()); - final String implVersion = - Optional.ofNullable(pluginPackage.getImplementationVersion()) - .filter(Predicate.not(String::isBlank)) - .orElse(""); - return implTitle + "/v" + implVersion; + private boolean registerPlugin(final BesuPlugin plugin) { + try { + plugin.register(this); + LOG.info("Registered plugin of type {}.", plugin.getClass().getName()); + pluginVersions.add(plugin.getVersion()); + } catch (final Exception e) { + LOG.error( + "Error registering plugin of type " + + plugin.getClass().getName() + + ", start and stop will not be called.", + e); + return false; + } + return true; } /** Before external services. */ @@ -178,7 +206,7 @@ public void beforeExternalServices() { Lifecycle.REGISTERED, state); state = Lifecycle.BEFORE_EXTERNAL_SERVICES_STARTED; - final Iterator pluginsIterator = plugins.iterator(); + final Iterator pluginsIterator = registeredPlugins.iterator(); while (pluginsIterator.hasNext()) { final BesuPlugin plugin = pluginsIterator.next(); @@ -209,7 +237,7 @@ public void startPlugins() { Lifecycle.BEFORE_EXTERNAL_SERVICES_FINISHED, state); state = Lifecycle.BEFORE_MAIN_LOOP_STARTED; - final Iterator pluginsIterator = plugins.iterator(); + final Iterator pluginsIterator = registeredPlugins.iterator(); while (pluginsIterator.hasNext()) { final BesuPlugin plugin = pluginsIterator.next(); @@ -240,7 +268,7 @@ public void stopPlugins() { state); state = Lifecycle.STOPPING; - for (final BesuPlugin plugin : plugins) { + for (final BesuPlugin plugin : registeredPlugins) { try { plugin.stop(); LOG.debug("Stopped plugin of type {}.", plugin.getClass().getName()); @@ -253,11 +281,6 @@ public void stopPlugins() { state = Lifecycle.STOPPED; } - @Override - public Collection getPluginVersions() { - return Collections.unmodifiableList(pluginVersions); - } - private static URL pathToURIOrNull(final Path p) { try { return p.toUri().toURL(); @@ -266,16 +289,6 @@ private static URL pathToURIOrNull(final Path p) { } } - /** - * Gets plugins. - * - * @return the plugins - */ - @VisibleForTesting - List getPlugins() { - return Collections.unmodifiableList(plugins); - } - private Optional pluginDirectoryLoader(final Path pluginsDir) { if (pluginsDir != null && pluginsDir.toFile().isDirectory()) { LOG.debug("Searching for plugins in {}", pluginsDir.toAbsolutePath()); @@ -299,14 +312,73 @@ private Optional pluginDirectoryLoader(final Path pluginsDir) { return Optional.empty(); } + @Override + public Collection getPluginVersions() { + return Collections.unmodifiableList(pluginVersions); + } + + /** + * Gets plugins. + * + * @return the plugins + */ + @VisibleForTesting + List getRegisteredPlugins() { + return Collections.unmodifiableList(registeredPlugins); + } + /** * Gets named plugins. * * @return the named plugins */ public Map getNamedPlugins() { - return plugins.stream() + return registeredPlugins.stream() .filter(plugin -> plugin.getName().isPresent()) .collect(Collectors.toMap(plugin -> plugin.getName().get(), plugin -> plugin, (a, b) -> b)); } + + /** + * Generates a summary log of plugin registration. The summary includes registered plugins, + * detected but not registered (skipped) plugins + * + * @return A list of strings, each representing a line in the summary log. + */ + public List getPluginsSummaryLog() { + List summary = new ArrayList<>(); + summary.add("Plugin Registration Summary:"); + + // Log registered plugins with their names and versions + if (registeredPlugins.isEmpty()) { + summary.add("No plugins have been registered."); + } else { + summary.add("Registered Plugins:"); + registeredPlugins.forEach( + plugin -> + summary.add( + String.format( + " - %s (Version: %s)", + plugin.getClass().getSimpleName(), plugin.getVersion()))); + } + + // Identify and log detected but not registered (skipped) plugins + List skippedPlugins = + detectedPlugins.stream() + .filter(plugin -> !registeredPlugins.contains(plugin)) + .map(plugin -> plugin.getClass().getSimpleName()) + .toList(); + + if (!skippedPlugins.isEmpty()) { + summary.add("Skipped Plugins:"); + skippedPlugins.forEach( + pluginName -> + summary.add(String.format(" - %s (Detected but not registered)", pluginName))); + } + summary.add( + String.format( + "TOTAL = %d of %d plugins successfully registered.", + registeredPlugins.size(), detectedPlugins.size())); + + return summary; + } } diff --git a/besu/src/test/java/org/hyperledger/besu/cli/CommandLineUtilsDefaultsTest.java b/besu/src/test/java/org/hyperledger/besu/cli/CommandLineUtilsDefaultsTest.java new file mode 100644 index 00000000000..836d7fb3c4e --- /dev/null +++ b/besu/src/test/java/org/hyperledger/besu/cli/CommandLineUtilsDefaultsTest.java @@ -0,0 +1,110 @@ +/* + * Copyright Hyperledger Besu Contributors. + * + * 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 + */ + +package org.hyperledger.besu.cli; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.cli.util.CommandLineUtils.getOptionValueOrDefault; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.hyperledger.besu.cli.util.CommandLineUtils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +/** + * Unit tests for {@link CommandLineUtils} focusing on the retrieval of option values + * (getOptionValueOrDefault). + */ +public class CommandLineUtilsDefaultsTest { + private static final String OPTION_NAME = "option"; + private static final String OPTION_VALUE = "optionValue"; + private static final String DEFAULT_VALUE = "defaultValue"; + public final CommandLine.ITypeConverter converter = String::valueOf; + private CommandLine commandLine; + private CommandLine.Model.OptionSpec optionSpec; + private CommandLine.IDefaultValueProvider defaultValueProvider; + private CommandLine.ParseResult parseResult; + + @BeforeEach + public void setUp() { + commandLine = mock(CommandLine.class); + parseResult = mock(CommandLine.ParseResult.class); + CommandLine.Model.CommandSpec commandSpec = mock(CommandLine.Model.CommandSpec.class); + optionSpec = mock(CommandLine.Model.OptionSpec.class); + defaultValueProvider = mock(CommandLine.IDefaultValueProvider.class); + when(commandLine.getParseResult()).thenReturn(parseResult); + when(commandLine.getCommandSpec()).thenReturn(commandSpec); + when(commandLine.getDefaultValueProvider()).thenReturn(defaultValueProvider); + when(parseResult.matchedOptionValue(anyString(), any())).thenCallRealMethod(); + when(commandSpec.findOption(OPTION_NAME)).thenReturn(optionSpec); + } + + @Test + public void testGetOptionValueOrDefault_UserProvidedValue() { + when(parseResult.matchedOption(OPTION_NAME)).thenReturn(optionSpec); + when(optionSpec.getValue()).thenReturn(OPTION_VALUE); + + String result = getOptionValueOrDefault(commandLine, OPTION_NAME, converter); + assertThat(result).isEqualTo(OPTION_VALUE); + } + + @Test + public void testGetOptionValueOrDefault_DefaultValue() throws Exception { + when(defaultValueProvider.defaultValue(optionSpec)).thenReturn(DEFAULT_VALUE); + String result = getOptionValueOrDefault(commandLine, OPTION_NAME, converter); + assertThat(result).isEqualTo(DEFAULT_VALUE); + } + + @Test + public void userOptionOverridesDefaultValue() throws Exception { + when(parseResult.matchedOption(OPTION_NAME)).thenReturn(optionSpec); + when(optionSpec.getValue()).thenReturn(OPTION_VALUE); + + when(defaultValueProvider.defaultValue(optionSpec)).thenReturn(DEFAULT_VALUE); + String result = getOptionValueOrDefault(commandLine, OPTION_NAME, converter); + assertThat(result).isEqualTo(OPTION_VALUE); + } + + @Test + public void testGetOptionValueOrDefault_NoValueOrDefault() { + String result = getOptionValueOrDefault(commandLine, OPTION_NAME, converter); + assertThat(result).isNull(); + } + + @Test + public void testGetOptionValueOrDefault_ConversionFailure() throws Exception { + when(defaultValueProvider.defaultValue(optionSpec)).thenReturn(DEFAULT_VALUE); + + CommandLine.ITypeConverter failingConverter = + value -> { + throw new Exception("Conversion failed"); + }; + + String actualMessage = + assertThrows( + RuntimeException.class, + () -> getOptionValueOrDefault(commandLine, OPTION_NAME, failingConverter)) + .getMessage(); + final String expectedMessage = + "Failed to convert default value for option option: Conversion failed"; + assertThat(actualMessage).isEqualTo(expectedMessage); + } +} diff --git a/besu/src/test/java/org/hyperledger/besu/cli/CommandLineUtilsTest.java b/besu/src/test/java/org/hyperledger/besu/cli/CommandLineUtilsTest.java index d8e8912eefa..24b840dc74b 100644 --- a/besu/src/test/java/org/hyperledger/besu/cli/CommandLineUtilsTest.java +++ b/besu/src/test/java/org/hyperledger/besu/cli/CommandLineUtilsTest.java @@ -69,7 +69,6 @@ private abstract static class AbstractTestCommand implements Runnable { commandLine.setDefaultValueProvider(new EnvironmentVariableDefaultProvider(environment)); } - // Completely disables p2p within Besu. @Option( names = {"--option-enabled"}, arity = "1") diff --git a/besu/src/test/java/org/hyperledger/besu/cli/util/ConfigOptionSearchAndRunHandlerTest.java b/besu/src/test/java/org/hyperledger/besu/cli/util/ConfigDefaultValueProviderStrategyTest.java similarity index 83% rename from besu/src/test/java/org/hyperledger/besu/cli/util/ConfigOptionSearchAndRunHandlerTest.java rename to besu/src/test/java/org/hyperledger/besu/cli/util/ConfigDefaultValueProviderStrategyTest.java index fbc672dc0a1..a2ea92e48b2 100644 --- a/besu/src/test/java/org/hyperledger/besu/cli/util/ConfigOptionSearchAndRunHandlerTest.java +++ b/besu/src/test/java/org/hyperledger/besu/cli/util/ConfigDefaultValueProviderStrategyTest.java @@ -52,7 +52,7 @@ import picocli.CommandLine.RunLast; @ExtendWith(MockitoExtension.class) -public class ConfigOptionSearchAndRunHandlerTest { +public class ConfigDefaultValueProviderStrategyTest { private static final String CONFIG_FILE_OPTION_NAME = "--config-file"; @TempDir public Path temp; @@ -61,7 +61,7 @@ public class ConfigOptionSearchAndRunHandlerTest { private final IExecutionStrategy resultHandler = new RunLast(); private final Map environment = singletonMap("BESU_LOGGING", "ERROR"); - private ConfigOptionSearchAndRunHandler configParsingHandler; + private ConfigDefaultValueProviderStrategy configParsingHandler; @Mock ParseResult mockParseResult; @Mock CommandSpec mockCommandSpec; @@ -84,60 +84,52 @@ public void initMocks() { lenient().when(mockConfigOptionSpec.getter()).thenReturn(mockConfigOptionGetter); levelOption = new LoggingLevelOption(); levelOption.setLogLevel("INFO"); - configParsingHandler = - new ConfigOptionSearchAndRunHandler( - resultHandler, mockParameterExceptionHandler, environment); + configParsingHandler = new ConfigDefaultValueProviderStrategy(resultHandler, environment); } @Test public void handleWithCommandLineOption() throws Exception { when(mockConfigOptionGetter.get()).thenReturn(Files.createTempFile("tmp", "txt").toFile()); - final List result = configParsingHandler.handle(mockParseResult); + configParsingHandler.execute(mockParseResult); verify(mockCommandLine).setDefaultValueProvider(any(IDefaultValueProvider.class)); verify(mockCommandLine).setExecutionStrategy(eq(resultHandler)); - verify(mockCommandLine).setParameterExceptionHandler(eq(mockParameterExceptionHandler)); verify(mockCommandLine).execute(anyString()); - - assertThat(result).isEmpty(); } @Test public void handleWithEnvironmentVariable() throws IOException { when(mockParseResult.hasMatchedOption(CONFIG_FILE_OPTION_NAME)).thenReturn(false); - final ConfigOptionSearchAndRunHandler environmentConfigFileParsingHandler = - new ConfigOptionSearchAndRunHandler( + final ConfigDefaultValueProviderStrategy environmentConfigFileParsingHandler = + new ConfigDefaultValueProviderStrategy( resultHandler, - mockParameterExceptionHandler, singletonMap( "BESU_CONFIG_FILE", Files.createFile(temp.resolve("tmp")).toFile().getAbsolutePath())); when(mockParseResult.hasMatchedOption(CONFIG_FILE_OPTION_NAME)).thenReturn(false); - environmentConfigFileParsingHandler.handle(mockParseResult); + environmentConfigFileParsingHandler.execute(mockParseResult); } @Test public void handleWithCommandLineOptionShouldRaiseExceptionIfNoFileParam() throws Exception { final String error_message = "an error occurred during get"; when(mockConfigOptionGetter.get()).thenThrow(new Exception(error_message)); - assertThatThrownBy(() -> configParsingHandler.handle(mockParseResult)) + assertThatThrownBy(() -> configParsingHandler.execute(mockParseResult)) .isInstanceOf(Exception.class) .hasMessage(error_message); } @Test public void handleWithEnvironmentVariableOptionShouldRaiseExceptionIfNoFileParam() { - final ConfigOptionSearchAndRunHandler environmentConfigFileParsingHandler = - new ConfigOptionSearchAndRunHandler( - resultHandler, - mockParameterExceptionHandler, - singletonMap("BESU_CONFIG_FILE", "not_found.toml")); + final ConfigDefaultValueProviderStrategy environmentConfigFileParsingHandler = + new ConfigDefaultValueProviderStrategy( + resultHandler, singletonMap("BESU_CONFIG_FILE", "not_found.toml")); when(mockParseResult.hasMatchedOption(CONFIG_FILE_OPTION_NAME)).thenReturn(false); - assertThatThrownBy(() -> environmentConfigFileParsingHandler.handle(mockParseResult)) + assertThatThrownBy(() -> environmentConfigFileParsingHandler.execute(mockParseResult)) .isInstanceOf(CommandLine.ParameterException.class); } @@ -163,15 +155,14 @@ public void shouldRetrieveConfigFromEnvironmentWhenConfigFileNotSpecified() thro public void handleThrowsErrorWithWithEnvironmentVariableAndCommandLineSpecified() throws IOException { - final ConfigOptionSearchAndRunHandler environmentConfigFileParsingHandler = - new ConfigOptionSearchAndRunHandler( + final ConfigDefaultValueProviderStrategy environmentConfigFileParsingHandler = + new ConfigDefaultValueProviderStrategy( resultHandler, - mockParameterExceptionHandler, singletonMap("BESU_CONFIG_FILE", temp.resolve("tmp").toFile().getAbsolutePath())); when(mockParseResult.hasMatchedOption(CONFIG_FILE_OPTION_NAME)).thenReturn(true); - assertThatThrownBy(() -> environmentConfigFileParsingHandler.handle(mockParseResult)) + assertThatThrownBy(() -> environmentConfigFileParsingHandler.execute(mockParseResult)) .isInstanceOf(CommandLine.ParameterException.class); } } diff --git a/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/core/plugins/PluginConfiguration.java b/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/core/plugins/PluginConfiguration.java new file mode 100644 index 00000000000..1f38386aeae --- /dev/null +++ b/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/core/plugins/PluginConfiguration.java @@ -0,0 +1,92 @@ +/* + * Copyright Hyperledger Besu Contributors. + * + * 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 + */ +package org.hyperledger.besu.ethereum.core.plugins; + +import static java.util.Objects.requireNonNull; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; + +/** + * Configuration for managing plugins, including their information, detection type, and directory. + */ +public class PluginConfiguration { + private final List requestedPlugins; + private final Path pluginsDir; + + /** + * Constructs a new PluginConfiguration with the specified plugin information and requestedPlugins + * directory. + * + * @param requestedPlugins List of {@link PluginInfo} objects representing the requestedPlugins. + * @param pluginsDir The directory where requestedPlugins are located. + */ + public PluginConfiguration(final List requestedPlugins, final Path pluginsDir) { + this.requestedPlugins = requestedPlugins; + this.pluginsDir = pluginsDir; + } + + /** + * Constructs a PluginConfiguration with specified plugins using the default directory. + * + * @param requestedPlugins List of plugins for consideration or registration. discoverable plugins + * are. + */ + public PluginConfiguration(final List requestedPlugins) { + this.requestedPlugins = requestedPlugins; + this.pluginsDir = PluginConfiguration.defaultPluginsDir(); + } + + /** + * Constructs a PluginConfiguration with the specified plugins directory + * + * @param pluginsDir The directory where plugins are located. Cannot be null. + */ + public PluginConfiguration(final Path pluginsDir) { + this.requestedPlugins = null; + this.pluginsDir = requireNonNull(pluginsDir); + } + + /** + * Returns the names of requested plugins, or an empty list if none. + * + * @return List of requested plugin names, never {@code null}. + */ + public List getRequestedPlugins() { + return requestedPlugins == null + ? Collections.emptyList() + : requestedPlugins.stream().map(PluginInfo::name).toList(); + } + + public Path getPluginsDir() { + return pluginsDir; + } + + /** + * Returns the default plugins directory based on system properties. + * + * @return The default {@link Path} to the plugin's directory. + */ + public static Path defaultPluginsDir() { + final String pluginsDirProperty = System.getProperty("besu.plugins.dir"); + if (pluginsDirProperty == null) { + return Paths.get(System.getProperty("besu.home", "."), "plugins"); + } else { + return Paths.get(pluginsDirProperty); + } + } +} diff --git a/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/core/plugins/PluginInfo.java b/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/core/plugins/PluginInfo.java new file mode 100644 index 00000000000..a30d4d55f91 --- /dev/null +++ b/ethereum/core/src/main/java/org/hyperledger/besu/ethereum/core/plugins/PluginInfo.java @@ -0,0 +1,37 @@ +/* + * Copyright Hyperledger Besu Contributors. + * + * 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 + */ +package org.hyperledger.besu.ethereum.core.plugins; + +/** Represents information about a plugin, including its name. */ +public final class PluginInfo { + private final String name; + + /** + * Constructs a new PluginInfo instance with the specified name. + * + * @param name The name of the plugin. Cannot be null or empty. + * @throws IllegalArgumentException if the name is null or empty. + */ + public PluginInfo(final String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Plugin name cannot be null or empty."); + } + this.name = name; + } + + public String name() { + return name; + } +} diff --git a/plugin-api/build.gradle b/plugin-api/build.gradle index fddd2255a8b..8e2d8b726df 100644 --- a/plugin-api/build.gradle +++ b/plugin-api/build.gradle @@ -69,7 +69,7 @@ Calculated : ${currentHash} tasks.register('checkAPIChanges', FileStateChecker) { description = "Checks that the API for the Plugin-API project does not change without deliberate thought" files = sourceSets.main.allJava.files - knownHash = '0mJiCGsToqx5aAJEvwnT3V0R8o4PXBYWiB0wT6CMpuo=' + knownHash = '78xbZ20PDB9CDcaSVY92VA8cXWGu4GwaZkvegWgep24=' } check.dependsOn('checkAPIChanges') diff --git a/plugin-api/src/main/java/org/hyperledger/besu/plugin/BesuPlugin.java b/plugin-api/src/main/java/org/hyperledger/besu/plugin/BesuPlugin.java index 5a54808a6c1..9c9dc44bf02 100644 --- a/plugin-api/src/main/java/org/hyperledger/besu/plugin/BesuPlugin.java +++ b/plugin-api/src/main/java/org/hyperledger/besu/plugin/BesuPlugin.java @@ -84,4 +84,24 @@ default CompletableFuture reloadConfiguration() { * started. */ void stop(); + + /** + * Retrieves the version information of the plugin. It constructs a version string using the + * implementation title and version from the package information. If either the title or version + * is not available, it defaults to the class's simple name and "Unknown Version", respectively. + * + * @return A string representing the plugin's version information, formatted as "Title/vVersion". + */ + default String getVersion() { + Package pluginPackage = this.getClass().getPackage(); + String implTitle = + Optional.ofNullable(pluginPackage.getImplementationTitle()) + .filter(title -> !title.isBlank()) + .orElseGet(() -> this.getClass().getSimpleName()); + String implVersion = + Optional.ofNullable(pluginPackage.getImplementationVersion()) + .filter(version -> !version.isBlank()) + .orElse(""); + return implTitle + "/v" + implVersion; + } }