Plugin Framework (#1435)

Provide a mechanism for Pantheon to integrate plugin code at runtime.
Signed-off-by: Adrian Sutton <adrian.sutton@consensys.net>
pull/2/head
Danno Ferrin 6 years ago committed by GitHub
parent 0b53195b0b
commit 8b7c5f7949
  1. 17
      acceptance-tests/build.gradle
  2. 2
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/NodeConfiguration.java
  3. 31
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/PantheonNode.java
  4. 8
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/ProcessPantheonNodeRunner.java
  5. 17
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/factory/PantheonFactoryConfiguration.java
  6. 20
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/factory/PantheonFactoryConfigurationBuilder.java
  7. 15
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/factory/PantheonNodeFactory.java
  8. 90
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/plugins/PluginsAcceptanceTest.java
  9. 1
      build.gradle
  10. 3
      pantheon/build.gradle
  11. 4
      pantheon/src/main/java/tech/pegasys/pantheon/Pantheon.java
  12. 1
      pantheon/src/main/java/tech/pegasys/pantheon/cli/DefaultCommandValues.java
  13. 33
      pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java
  14. 11
      pantheon/src/test/java/tech/pegasys/pantheon/cli/CommandTestAbstract.java
  15. 58
      plugins/build.gradle
  16. 20
      plugins/src/main/java/tech/pegasys/pantheon/plugins/PantheonContext.java
  17. 22
      plugins/src/main/java/tech/pegasys/pantheon/plugins/PantheonPlugin.java
  18. 179
      plugins/src/main/java/tech/pegasys/pantheon/plugins/internal/PantheonPluginContextImpl.java
  19. 2
      plugins/src/main/java/tech/pegasys/pantheon/plugins/internal/package-info.java
  20. 34
      plugins/src/main/java/tech/pegasys/pantheon/plugins/services/PantheonEvents.java
  21. 27
      plugins/src/main/java/tech/pegasys/pantheon/plugins/services/PicoCLIOptions.java
  22. 126
      plugins/src/test/java/tech/pegasys/pantheon/plugins/TestPlugin.java
  23. 157
      plugins/src/test/java/tech/pegasys/pantheon/plugins/internal/PantheonPluginContextImplTest.java
  24. 1
      settings.gradle

@ -21,19 +21,20 @@ dependencies {
testImplementation project(':consensus:ibft')
testImplementation project(':crypto')
testImplementation project(':enclave')
testImplementation project(':ethereum:eth')
testImplementation project(':ethereum:core')
testImplementation project(':ethereum:blockcreation')
testImplementation project(':ethereum:core')
testImplementation project(path: ':ethereum:core', configuration: 'testSupportArtifacts')
testImplementation project(':ethereum:eth')
testImplementation project(':ethereum:graphqlrpc')
testImplementation project(':ethereum:jsonrpc')
testImplementation project(':ethereum:permissioning')
testImplementation project(':ethereum:graphqlrpc')
testImplementation project(':ethereum:rlp')
testImplementation project(':metrics:core')
testImplementation project(path: ':plugins', configuration: 'testArtifacts')
testImplementation project(':pantheon')
testImplementation project(':services:kvstore')
testImplementation project(':testutil')
testImplementation project(':util')
testImplementation project(path: ':ethereum:core', configuration: 'testSupportArtifacts')
testImplementation 'com.google.guava:guava'
testImplementation 'com.squareup.okhttp3:okhttp'
@ -52,10 +53,16 @@ dependencies {
test.enabled = false
sourceSets {
test { resources { srcDirs "${rootDir}/plugins/build/libs" } }
}
processTestResources.dependsOn(':plugins:testJar')
task acceptanceTest(type: Test) {
dependsOn(rootProject.installDist)
systemProperty 'acctests.runPantheonAsProcess', 'true'
mustRunAfter rootProject.subprojects*.test
description = 'Runs Pantheon acceptance tests.'
group = 'verification'
}
acceptanceTest.dependsOn(rootProject.installDist)

@ -43,4 +43,6 @@ public interface NodeConfiguration {
boolean isDiscoveryEnabled();
boolean isBootnodeEligible();
List<String> getExtraCLIOptions();
}

@ -96,6 +96,8 @@ public class PantheonNode implements NodeConfiguration, RunnableNode, AutoClosea
private HttpRequestFactory httpRequestFactory;
private boolean useWsForJsonRpc = false;
private String token = null;
private final List<String> plugins = new ArrayList<>();
private final List<String> extraCLIOptions;
public PantheonNode(
final String name,
@ -110,7 +112,9 @@ public class PantheonNode implements NodeConfiguration, RunnableNode, AutoClosea
final GenesisConfigProvider genesisConfigProvider,
final boolean p2pEnabled,
final boolean discoveryEnabled,
final boolean bootnodeEligible)
final boolean bootnodeEligible,
final List<String> plugins,
final List<String> extraCLIOptions)
throws IOException {
this.bootnodeEligible = bootnodeEligible;
this.homeDirectory = Files.createTempDirectory("acctest");
@ -118,7 +122,7 @@ public class PantheonNode implements NodeConfiguration, RunnableNode, AutoClosea
path -> {
try {
copyResource(path, homeDirectory.resolve("key"));
} catch (IOException e) {
} catch (final IOException e) {
LOG.error("Could not find key file \"{}\" in resources", path);
}
});
@ -135,6 +139,18 @@ public class PantheonNode implements NodeConfiguration, RunnableNode, AutoClosea
this.devMode = devMode;
this.p2pEnabled = p2pEnabled;
this.discoveryEnabled = discoveryEnabled;
plugins.forEach(
pluginName -> {
try {
homeDirectory.resolve("plugins").toFile().mkdirs();
copyResource(
pluginName + ".jar", homeDirectory.resolve("plugins/" + pluginName + ".jar"));
PantheonNode.this.plugins.add(pluginName);
} catch (final IOException e) {
LOG.error("Could not find plugin \"{}\" in resources", pluginName);
}
});
this.extraCLIOptions = extraCLIOptions;
LOG.info("Created PantheonNode {}", this.toString());
}
@ -391,7 +407,7 @@ public class PantheonNode implements NodeConfiguration, RunnableNode, AutoClosea
return Util.publicKeyToAddress(keyPair.getPublicKey());
}
Path homeDirectory() {
public Path homeDirectory() {
return homeDirectory;
}
@ -481,6 +497,15 @@ public class PantheonNode implements NodeConfiguration, RunnableNode, AutoClosea
return permissioningConfiguration;
}
public List<String> getPlugins() {
return plugins;
}
@Override
public List<String> getExtraCLIOptions() {
return extraCLIOptions;
}
@Override
public String toString() {
return MoreObjects.toStringHelper(this)

@ -177,6 +177,7 @@ public class ProcessPantheonNodeRunner implements PantheonNodeRunner {
params.add(permissioningConfiguration.getNodeSmartContractAddress().toString());
}
});
params.addAll(node.getExtraCLIOptions());
LOG.info("Creating pantheon process with params {}", params);
final ProcessBuilder processBuilder =
@ -184,6 +185,13 @@ public class ProcessPantheonNodeRunner implements PantheonNodeRunner {
.directory(new File(System.getProperty("user.dir")).getParentFile())
.redirectErrorStream(true)
.redirectInput(Redirect.INHERIT);
if (!node.getPlugins().isEmpty()) {
processBuilder
.environment()
.put(
"PANTHEON_OPTS",
"-Dpantheon.plugins.dir=" + dataDir.resolve("plugins").toAbsolutePath().toString());
}
try {
final Process process = processBuilder.start();

@ -20,6 +20,7 @@ import tech.pegasys.pantheon.ethereum.permissioning.PermissioningConfiguration;
import tech.pegasys.pantheon.metrics.prometheus.MetricsConfiguration;
import tech.pegasys.pantheon.tests.acceptance.dsl.node.GenesisConfigProvider;
import java.util.List;
import java.util.Optional;
class PantheonFactoryConfiguration {
@ -37,6 +38,8 @@ class PantheonFactoryConfiguration {
private final boolean p2pEnabled;
private final boolean discoveryEnabled;
private final boolean bootnodeEligible;
private final List<String> plugins;
private final List<String> extraCLIOptions;
PantheonFactoryConfiguration(
final String name,
@ -51,7 +54,9 @@ class PantheonFactoryConfiguration {
final GenesisConfigProvider genesisConfigProvider,
final boolean p2pEnabled,
final boolean discoveryEnabled,
final boolean bootnodeEligible) {
final boolean bootnodeEligible,
final List<String> plugins,
final List<String> extraCLIOptions) {
this.name = name;
this.miningParameters = miningParameters;
this.privacyParameters = privacyParameters;
@ -65,6 +70,8 @@ class PantheonFactoryConfiguration {
this.p2pEnabled = p2pEnabled;
this.discoveryEnabled = discoveryEnabled;
this.bootnodeEligible = bootnodeEligible;
this.plugins = plugins;
this.extraCLIOptions = extraCLIOptions;
}
public String getName() {
@ -118,4 +125,12 @@ class PantheonFactoryConfiguration {
public boolean isBootnodeEligible() {
return bootnodeEligible;
}
public List<String> getPlugins() {
return plugins;
}
public List<String> getExtraCLIOptions() {
return extraCLIOptions;
}
}

@ -26,7 +26,9 @@ import tech.pegasys.pantheon.tests.acceptance.dsl.node.GenesisConfigProvider;
import java.net.URISyntaxException;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
public class PantheonFactoryConfigurationBuilder {
@ -45,6 +47,8 @@ public class PantheonFactoryConfigurationBuilder {
private Boolean p2pEnabled = true;
private boolean discoveryEnabled = true;
private boolean bootnodeEligible = true;
private List<String> plugins = new ArrayList<>();
private List<String> extraCLIOptions = new ArrayList<>();
public PantheonFactoryConfigurationBuilder name(final String name) {
this.name = name;
@ -169,6 +173,18 @@ public class PantheonFactoryConfigurationBuilder {
return this;
}
public PantheonFactoryConfigurationBuilder plugins(final List<String> plugins) {
this.plugins.clear();
this.plugins.addAll(plugins);
return this;
}
public PantheonFactoryConfigurationBuilder extraCLIOptions(final List<String> extraCLIOptions) {
this.extraCLIOptions.clear();
this.extraCLIOptions.addAll(extraCLIOptions);
return this;
}
public PantheonFactoryConfiguration build() {
return new PantheonFactoryConfiguration(
name,
@ -183,6 +199,8 @@ public class PantheonFactoryConfigurationBuilder {
genesisConfigProvider,
p2pEnabled,
discoveryEnabled,
bootnodeEligible);
bootnodeEligible,
plugins,
extraCLIOptions);
}
}

@ -60,7 +60,9 @@ public class PantheonNodeFactory {
config.getGenesisConfigProvider(),
config.isP2pEnabled(),
config.isDiscoveryEnabled(),
config.isBootnodeEligible());
config.isBootnodeEligible(),
config.getPlugins(),
config.getExtraCLIOptions());
}
public PantheonNode createMinerNode(final String name) throws IOException {
@ -173,6 +175,17 @@ public class PantheonNodeFactory {
return create(new PantheonFactoryConfigurationBuilder().name(name).build());
}
public PantheonNode createPluginsNode(
final String name, final List<String> plugins, final List<String> extraCLIOptions)
throws IOException {
return create(
new PantheonFactoryConfigurationBuilder()
.name(name)
.plugins(plugins)
.extraCLIOptions(extraCLIOptions)
.build());
}
public PantheonNode createArchiveNodeWithRpcApis(
final String name, final RpcApi... enabledRpcApis) throws IOException {
final JsonRpcConfiguration jsonRpcConfig = createJsonRpcEnabledConfig();

@ -0,0 +1,90 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.tests.acceptance.plugins;
import static org.assertj.core.api.Assertions.assertThat;
import tech.pegasys.pantheon.tests.acceptance.dsl.AcceptanceTestBase;
import tech.pegasys.pantheon.tests.acceptance.dsl.node.PantheonNode;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import org.awaitility.Awaitility;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
public class PluginsAcceptanceTest extends AcceptanceTestBase {
private PantheonNode node;
// context: https://en.wikipedia.org/wiki/The_Magic_Words_are_Squeamish_Ossifrage
private static final String MAGIC_WORDS = "Squemish Ossifrage";
@Before
public void setUp() throws Exception {
node =
pantheon.createPluginsNode(
"node1",
Collections.singletonList("testPlugin"),
Collections.singletonList("--Xtest-option=" + MAGIC_WORDS));
cluster.start(node);
}
@Test
public void shouldRegister() throws IOException {
final Path registrationFile = node.homeDirectory().resolve("plugins/testPlugin.registered");
waitForFile(registrationFile);
// this assert is false as CLI will not be parsed at this point
assertThat(Files.readAllLines(registrationFile).stream().anyMatch(s -> s.contains(MAGIC_WORDS)))
.isFalse();
}
@Test
public void shouldStart() throws IOException {
final Path registrationFile = node.homeDirectory().resolve("plugins/testPlugin.started");
waitForFile(registrationFile);
// this assert is true as CLI will be parsed at this point
assertThat(Files.readAllLines(registrationFile).stream().anyMatch(s -> s.contains(MAGIC_WORDS)))
.isTrue();
}
@Test
@Ignore("No way to do a graceful shutdown of Pantheon at the moment.")
public void shouldStop() {
cluster.stopNode(node);
waitForFile(node.homeDirectory().resolve("plugins/testPlugin.stopped"));
}
private void waitForFile(final Path path) {
final File file = path.toFile();
Awaitility.waitAtMost(30, TimeUnit.SECONDS)
.until(
() -> {
if (file.exists()) {
try (final Stream<String> s = Files.lines(path)) {
return s.count() > 0;
}
} else {
return false;
}
});
}
}

@ -123,6 +123,7 @@ allprojects {
target '*.gradle'
greclipse().configFile(rootProject.file('gradle/formatter.properties'))
endWithNewline()
paddedCell()
}
// Below this line are currently only license header tasks

@ -32,6 +32,7 @@ dependencies {
implementation project(':consensus:clique')
implementation project(':consensus:ibft')
implementation project(':consensus:ibftlegacy')
implementation project(':enclave')
implementation project(':ethereum:blockcreation')
implementation project(':ethereum:core')
implementation project(':ethereum:eth')
@ -40,8 +41,8 @@ dependencies {
implementation project(':ethereum:permissioning')
implementation project(':ethereum:p2p')
implementation project(':ethereum:rlp')
implementation project(':plugins')
implementation project(':metrics:core')
implementation project(':enclave')
implementation project(':services:kvstore')
implementation 'com.graphql-java:graphql-java'

@ -18,6 +18,7 @@ import tech.pegasys.pantheon.cli.PantheonCommand;
import tech.pegasys.pantheon.controller.PantheonController;
import tech.pegasys.pantheon.ethereum.eth.EthereumWireProtocolConfiguration;
import tech.pegasys.pantheon.ethereum.eth.sync.SynchronizerConfiguration;
import tech.pegasys.pantheon.plugins.internal.PantheonPluginContextImpl;
import tech.pegasys.pantheon.services.kvstore.RocksDbConfiguration;
import tech.pegasys.pantheon.util.BlockImporter;
@ -41,7 +42,8 @@ public final class Pantheon {
new PantheonController.Builder(),
new SynchronizerConfiguration.Builder(),
EthereumWireProtocolConfiguration.builder(),
new RocksDbConfiguration.Builder());
new RocksDbConfiguration.Builder(),
new PantheonPluginContextImpl());
pantheonCommand.parse(
new RunLast().andExit(SUCCESS_EXIT_CODE),

@ -43,6 +43,7 @@ public interface DefaultCommandValues {
long DEFAULT_MIN_REFRESH_DELAY = 1;
String DOCKER_GENESIS_LOCATION = "/etc/pantheon/genesis.json";
String DOCKER_DATADIR_LOCATION = "/var/lib/pantheon";
String DOCKER_PLUGINSDIR_LOCATION = "/etc/pantheon/plugins";
String DOCKER_RPC_HTTP_AUTHENTICATION_CREDENTIALS_FILE_LOCATION =
"/etc/pantheon/rpc_http_auth_config.toml";
String DOCKER_RPC_WS_AUTHENTICATION_CREDENTIALS_FILE_LOCATION =

@ -65,6 +65,8 @@ import tech.pegasys.pantheon.metrics.MetricsSystem;
import tech.pegasys.pantheon.metrics.prometheus.MetricsConfiguration;
import tech.pegasys.pantheon.metrics.prometheus.PrometheusMetricsSystem;
import tech.pegasys.pantheon.metrics.vertx.VertxMetricsAdapterFactory;
import tech.pegasys.pantheon.plugins.internal.PantheonPluginContextImpl;
import tech.pegasys.pantheon.plugins.services.PicoCLIOptions;
import tech.pegasys.pantheon.services.kvstore.RocksDbConfiguration;
import tech.pegasys.pantheon.util.BlockImporter;
import tech.pegasys.pantheon.util.InvalidConfigurationException;
@ -135,6 +137,7 @@ public class PantheonCommand implements DefaultCommandValues, Runnable {
private final RocksDbConfiguration.Builder rocksDbConfigurationBuilder;
private final RunnerBuilder runnerBuilder;
private final PantheonController.Builder controllerBuilderFactory;
private final PantheonPluginContextImpl pantheonPluginContext;
protected KeyLoader getKeyLoader() {
return KeyPairUtil::loadKeyPair;
@ -569,7 +572,8 @@ public class PantheonCommand implements DefaultCommandValues, Runnable {
final PantheonController.Builder controllerBuilderFactory,
final SynchronizerConfiguration.Builder synchronizerConfigurationBuilder,
final EthereumWireProtocolConfiguration.Builder ethereumWireConfigurationBuilder,
final RocksDbConfiguration.Builder rocksDbConfigurationBuilder) {
final RocksDbConfiguration.Builder rocksDbConfigurationBuilder,
final PantheonPluginContextImpl pantheonPluginContext) {
this.logger = logger;
this.blockImporter = blockImporter;
this.runnerBuilder = runnerBuilder;
@ -577,6 +581,7 @@ public class PantheonCommand implements DefaultCommandValues, Runnable {
this.synchronizerConfigurationBuilder = synchronizerConfigurationBuilder;
this.ethereumWireConfigurationBuilder = ethereumWireConfigurationBuilder;
this.rocksDbConfigurationBuilder = rocksDbConfigurationBuilder;
this.pantheonPluginContext = pantheonPluginContext;
}
private StandaloneCommand standaloneCommands;
@ -626,6 +631,11 @@ public class PantheonCommand implements DefaultCommandValues, Runnable {
"Ethereum Wire Protocol",
ethereumWireConfigurationBuilder));
pantheonPluginContext.addService(
PicoCLIOptions.class,
(namespace, optionObject) -> commandLine.addMixin("Plugin " + namespace, optionObject));
pantheonPluginContext.registerPlugins(pluginsDir());
// 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.
@ -691,7 +701,7 @@ public class PantheonCommand implements DefaultCommandValues, Runnable {
logger.info("Connecting to {} static nodes.", staticNodes.size());
logger.trace("Static Nodes = {}", staticNodes);
List<URI> enodeURIs =
final List<URI> enodeURIs =
ethNetworkConfig.getBootNodes().stream()
.map(EnodeURL::toURI)
.collect(Collectors.toList());
@ -706,6 +716,8 @@ public class PantheonCommand implements DefaultCommandValues, Runnable {
ensureAllNodesAreInWhitelist(
staticNodes.stream().map(EnodeURL::toURI).collect(Collectors.toList()), p));
pantheonPluginContext.startPlugins();
synchronize(
buildController(),
p2pEnabled,
@ -1068,8 +1080,8 @@ public class PantheonCommand implements DefaultCommandValues, Runnable {
new Thread(
() -> {
try {
pantheonPluginContext.stopPlugins();
runner.close();
LogManager.shutdown();
} catch (final Exception e) {
logger.error("Failed to stop Pantheon");
@ -1205,6 +1217,21 @@ public class PantheonCommand implements DefaultCommandValues, Runnable {
}
}
private Path pluginsDir() {
if (isFullInstantiation()) {
final String pluginsDir = System.getProperty("pantheon.plugins.dir");
if (pluginsDir == null) {
return new File("plugins").toPath();
} else {
return new File(pluginsDir).toPath();
}
} else if (isDocker) {
return Paths.get(DOCKER_PLUGINSDIR_LOCATION);
} else {
return null; // null means no plugins
}
}
File nodePrivateKeyFile() {
File nodePrivateKeyFile = null;
if (isFullInstantiation()) {

@ -32,6 +32,7 @@ import tech.pegasys.pantheon.ethereum.jsonrpc.JsonRpcConfiguration;
import tech.pegasys.pantheon.ethereum.jsonrpc.websocket.WebSocketConfiguration;
import tech.pegasys.pantheon.ethereum.permissioning.PermissioningConfiguration;
import tech.pegasys.pantheon.metrics.prometheus.MetricsConfiguration;
import tech.pegasys.pantheon.plugins.internal.PantheonPluginContextImpl;
import tech.pegasys.pantheon.services.kvstore.RocksDbConfiguration;
import tech.pegasys.pantheon.util.BlockImporter;
@ -84,6 +85,7 @@ public abstract class CommandTestAbstract {
@Mock PantheonController<Object> mockController;
@Mock BlockImporter mockBlockImporter;
@Mock Logger mockLogger;
@Mock PantheonPluginContextImpl mockPantheonPluginContext;
@Captor ArgumentCaptor<Collection<String>> stringListArgumentCaptor;
@Captor ArgumentCaptor<Path> pathArgumentCaptor;
@ -195,7 +197,8 @@ public abstract class CommandTestAbstract {
mockSyncConfBuilder,
mockEthereumWireProtocolConfigurationBuilder,
mockRocksDbConfBuilder,
keyLoader);
keyLoader,
mockPantheonPluginContext);
// parse using Ansi.OFF to be able to assert on non formatted output results
pantheonCommand.parse(
@ -224,7 +227,8 @@ public abstract class CommandTestAbstract {
final SynchronizerConfiguration.Builder mockSyncConfBuilder,
final EthereumWireProtocolConfiguration.Builder mockEthereumConfigurationMockBuilder,
final RocksDbConfiguration.Builder mockRocksDbConfBuilder,
final KeyLoader keyLoader) {
final KeyLoader keyLoader,
final PantheonPluginContextImpl pantheonPluginContext) {
super(
mockLogger,
mockBlockImporter,
@ -232,7 +236,8 @@ public abstract class CommandTestAbstract {
controllerBuilderFactory,
mockSyncConfBuilder,
mockEthereumConfigurationMockBuilder,
mockRocksDbConfBuilder);
mockRocksDbConfBuilder,
pantheonPluginContext);
this.keyLoader = keyLoader;
}
}

@ -0,0 +1,58 @@
/*
* Copyright 2018 ConsenSys AG.
*
* 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.
*/
apply plugin: 'java-library'
jar {
baseName 'pantheon-plugins'
manifest {
attributes(
'Specification-Title': baseName,
'Specification-Version': project.version,
'Implementation-Title': baseName,
'Implementation-Version': calculateVersion()
)
}
}
dependencies {
implementation 'com.google.guava:guava'
implementation 'org.apache.logging.log4j:log4j-api'
testAnnotationProcessor 'com.google.auto.service:auto-service'
testImplementation 'com.google.auto.service:auto-service'
testImplementation 'info.picocli:picocli'
testImplementation 'junit:junit'
testImplementation 'org.apache.logging.log4j:log4j-api'
testImplementation 'org.assertj:assertj-core'
testImplementation 'org.mockito:mockito-core'
}
task testJar(type: Jar) {
archiveName 'testPlugin.jar'
manifest {
attributes(
'Specification-Title': baseName,
'Specification-Version': project.version,
'Implementation-Title': baseName,
'Implementation-Version': calculateVersion()
)
}
archiveClassifier = 'tests'
from sourceSets.test.output
}
configurations { testArtifacts }
artifacts { testArtifacts testJar }

@ -0,0 +1,20 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.plugins;
import java.util.Optional;
public interface PantheonContext {
<T> Optional<T> getService(Class<T> serviceType);
}

@ -0,0 +1,22 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.plugins;
public interface PantheonPlugin {
void register(PantheonContext context);
void start();
void stop();
}

@ -0,0 +1,179 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.plugins.internal;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import tech.pegasys.pantheon.plugins.PantheonContext;
import tech.pegasys.pantheon.plugins.PantheonPlugin;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.stream.Stream;
import com.google.common.annotations.VisibleForTesting;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class PantheonPluginContextImpl implements PantheonContext {
private static final Logger LOG = LogManager.getLogger();
private enum Lifecycle {
UNINITIALIZED,
REGISTERING,
REGISTERED,
STARTING,
STARTED,
STOPPING,
STOPPED
}
private Lifecycle state = Lifecycle.UNINITIALIZED;
private final Map<Class<?>, ? super Object> serviceRegistry = new HashMap<>();
private final List<PantheonPlugin> plugins = new ArrayList<>();
public <T> void addService(final Class<T> serviceType, final T service) {
checkArgument(serviceType.isInterface(), "Services must be Java interfaces.");
checkArgument(
serviceType.isInstance(service),
"The service registered with a type must implement that type");
serviceRegistry.put(serviceType, service);
}
@SuppressWarnings("unchecked")
@Override
public <T> Optional<T> getService(final Class<T> serviceType) {
return Optional.ofNullable((T) serviceRegistry.get(serviceType));
}
public void registerPlugins(final Path pluginsDir) {
checkState(
state == Lifecycle.UNINITIALIZED,
"Pantheon plugins have already been registered. Cannot register additional plugins.");
if (pluginsDir == null) {
LOG.debug("Plugins are disabled.");
return;
}
state = Lifecycle.REGISTERING;
LOG.debug("Searching for plugins in {}", pluginsDir.toAbsolutePath().toString());
try (final Stream<Path> pluginFilesList = Files.list(pluginsDir)) {
final URL[] pluginJarURLs =
pluginFilesList
.filter(p -> p.getFileName().toString().endsWith(".jar"))
.map(PantheonPluginContextImpl::pathToURIOrNull)
.toArray(URL[]::new);
final ServiceLoader<PantheonPlugin> serviceLoader =
ServiceLoader.load(
PantheonPlugin.class,
new URLClassLoader(pluginJarURLs, this.getClass().getClassLoader()));
for (final PantheonPlugin plugin : serviceLoader) {
try {
plugin.register(this);
LOG.debug("Registered plugin of type {}.", plugin.getClass().getName());
} catch (final Exception e) {
LOG.error(
"Error registering plugin of type {}, start and stop will not be called. \n{}",
plugin.getClass(),
e);
continue;
}
plugins.add(plugin);
}
} catch (final MalformedURLException e) {
LOG.error("Error converting files to URLs, could not load plugins", e);
} catch (final IOException e) {
LOG.error("Error enumerating plugins, could not load plugins", e);
}
LOG.debug("Plugin registration complete.");
state = Lifecycle.REGISTERED;
}
public void startPlugins() {
checkState(
state == Lifecycle.REGISTERED,
"PantheonContext should be in state %s but it was in %s",
Lifecycle.REGISTERED,
state);
state = Lifecycle.STARTING;
final Iterator<PantheonPlugin> pluginsIterator = plugins.iterator();
while (pluginsIterator.hasNext()) {
final PantheonPlugin plugin = pluginsIterator.next();
try {
plugin.start();
LOG.debug("Started plugin of type {}.", plugin.getClass().getName());
} catch (final Exception e) {
LOG.error(
"Error starting plugin of type {}, stop will not be called. \n{}",
plugin.getClass(),
e);
pluginsIterator.remove();
}
}
LOG.debug("Plugin startup complete.");
state = Lifecycle.STARTED;
}
public void stopPlugins() {
checkState(
state == Lifecycle.STARTED,
"PantheonContext should be in state %s but it was in %s",
Lifecycle.STARTED,
state);
state = Lifecycle.STOPPING;
for (final PantheonPlugin plugin : plugins) {
try {
plugin.stop();
LOG.debug("Stopped plugin of type {}.", plugin.getClass().getName());
} catch (final Exception e) {
LOG.error("Error stopping plugin of type {}. \n{}", plugin.getClass(), e);
}
}
LOG.debug("Plugin shutdown complete.");
state = Lifecycle.STOPPED;
}
private static URL pathToURIOrNull(final Path p) {
try {
return p.toUri().toURL();
} catch (final MalformedURLException e) {
return null;
}
}
@VisibleForTesting
List<PantheonPlugin> getPlugins() {
return Collections.unmodifiableList(plugins);
}
}

@ -0,0 +1,2 @@
/** This package will be hidden from external users once Pantheon migrates to Java 11. */
package tech.pegasys.pantheon.plugins.internal;

@ -0,0 +1,34 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.plugins.services;
import java.util.function.Consumer;
public interface PantheonEvents {
/**
* Returns the raw RLP of a block that Pantheon has receieved and that has passed basic validation
* checks.
*
* @param blockJSONListener The listener that will accept a JSON string as the event.
* @return an object to be used as an identifier when de-registering the event.
*/
Object addBlockAddedListener(Consumer<String> blockJSONListener);
/**
* Remove the blockAdded listener from pantheon notifications.
*
* @param listenerIdentifier The instance that was returned from addBlockAddedListener;
*/
void removeBlockAddedObserver(Object listenerIdentifier);
}

@ -0,0 +1,27 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.plugins.services;
/** This service will be available during the registration callbacks. */
public interface PicoCLIOptions {
/**
* During the registration callback plugins can register CLI options that should be added to
* Pantheon's CLI startup.
*
* @param namespace A namespace prefix. All registered options must start with this prefix
* @param optionObject The instance of the object to be inspected. PicoCLI will reflect the fields
* of this object to extract the CLI options.
*/
void addPicoCLIOptions(String namespace, Object optionObject);
}

@ -0,0 +1,126 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.plugins;
import tech.pegasys.pantheon.plugins.services.PantheonEvents;
import tech.pegasys.pantheon.plugins.services.PicoCLIOptions;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Collections;
import java.util.Optional;
import com.google.auto.service.AutoService;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import picocli.CommandLine.Option;
@AutoService(PantheonPlugin.class)
public class TestPlugin implements PantheonPlugin {
private static final Logger LOG = LogManager.getLogger();
private PantheonContext context;
private Optional<Object> listenerReference;
@Option(names = "--Xtest-option", hidden = true, defaultValue = "UNSET")
String testOption = System.getProperty("testPlugin.testOption");
private String state = "uninited";
@Override
public void register(final PantheonContext context) {
LOG.info("Registring. Test Option is '{}'", testOption);
state = "registering";
if ("FAILREGISTER".equals(testOption)) {
state = "failregister";
throw new RuntimeException("I was told to fail at registration");
}
this.context = context;
context
.getService(PicoCLIOptions.class)
.ifPresent(
picoCLIOptions -> picoCLIOptions.addPicoCLIOptions("Test Plugin", TestPlugin.this));
writeSignal("registered");
state = "registered";
}
@Override
public void start() {
LOG.info("Starting. Test Option is '{}'", testOption);
state = "starting";
if ("FAILSTART".equals(testOption)) {
state = "failstart";
throw new RuntimeException("I was told to fail at startup");
}
listenerReference =
context
.getService(PantheonEvents.class)
.map(
pantheonEvents ->
pantheonEvents.addBlockAddedListener(
s -> System.out.println("BlockAdded - " + s)));
writeSignal("started");
state = "started";
}
@Override
public void stop() {
LOG.info("Stopping. Test Option is '{}'", testOption);
state = "stopping";
if ("FAILSTOP".equals(testOption)) {
state = "failstop";
throw new RuntimeException("I was told to fail at stop");
}
listenerReference.ifPresent(
reference ->
context
.getService(PantheonEvents.class)
.ifPresent(pantheonEvents -> pantheonEvents.removeBlockAddedObserver(reference)));
writeSignal("stopped");
state = "stopped";
}
/** State is used to signal unit tests about the lifecycle */
public String getState() {
return state;
}
/** This is used to signal to the acceptance test that certain tasks were completed. */
private void writeSignal(final String signal) {
try {
final File callbackFile =
new File(System.getProperty("pantheon.plugins.dir", "plugins"), "testPlugin." + signal);
if (!callbackFile.getParentFile().exists()) {
callbackFile.getParentFile().mkdirs();
callbackFile.getParentFile().deleteOnExit();
}
Files.write(
callbackFile.toPath(),
Collections.singletonList(
signal + "\ntestOption=" + testOption + "\nid=" + System.identityHashCode(this)));
callbackFile.deleteOnExit();
} catch (final IOException ioe) {
throw new RuntimeException(ioe);
}
}
}

@ -0,0 +1,157 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.plugins.internal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import tech.pegasys.pantheon.plugins.PantheonPlugin;
import tech.pegasys.pantheon.plugins.TestPlugin;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import org.assertj.core.api.ThrowableAssert;
import org.junit.After;
import org.junit.BeforeClass;
import org.junit.Test;
public class PantheonPluginContextImplTest {
@BeforeClass
public static void createFakePluginDir() throws IOException {
if (System.getProperty("pantheon.plugins.dir") == null) {
final Path pluginDir = Files.createTempDirectory("pantheonTest");
pluginDir.toFile().deleteOnExit();
System.setProperty("pantheon.plugins.dir", pluginDir.toAbsolutePath().toString());
}
}
@After
public void clearTestPluginState() {
System.clearProperty("testPlugin.testOption");
}
@Test
public void verifyEverythingGoesSmoothly() {
final PantheonPluginContextImpl contextImpl = new PantheonPluginContextImpl();
assertThat(contextImpl.getPlugins()).isEmpty();
contextImpl.registerPlugins(new File(".").toPath());
assertThat(contextImpl.getPlugins()).isNotEmpty();
final Optional<TestPlugin> testPluginOptional = findTestPlugin(contextImpl.getPlugins());
assertThat(testPluginOptional).isPresent();
final TestPlugin testPlugin = testPluginOptional.get();
assertThat(testPlugin.getState()).isEqualTo("registered");
contextImpl.startPlugins();
assertThat(testPlugin.getState()).isEqualTo("started");
contextImpl.stopPlugins();
assertThat(testPlugin.getState()).isEqualTo("stopped");
}
@Test
public void registrationErrorsHandledSmoothly() {
final PantheonPluginContextImpl contextImpl = new PantheonPluginContextImpl();
System.setProperty("testPlugin.testOption", "FAILREGISTER");
assertThat(contextImpl.getPlugins()).isEmpty();
contextImpl.registerPlugins(new File(".").toPath());
assertThat(contextImpl.getPlugins()).isEmpty();
contextImpl.startPlugins();
assertThat(contextImpl.getPlugins()).isEmpty();
contextImpl.stopPlugins();
assertThat(contextImpl.getPlugins()).isEmpty();
}
@Test
public void startErrorsHandledSmoothly() {
final PantheonPluginContextImpl contextImpl = new PantheonPluginContextImpl();
System.setProperty("testPlugin.testOption", "FAILSTART");
assertThat(contextImpl.getPlugins()).isEmpty();
contextImpl.registerPlugins(new File(".").toPath());
assertThat(contextImpl.getPlugins()).isNotEmpty();
final Optional<TestPlugin> testPluginOptional = findTestPlugin(contextImpl.getPlugins());
assertThat(testPluginOptional).isPresent();
final TestPlugin testPlugin = testPluginOptional.get();
assertThat(testPlugin.getState()).isEqualTo("registered");
contextImpl.startPlugins();
assertThat(testPlugin.getState()).isEqualTo("failstart");
assertThat(contextImpl.getPlugins()).isEmpty();
contextImpl.stopPlugins();
assertThat(contextImpl.getPlugins()).isEmpty();
}
@Test
public void stopErrorsHandledSmoothly() {
final PantheonPluginContextImpl contextImpl = new PantheonPluginContextImpl();
System.setProperty("testPlugin.testOption", "FAILSTOP");
assertThat(contextImpl.getPlugins()).isEmpty();
contextImpl.registerPlugins(new File(".").toPath());
assertThat(contextImpl.getPlugins()).isNotEmpty();
final Optional<TestPlugin> testPluginOptional = findTestPlugin(contextImpl.getPlugins());
assertThat(testPluginOptional).isPresent();
final TestPlugin testPlugin = testPluginOptional.get();
assertThat(testPlugin.getState()).isEqualTo("registered");
contextImpl.startPlugins();
assertThat(testPlugin.getState()).isEqualTo("started");
contextImpl.stopPlugins();
assertThat(testPlugin.getState()).isEqualTo("failstop");
}
@Test
public void lifecycleExceptions() throws Throwable {
final PantheonPluginContextImpl contextImpl = new PantheonPluginContextImpl();
final ThrowableAssert.ThrowingCallable registerPlugins =
() -> contextImpl.registerPlugins(new File(".").toPath());
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(contextImpl::startPlugins);
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(contextImpl::stopPlugins);
registerPlugins.call();
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(registerPlugins);
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(contextImpl::stopPlugins);
contextImpl.startPlugins();
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(registerPlugins);
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(contextImpl::startPlugins);
contextImpl.stopPlugins();
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(registerPlugins);
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(contextImpl::startPlugins);
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(contextImpl::stopPlugins);
}
private Optional<TestPlugin> findTestPlugin(final List<PantheonPlugin> plugins) {
return plugins.stream()
.filter(p -> p instanceof TestPlugin)
.map(p -> (TestPlugin) p)
.findFirst();
}
}

@ -35,6 +35,7 @@ include 'ethereum:trie'
include 'metrics:core'
include 'metrics:rocksdb'
include 'pantheon'
include 'plugins'
include 'services:kvstore'
include 'services:pipeline'
include 'services:tasks'

Loading…
Cancel
Save