diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/plugins/PantheonEventsPluginTest.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/plugins/PantheonEventsPluginTest.java new file mode 100644 index 0000000000..77fbb3acbf --- /dev/null +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/plugins/PantheonEventsPluginTest.java @@ -0,0 +1,61 @@ +/* + * 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 tech.pegasys.pantheon.tests.acceptance.dsl.AcceptanceTestBase; +import tech.pegasys.pantheon.tests.acceptance.dsl.node.PantheonNode; + +import java.io.File; +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.Test; + +public class PantheonEventsPluginTest extends AcceptanceTestBase { + private PantheonNode pluginNode; + private PantheonNode minerNode; + + @Before + public void setUp() throws Exception { + minerNode = pantheon.createMinerNode("minerNode"); + pluginNode = + pantheon.createPluginsNode( + "node1", Collections.singletonList("testPlugin"), Collections.emptyList()); + cluster.start(pluginNode, minerNode); + } + + @Test + public void blockIsAnnounded() { + waitForFile(pluginNode.homeDirectory().resolve("plugins/newBlock.2")); + } + + private void waitForFile(final Path path) { + final File file = path.toFile(); + Awaitility.waitAtMost(30, TimeUnit.SECONDS) + .until( + () -> { + if (file.exists()) { + try (final Stream s = Files.lines(path)) { + return s.count() > 0; + } + } else { + return false; + } + }); + } +} diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/plugins/PluginsAcceptanceTest.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/plugins/PicoCLIOptionsPluginTest.java similarity index 97% rename from acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/plugins/PluginsAcceptanceTest.java rename to acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/plugins/PicoCLIOptionsPluginTest.java index 7da7405432..3519ba688f 100644 --- a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/plugins/PluginsAcceptanceTest.java +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/plugins/PicoCLIOptionsPluginTest.java @@ -30,7 +30,7 @@ import org.junit.Before; import org.junit.Ignore; import org.junit.Test; -public class PluginsAcceptanceTest extends AcceptanceTestBase { +public class PicoCLIOptionsPluginTest extends AcceptanceTestBase { private PantheonNode node; // context: https://en.wikipedia.org/wiki/The_Magic_Words_are_Squeamish_Ossifrage diff --git a/ethereum/eth/src/main/java/tech/pegasys/pantheon/ethereum/eth/manager/EthProtocolManager.java b/ethereum/eth/src/main/java/tech/pegasys/pantheon/ethereum/eth/manager/EthProtocolManager.java index 0f96453934..298d1407f1 100644 --- a/ethereum/eth/src/main/java/tech/pegasys/pantheon/ethereum/eth/manager/EthProtocolManager.java +++ b/ethereum/eth/src/main/java/tech/pegasys/pantheon/ethereum/eth/manager/EthProtocolManager.java @@ -140,6 +140,10 @@ public class EthProtocolManager implements ProtocolManager, MinedBlockObserver { return ethContext; } + public BlockBroadcaster getBlockBroadcaster() { + return blockBroadcaster; + } + @Override public String getSupportedProtocol() { return EthProtocol.NAME; diff --git a/ethereum/eth/src/main/java/tech/pegasys/pantheon/ethereum/eth/sync/BlockBroadcaster.java b/ethereum/eth/src/main/java/tech/pegasys/pantheon/ethereum/eth/sync/BlockBroadcaster.java index 9c256d508a..3c8ccb98b0 100644 --- a/ethereum/eth/src/main/java/tech/pegasys/pantheon/ethereum/eth/sync/BlockBroadcaster.java +++ b/ethereum/eth/src/main/java/tech/pegasys/pantheon/ethereum/eth/sync/BlockBroadcaster.java @@ -16,8 +16,11 @@ import tech.pegasys.pantheon.ethereum.core.Block; import tech.pegasys.pantheon.ethereum.eth.manager.EthContext; import tech.pegasys.pantheon.ethereum.eth.messages.NewBlockMessage; import tech.pegasys.pantheon.ethereum.p2p.api.PeerConnection; +import tech.pegasys.pantheon.util.Subscribers; import tech.pegasys.pantheon.util.uint.UInt256; +import java.util.function.Consumer; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -25,12 +28,22 @@ public class BlockBroadcaster { private static final Logger LOG = LogManager.getLogger(); private final EthContext ethContext; + private final Subscribers> blockPropagatedSubscribers = new Subscribers<>(); public BlockBroadcaster(final EthContext ethContext) { this.ethContext = ethContext; } + public long subscribePropagateNewBlocks(final Consumer callback) { + return blockPropagatedSubscribers.subscribe(callback); + } + + public void unsubscribePropagateNewBlocks(final long id) { + blockPropagatedSubscribers.unsubscribe(id); + } + public void propagate(final Block block, final UInt256 totalDifficulty) { + blockPropagatedSubscribers.forEach(listener -> listener.accept(block)); final NewBlockMessage newBlockMessage = NewBlockMessage.create(block, totalDifficulty); ethContext .getEthPeers() diff --git a/ethereum/eth/src/main/java/tech/pegasys/pantheon/ethereum/eth/sync/DefaultSynchronizer.java b/ethereum/eth/src/main/java/tech/pegasys/pantheon/ethereum/eth/sync/DefaultSynchronizer.java index d8d6fd6f57..404781ebc5 100644 --- a/ethereum/eth/src/main/java/tech/pegasys/pantheon/ethereum/eth/sync/DefaultSynchronizer.java +++ b/ethereum/eth/src/main/java/tech/pegasys/pantheon/ethereum/eth/sync/DefaultSynchronizer.java @@ -55,6 +55,7 @@ public class DefaultSynchronizer implements Synchronizer { final ProtocolSchedule protocolSchedule, final ProtocolContext protocolContext, final WorldStateStorage worldStateStorage, + final BlockBroadcaster blockBroadcaster, final EthContext ethContext, final SyncState syncState, final Path dataDirectory, @@ -78,7 +79,7 @@ public class DefaultSynchronizer implements Synchronizer { syncState, new PendingBlocks(), metricsSystem, - new BlockBroadcaster(ethContext)); + blockBroadcaster); this.fullSyncDownloader = new FullSyncDownloader<>( diff --git a/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java b/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java index 8a4acbb8dc..559357af28 100644 --- a/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java +++ b/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java @@ -66,7 +66,10 @@ 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.PantheonEvents; import tech.pegasys.pantheon.plugins.services.PicoCLIOptions; +import tech.pegasys.pantheon.services.PantheonEventsImpl; +import tech.pegasys.pantheon.services.PicoCLIOptionsImpl; import tech.pegasys.pantheon.services.kvstore.RocksDbConfiguration; import tech.pegasys.pantheon.util.BlockImporter; import tech.pegasys.pantheon.util.InvalidConfigurationException; @@ -644,9 +647,7 @@ public class PantheonCommand implements DefaultCommandValues, Runnable { "Ethereum Wire Protocol", ethereumWireConfigurationBuilder)); - pantheonPluginContext.addService( - PicoCLIOptions.class, - (namespace, optionObject) -> commandLine.addMixin("Plugin " + namespace, optionObject)); + pantheonPluginContext.addService(PicoCLIOptions.class, new PicoCLIOptionsImpl(commandLine)); pantheonPluginContext.registerPlugins(pluginsDir()); // Create a handler that will search for a config file option and use it for @@ -729,10 +730,16 @@ public class PantheonCommand implements DefaultCommandValues, Runnable { ensureAllNodesAreInWhitelist( staticNodes.stream().map(EnodeURL::toURI).collect(Collectors.toList()), p)); + final PantheonController pantheonController = buildController(); + final MetricsConfiguration metricsConfiguration = metricsConfiguration(); + + pantheonPluginContext.addService( + PantheonEvents.class, + new PantheonEventsImpl((pantheonController.getProtocolManager().getBlockBroadcaster()))); pantheonPluginContext.startPlugins(); synchronize( - buildController(), + pantheonController, p2pEnabled, peerDiscoveryEnabled, ethNetworkConfig, @@ -742,7 +749,7 @@ public class PantheonCommand implements DefaultCommandValues, Runnable { graphQLRpcConfiguration, jsonRpcConfiguration, webSocketConfiguration, - metricsConfiguration(), + metricsConfiguration, permissioningConfiguration, staticNodes); } catch (final Exception e) { @@ -1234,7 +1241,7 @@ public class PantheonCommand implements DefaultCommandValues, Runnable { if (isFullInstantiation()) { final String pluginsDir = System.getProperty("pantheon.plugins.dir"); if (pluginsDir == null) { - return new File("plugins").toPath(); + return new File(System.getProperty("pantheon.home", "."), "plugins").toPath(); } else { return new File(pluginsDir).toPath(); } diff --git a/pantheon/src/main/java/tech/pegasys/pantheon/controller/PantheonController.java b/pantheon/src/main/java/tech/pegasys/pantheon/controller/PantheonController.java index 53df5dfdae..ae3fc75c05 100644 --- a/pantheon/src/main/java/tech/pegasys/pantheon/controller/PantheonController.java +++ b/pantheon/src/main/java/tech/pegasys/pantheon/controller/PantheonController.java @@ -20,6 +20,7 @@ import tech.pegasys.pantheon.ethereum.ProtocolContext; import tech.pegasys.pantheon.ethereum.blockcreation.MiningCoordinator; import tech.pegasys.pantheon.ethereum.core.PrivacyParameters; import tech.pegasys.pantheon.ethereum.core.Synchronizer; +import tech.pegasys.pantheon.ethereum.eth.manager.EthProtocolManager; import tech.pegasys.pantheon.ethereum.eth.transactions.TransactionPool; import tech.pegasys.pantheon.ethereum.jsonrpc.RpcApi; import tech.pegasys.pantheon.ethereum.jsonrpc.internal.methods.JsonRpcMethod; @@ -35,6 +36,7 @@ public class PantheonController implements java.io.Closeable { public static final String DATABASE_PATH = "database"; private final ProtocolSchedule protocolSchedule; private final ProtocolContext protocolContext; + private final EthProtocolManager ethProtocolManager; private final GenesisConfigOptions genesisConfigOptions; private final SubProtocolConfiguration subProtocolConfiguration; private final KeyPair keyPair; @@ -49,6 +51,7 @@ public class PantheonController implements java.io.Closeable { PantheonController( final ProtocolSchedule protocolSchedule, final ProtocolContext protocolContext, + final EthProtocolManager ethProtocolManager, final GenesisConfigOptions genesisConfigOptions, final SubProtocolConfiguration subProtocolConfiguration, final Synchronizer synchronizer, @@ -60,6 +63,7 @@ public class PantheonController implements java.io.Closeable { final Runnable close) { this.protocolSchedule = protocolSchedule; this.protocolContext = protocolContext; + this.ethProtocolManager = ethProtocolManager; this.genesisConfigOptions = genesisConfigOptions; this.subProtocolConfiguration = subProtocolConfiguration; this.synchronizer = synchronizer; @@ -79,6 +83,10 @@ public class PantheonController implements java.io.Closeable { return protocolSchedule; } + public EthProtocolManager getProtocolManager() { + return ethProtocolManager; + } + public GenesisConfigOptions getGenesisConfigOptions() { return genesisConfigOptions; } diff --git a/pantheon/src/main/java/tech/pegasys/pantheon/controller/PantheonControllerBuilder.java b/pantheon/src/main/java/tech/pegasys/pantheon/controller/PantheonControllerBuilder.java index 06e9ed5b29..36e83f48a7 100644 --- a/pantheon/src/main/java/tech/pegasys/pantheon/controller/PantheonControllerBuilder.java +++ b/pantheon/src/main/java/tech/pegasys/pantheon/controller/PantheonControllerBuilder.java @@ -64,6 +64,7 @@ public abstract class PantheonControllerBuilder { protected GenesisConfigFile genesisConfig; protected SynchronizerConfiguration syncConfig; + protected EthProtocolManager ethProtocolManager; protected EthereumWireProtocolConfiguration ethereumWireProtocolConfiguration; protected Integer networkId; protected MiningParameters miningParameters; @@ -196,8 +197,7 @@ public abstract class PantheonControllerBuilder { final MutableBlockchain blockchain = protocolContext.getBlockchain(); final boolean fastSyncEnabled = syncConfig.syncMode().equals(SyncMode.FAST); - final EthProtocolManager ethProtocolManager = - createEthProtocolManager(protocolContext, fastSyncEnabled); + ethProtocolManager = createEthProtocolManager(protocolContext, fastSyncEnabled); final SyncState syncState = new SyncState(blockchain, ethProtocolManager.ethContext().getEthPeers()); final Synchronizer synchronizer = @@ -206,6 +206,7 @@ public abstract class PantheonControllerBuilder { protocolSchedule, protocolContext, protocolContext.getWorldStateArchive().getStorage(), + ethProtocolManager.getBlockBroadcaster(), ethProtocolManager.ethContext(), syncState, dataDirectory, @@ -250,6 +251,7 @@ public abstract class PantheonControllerBuilder { return new PantheonController<>( protocolSchedule, protocolContext, + ethProtocolManager, genesisConfig.getConfigOptions(), subProtocolConfiguration, synchronizer, diff --git a/pantheon/src/main/java/tech/pegasys/pantheon/services/PantheonEventsImpl.java b/pantheon/src/main/java/tech/pegasys/pantheon/services/PantheonEventsImpl.java new file mode 100644 index 0000000000..5028bd3371 --- /dev/null +++ b/pantheon/src/main/java/tech/pegasys/pantheon/services/PantheonEventsImpl.java @@ -0,0 +1,53 @@ +/* + * 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.services; + +import tech.pegasys.pantheon.ethereum.core.Block; +import tech.pegasys.pantheon.ethereum.eth.sync.BlockBroadcaster; +import tech.pegasys.pantheon.plugins.services.PantheonEvents; + +import com.google.common.collect.ImmutableMap; +import io.vertx.core.json.Json; + +public class PantheonEventsImpl implements PantheonEvents { + private final BlockBroadcaster blockBroadcaster; + + public PantheonEventsImpl(final BlockBroadcaster blockBroadcaster) { + this.blockBroadcaster = blockBroadcaster; + } + + @Override + public Object addNewBlockPropagatedListener(final NewBlockPropagatedListener listener) { + return blockBroadcaster.subscribePropagateNewBlocks( + block -> dispatchNewBlockPropagatedMessage(block, listener)); + } + + @Override + public void removeNewBlockPropagatedListener(final Object listenerIdentifier) { + if (listenerIdentifier instanceof Long) { + blockBroadcaster.unsubscribePropagateNewBlocks((Long) listenerIdentifier); + } + } + + private void dispatchNewBlockPropagatedMessage( + final Block block, final NewBlockPropagatedListener listener) { + final ImmutableMap result = + new ImmutableMap.Builder<>() + .put("type", "NewBlock") + .put("blockHash", block.getHash().toString()) + .put("blockNumber", block.getHeader().getNumber()) + .put("timestamp", block.getHeader().getTimestamp()) + .build(); + listener.newBlockPropagated(Json.encode(result)); + } +} diff --git a/pantheon/src/main/java/tech/pegasys/pantheon/services/PicoCLIOptionsImpl.java b/pantheon/src/main/java/tech/pegasys/pantheon/services/PicoCLIOptionsImpl.java new file mode 100644 index 0000000000..07d5581d09 --- /dev/null +++ b/pantheon/src/main/java/tech/pegasys/pantheon/services/PicoCLIOptionsImpl.java @@ -0,0 +1,31 @@ +/* + * 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.services; + +import tech.pegasys.pantheon.plugins.services.PicoCLIOptions; + +import picocli.CommandLine; + +public class PicoCLIOptionsImpl implements PicoCLIOptions { + + private final CommandLine commandLine; + + public PicoCLIOptionsImpl(final CommandLine commandLine) { + this.commandLine = commandLine; + } + + @Override + public void addPicoCLIOptions(final String namespace, final Object optionObject) { + commandLine.addMixin("Plugin " + namespace, optionObject); + } +} diff --git a/pantheon/src/test/java/tech/pegasys/pantheon/cli/CommandTestAbstract.java b/pantheon/src/test/java/tech/pegasys/pantheon/cli/CommandTestAbstract.java index 6f0249f637..9d0adba022 100644 --- a/pantheon/src/test/java/tech/pegasys/pantheon/cli/CommandTestAbstract.java +++ b/pantheon/src/test/java/tech/pegasys/pantheon/cli/CommandTestAbstract.java @@ -26,6 +26,8 @@ import tech.pegasys.pantheon.controller.PantheonController; import tech.pegasys.pantheon.controller.PantheonControllerBuilder; import tech.pegasys.pantheon.crypto.SECP256K1.KeyPair; import tech.pegasys.pantheon.ethereum.eth.EthereumWireProtocolConfiguration; +import tech.pegasys.pantheon.ethereum.eth.manager.EthProtocolManager; +import tech.pegasys.pantheon.ethereum.eth.sync.BlockBroadcaster; import tech.pegasys.pantheon.ethereum.eth.sync.SynchronizerConfiguration; import tech.pegasys.pantheon.ethereum.graphqlrpc.GraphQLRpcConfiguration; import tech.pegasys.pantheon.ethereum.jsonrpc.JsonRpcConfiguration; @@ -55,7 +57,6 @@ import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import picocli.CommandLine; import picocli.CommandLine.Help.Ansi; @@ -78,6 +79,8 @@ public abstract class CommandTestAbstract { @Mock PantheonController.Builder mockControllerBuilderFactory; @Mock PantheonControllerBuilder mockControllerBuilder; + @Mock EthProtocolManager mockEthProtocolManager; + @Mock BlockBroadcaster mockBlockBroadcaster; @Mock SynchronizerConfiguration.Builder mockSyncConfBuilder; @Mock EthereumWireProtocolConfiguration.Builder mockEthereumWireProtocolConfigurationBuilder; @Mock SynchronizerConfiguration mockSyncConf; @@ -112,9 +115,9 @@ public abstract class CommandTestAbstract { @Before public void initMocks() throws Exception { - doReturn(mockControllerBuilder).when(mockControllerBuilderFactory).fromEthNetworkConfig(any()); + // doReturn used because of generic PantheonController - Mockito.doReturn(mockController).when(mockControllerBuilder).build(); + doReturn(mockControllerBuilder).when(mockControllerBuilderFactory).fromEthNetworkConfig(any()); when(mockControllerBuilder.synchronizerConfiguration(any())).thenReturn(mockControllerBuilder); when(mockControllerBuilder.ethereumWireProtocolConfiguration(any())) .thenReturn(mockControllerBuilder); @@ -129,6 +132,12 @@ public abstract class CommandTestAbstract { when(mockControllerBuilder.privacyParameters(any())).thenReturn(mockControllerBuilder); when(mockControllerBuilder.clock(any())).thenReturn(mockControllerBuilder); + // doReturn used because of generic PantheonController + doReturn(mockController).when(mockControllerBuilder).build(); + when(mockController.getProtocolManager()).thenReturn(mockEthProtocolManager); + + when(mockEthProtocolManager.getBlockBroadcaster()).thenReturn(mockBlockBroadcaster); + when(mockSyncConfBuilder.syncMode(any())).thenReturn(mockSyncConfBuilder); when(mockSyncConfBuilder.maxTrailingPeers(anyInt())).thenReturn(mockSyncConfBuilder); when(mockSyncConfBuilder.fastSyncMinimumPeerCount(anyInt())).thenReturn(mockSyncConfBuilder); diff --git a/pantheon/src/test/java/tech/pegasys/pantheon/services/PantheonEventsImplTest.java b/pantheon/src/test/java/tech/pegasys/pantheon/services/PantheonEventsImplTest.java new file mode 100644 index 0000000000..d691ba8ccc --- /dev/null +++ b/pantheon/src/test/java/tech/pegasys/pantheon/services/PantheonEventsImplTest.java @@ -0,0 +1,95 @@ +/* + * 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.services; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import tech.pegasys.pantheon.ethereum.core.Block; +import tech.pegasys.pantheon.ethereum.core.BlockBody; +import tech.pegasys.pantheon.ethereum.core.BlockHeaderTestFixture; +import tech.pegasys.pantheon.ethereum.eth.manager.EthContext; +import tech.pegasys.pantheon.ethereum.eth.manager.EthPeers; +import tech.pegasys.pantheon.ethereum.eth.sync.BlockBroadcaster; +import tech.pegasys.pantheon.util.uint.UInt256; + +import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class PantheonEventsImplTest { + + @Mock private EthPeers ethPeers; + @Mock private EthContext mockEthContext; + private BlockBroadcaster blockBroadcaster; + private PantheonEventsImpl serviceImpl; + + @Before + public void setUp() { + when(ethPeers.streamAvailablePeers()).thenReturn(Stream.empty()).thenReturn(Stream.empty()); + when(mockEthContext.getEthPeers()).thenReturn(ethPeers); + + blockBroadcaster = new BlockBroadcaster(mockEthContext); + serviceImpl = new PantheonEventsImpl(blockBroadcaster); + } + + @Test + public void eventFiresAfterSubscribe() { + final AtomicReference result = new AtomicReference<>(); + serviceImpl.addNewBlockPropagatedListener(result::set); + + assertThat(result.get()).isNull(); + blockBroadcaster.propagate(generateBlock(), UInt256.of(1)); + + assertThat(result.get()).isNotEmpty(); + } + + @Test + public void eventDoesNotFireAfterUnsubscribe() { + final AtomicReference result = new AtomicReference<>(); + final Object id = serviceImpl.addNewBlockPropagatedListener(result::set); + + assertThat(result.get()).isNull(); + blockBroadcaster.propagate(generateBlock(), UInt256.of(1)); + + serviceImpl.removeNewBlockPropagatedListener(id); + result.set(null); + + blockBroadcaster.propagate(generateBlock(), UInt256.of(1)); + assertThat(result.get()).isNull(); + } + + @Test + public void propagationWithoutSubscriptionsCompletes() { + blockBroadcaster.propagate(generateBlock(), UInt256.of(1)); + } + + @Test + public void uselessUnsubscribesCompletes() { + serviceImpl.removeNewBlockPropagatedListener("doesNotExist"); + serviceImpl.removeNewBlockPropagatedListener(5); + serviceImpl.removeNewBlockPropagatedListener(5L); + } + + private Block generateBlock() { + final BlockBody body = new BlockBody(Collections.emptyList(), Collections.emptyList()); + return new Block(new BlockHeaderTestFixture().buildHeader(), body); + } +} diff --git a/pantheon/src/test/java/tech/pegasys/pantheon/services/PicoCLIOptionsImplTest.java b/pantheon/src/test/java/tech/pegasys/pantheon/services/PicoCLIOptionsImplTest.java new file mode 100644 index 0000000000..6a1aae7b84 --- /dev/null +++ b/pantheon/src/test/java/tech/pegasys/pantheon/services/PicoCLIOptionsImplTest.java @@ -0,0 +1,80 @@ +/* + * 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.services; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import org.junit.Before; +import org.junit.Test; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.UnmatchedArgumentException; + +public class PicoCLIOptionsImplTest { + + @Command + static final class SimpleCommand { + + @Option(names = "--existing") + String existingOption = "defaultexisting"; + } + + static final class MixinOptions { + @Option(names = "--mixin") + String mixinOption = "defaultmixin"; + } + + private SimpleCommand command; + private MixinOptions mixin; + private CommandLine commandLine; + private PicoCLIOptionsImpl serviceImpl; + + @Before + public void setUp() throws Exception { + command = new SimpleCommand(); + mixin = new MixinOptions(); + commandLine = new CommandLine(command); + serviceImpl = new PicoCLIOptionsImpl(commandLine); + + serviceImpl.addPicoCLIOptions("Test 1", mixin); + } + + @Test + public void testSimpleOptionParse() { + commandLine.parseArgs("--existing", "1", "--mixin", "2"); + assertThat(command.existingOption).isEqualTo("1"); + assertThat(mixin.mixinOption).isEqualTo("2"); + } + + @Test + public void testUnsetOptionLeavesDefault() { + commandLine.parseArgs("--existing", "1"); + assertThat(command.existingOption).isEqualTo("1"); + assertThat(mixin.mixinOption).isEqualTo("defaultmixin"); + } + + @Test + public void testMixinOptionOnly() { + commandLine.parseArgs("--mixin", "2"); + assertThat(command.existingOption).isEqualTo("defaultexisting"); + assertThat(mixin.mixinOption).isEqualTo("2"); + } + + @Test + public void testNotExistantOptionsFail() { + assertThatExceptionOfType(UnmatchedArgumentException.class) + .isThrownBy(() -> commandLine.parseArgs("--does-not-exist", "1")); + } +} diff --git a/plugins/src/main/java/tech/pegasys/pantheon/plugins/services/PantheonEvents.java b/plugins/src/main/java/tech/pegasys/pantheon/plugins/services/PantheonEvents.java index 12a1506646..2b356989cd 100644 --- a/plugins/src/main/java/tech/pegasys/pantheon/plugins/services/PantheonEvents.java +++ b/plugins/src/main/java/tech/pegasys/pantheon/plugins/services/PantheonEvents.java @@ -12,23 +12,25 @@ */ 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 + * Returns the raw RLP of a block that Pantheon has received 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 blockJSONListener); + Object addNewBlockPropagatedListener(NewBlockPropagatedListener blockJSONListener); /** * Remove the blockAdded listener from pantheon notifications. * * @param listenerIdentifier The instance that was returned from addBlockAddedListener; */ - void removeBlockAddedObserver(Object listenerIdentifier); + void removeNewBlockPropagatedListener(Object listenerIdentifier); + + interface NewBlockPropagatedListener { + void newBlockPropagated(String jsonBlock); + } } diff --git a/plugins/src/test/java/tech/pegasys/pantheon/plugins/TestPantheonEventsPlugin.java b/plugins/src/test/java/tech/pegasys/pantheon/plugins/TestPantheonEventsPlugin.java new file mode 100644 index 0000000000..9364349862 --- /dev/null +++ b/plugins/src/test/java/tech/pegasys/pantheon/plugins/TestPantheonEventsPlugin.java @@ -0,0 +1,77 @@ +/* + * 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 java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Collections; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import com.google.auto.service.AutoService; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +@AutoService(PantheonPlugin.class) +public class TestPantheonEventsPlugin implements PantheonPlugin { + private static final Logger LOG = LogManager.getLogger(); + private Optional subscriptionId; + + private PantheonContext context; + private final AtomicInteger blockCounter = new AtomicInteger(); + + @Override + public void register(final PantheonContext context) { + this.context = context; + LOG.info("Regisgered"); + } + + @Override + public void start() { + subscriptionId = + context + .getService(PantheonEvents.class) + .map(events -> events.addNewBlockPropagatedListener(this::onBlockAnnounce)); + LOG.info("Listening with ID#" + subscriptionId); + } + + @Override + public void stop() { + subscriptionId.ifPresent( + id -> + context + .getService(PantheonEvents.class) + .ifPresent(pantheonEvents -> pantheonEvents.removeNewBlockPropagatedListener(id))); + LOG.info("No longer listening with ID#" + subscriptionId); + } + + private void onBlockAnnounce(final String json) { + final int blockCount = blockCounter.incrementAndGet(); + LOG.info("I got a new block! (I've seen {}) - {}", blockCount, json); + try { + final File callbackFile = + new File(System.getProperty("pantheon.plugins.dir", "plugins"), "newBlock." + blockCount); + if (!callbackFile.getParentFile().exists()) { + callbackFile.getParentFile().mkdirs(); + callbackFile.getParentFile().deleteOnExit(); + } + Files.write(callbackFile.toPath(), Collections.singletonList(json)); + callbackFile.deleteOnExit(); + } catch (final IOException ioe) { + throw new RuntimeException(ioe); + } + } +} diff --git a/plugins/src/test/java/tech/pegasys/pantheon/plugins/TestPlugin.java b/plugins/src/test/java/tech/pegasys/pantheon/plugins/TestPicoCLIPlugin.java similarity index 77% rename from plugins/src/test/java/tech/pegasys/pantheon/plugins/TestPlugin.java rename to plugins/src/test/java/tech/pegasys/pantheon/plugins/TestPicoCLIPlugin.java index a5a1209a89..1742e8fd59 100644 --- a/plugins/src/test/java/tech/pegasys/pantheon/plugins/TestPlugin.java +++ b/plugins/src/test/java/tech/pegasys/pantheon/plugins/TestPicoCLIPlugin.java @@ -12,14 +12,12 @@ */ 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; @@ -27,14 +25,11 @@ import org.apache.logging.log4j.Logger; import picocli.CommandLine.Option; @AutoService(PantheonPlugin.class) -public class TestPlugin implements PantheonPlugin { +public class TestPicoCLIPlugin implements PantheonPlugin { private static final Logger LOG = LogManager.getLogger(); - private PantheonContext context; - private Optional listenerReference; - @Option(names = "--Xtest-option", hidden = true, defaultValue = "UNSET") - String testOption = System.getProperty("testPlugin.testOption"); + String testOption = System.getProperty("testPicoCLIPlugin.testOption"); private String state = "uninited"; @@ -48,11 +43,11 @@ public class TestPlugin implements PantheonPlugin { 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)); + picoCLIOptions -> + picoCLIOptions.addPicoCLIOptions("Test PicoCLI Plugin", TestPicoCLIPlugin.this)); writeSignal("registered"); state = "registered"; @@ -68,14 +63,6 @@ public class TestPlugin implements PantheonPlugin { 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"; } @@ -90,12 +77,6 @@ public class TestPlugin implements PantheonPlugin { 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"; } diff --git a/plugins/src/test/java/tech/pegasys/pantheon/plugins/internal/PantheonPluginContextImplTest.java b/plugins/src/test/java/tech/pegasys/pantheon/plugins/internal/PantheonPluginContextImplTest.java index a9db04fd9e..abe8f85b89 100644 --- a/plugins/src/test/java/tech/pegasys/pantheon/plugins/internal/PantheonPluginContextImplTest.java +++ b/plugins/src/test/java/tech/pegasys/pantheon/plugins/internal/PantheonPluginContextImplTest.java @@ -16,7 +16,7 @@ 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 tech.pegasys.pantheon.plugins.TestPicoCLIPlugin; import java.io.File; import java.io.IOException; @@ -43,7 +43,7 @@ public class PantheonPluginContextImplTest { @After public void clearTestPluginState() { - System.clearProperty("testPlugin.testOption"); + System.clearProperty("testPicoCLIPlugin.testOption"); } @Test @@ -54,75 +54,75 @@ public class PantheonPluginContextImplTest { contextImpl.registerPlugins(new File(".").toPath()); assertThat(contextImpl.getPlugins()).isNotEmpty(); - final Optional testPluginOptional = findTestPlugin(contextImpl.getPlugins()); + final Optional testPluginOptional = findTestPlugin(contextImpl.getPlugins()); assertThat(testPluginOptional).isPresent(); - final TestPlugin testPlugin = testPluginOptional.get(); - assertThat(testPlugin.getState()).isEqualTo("registered"); + final TestPicoCLIPlugin testPicoCLIPlugin = testPluginOptional.get(); + assertThat(testPicoCLIPlugin.getState()).isEqualTo("registered"); contextImpl.startPlugins(); - assertThat(testPlugin.getState()).isEqualTo("started"); + assertThat(testPicoCLIPlugin.getState()).isEqualTo("started"); contextImpl.stopPlugins(); - assertThat(testPlugin.getState()).isEqualTo("stopped"); + assertThat(testPicoCLIPlugin.getState()).isEqualTo("stopped"); } @Test public void registrationErrorsHandledSmoothly() { final PantheonPluginContextImpl contextImpl = new PantheonPluginContextImpl(); - System.setProperty("testPlugin.testOption", "FAILREGISTER"); + System.setProperty("testPicoCLIPlugin.testOption", "FAILREGISTER"); assertThat(contextImpl.getPlugins()).isEmpty(); contextImpl.registerPlugins(new File(".").toPath()); - assertThat(contextImpl.getPlugins()).isEmpty(); + assertThat(contextImpl.getPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); contextImpl.startPlugins(); - assertThat(contextImpl.getPlugins()).isEmpty(); + assertThat(contextImpl.getPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); contextImpl.stopPlugins(); - assertThat(contextImpl.getPlugins()).isEmpty(); + assertThat(contextImpl.getPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); } @Test public void startErrorsHandledSmoothly() { final PantheonPluginContextImpl contextImpl = new PantheonPluginContextImpl(); - System.setProperty("testPlugin.testOption", "FAILSTART"); + System.setProperty("testPicoCLIPlugin.testOption", "FAILSTART"); assertThat(contextImpl.getPlugins()).isEmpty(); contextImpl.registerPlugins(new File(".").toPath()); - assertThat(contextImpl.getPlugins()).isNotEmpty(); + assertThat(contextImpl.getPlugins()).extracting("class").contains(TestPicoCLIPlugin.class); - final Optional testPluginOptional = findTestPlugin(contextImpl.getPlugins()); + final Optional testPluginOptional = findTestPlugin(contextImpl.getPlugins()); assertThat(testPluginOptional).isPresent(); - final TestPlugin testPlugin = testPluginOptional.get(); - assertThat(testPlugin.getState()).isEqualTo("registered"); + final TestPicoCLIPlugin testPicoCLIPlugin = testPluginOptional.get(); + assertThat(testPicoCLIPlugin.getState()).isEqualTo("registered"); contextImpl.startPlugins(); - assertThat(testPlugin.getState()).isEqualTo("failstart"); - assertThat(contextImpl.getPlugins()).isEmpty(); + assertThat(testPicoCLIPlugin.getState()).isEqualTo("failstart"); + assertThat(contextImpl.getPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); contextImpl.stopPlugins(); - assertThat(contextImpl.getPlugins()).isEmpty(); + assertThat(contextImpl.getPlugins()).isNotInstanceOfAny(TestPicoCLIPlugin.class); } @Test public void stopErrorsHandledSmoothly() { final PantheonPluginContextImpl contextImpl = new PantheonPluginContextImpl(); - System.setProperty("testPlugin.testOption", "FAILSTOP"); + System.setProperty("testPicoCLIPlugin.testOption", "FAILSTOP"); assertThat(contextImpl.getPlugins()).isEmpty(); contextImpl.registerPlugins(new File(".").toPath()); - assertThat(contextImpl.getPlugins()).isNotEmpty(); + assertThat(contextImpl.getPlugins()).extracting("class").contains(TestPicoCLIPlugin.class); - final Optional testPluginOptional = findTestPlugin(contextImpl.getPlugins()); + final Optional testPluginOptional = findTestPlugin(contextImpl.getPlugins()); assertThat(testPluginOptional).isPresent(); - final TestPlugin testPlugin = testPluginOptional.get(); - assertThat(testPlugin.getState()).isEqualTo("registered"); + final TestPicoCLIPlugin testPicoCLIPlugin = testPluginOptional.get(); + assertThat(testPicoCLIPlugin.getState()).isEqualTo("registered"); contextImpl.startPlugins(); - assertThat(testPlugin.getState()).isEqualTo("started"); + assertThat(testPicoCLIPlugin.getState()).isEqualTo("started"); contextImpl.stopPlugins(); - assertThat(testPlugin.getState()).isEqualTo("failstop"); + assertThat(testPicoCLIPlugin.getState()).isEqualTo("failstop"); } @Test @@ -148,10 +148,10 @@ public class PantheonPluginContextImplTest { assertThatExceptionOfType(IllegalStateException.class).isThrownBy(contextImpl::stopPlugins); } - private Optional findTestPlugin(final List plugins) { + private Optional findTestPlugin(final List plugins) { return plugins.stream() - .filter(p -> p instanceof TestPlugin) - .map(p -> (TestPlugin) p) + .filter(p -> p instanceof TestPicoCLIPlugin) + .map(p -> (TestPicoCLIPlugin) p) .findFirst(); } }