mirror of
https://github.com/corda/corda.git
synced 2025-06-21 00:23:09 +00:00
Merge remote-tracking branch 'remotes/open/master' into merges/CORDA-792
# Conflicts: # .idea/compiler.xml # build.gradle # node/src/integration-test/kotlin/net/corda/node/services/rpc/RpcSslTest.kt # node/src/main/kotlin/net/corda/node/internal/NodeStartup.kt # node/src/main/kotlin/net/corda/node/shell/CordaAuthenticationPlugin.kt # node/src/main/kotlin/net/corda/node/shell/CordaSSHAuthInfo.kt # node/src/main/kotlin/net/corda/node/shell/RPCOpsWithContext.kt # node/src/test/kotlin/net/corda/node/services/config/NodeConfigurationImplTest.kt # settings.gradle # testing/test-common/src/main/kotlin/net/corda/testing/common/internal/UnsafeCertificatesFactory.kt # tools/shell/src/integration-test/kotlin/net/corda/tools/shell/SSHServerTest.kt # tools/shell/src/main/java/net/corda/tools/shell/FlowShellCommand.java # tools/shell/src/main/java/net/corda/tools/shell/RunShellCommand.java # tools/shell/src/main/java/net/corda/tools/shell/StartShellCommand.java # tools/shell/src/main/kotlin/net/corda/tools/shell/FlowWatchPrintingSubscriber.kt # tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShell.kt # tools/shell/src/main/kotlin/net/corda/tools/shell/InteractiveShellCommand.kt # tools/shell/src/main/kotlin/net/corda/tools/shell/utlities/ANSIProgressRenderer.kt # tools/shell/src/main/resources/net/corda/tools/shell/base/login.groovy # tools/shell/src/test/kotlin/net/corda/tools/shell/CustomTypeJsonParsingTests.kt # tools/shell/src/test/kotlin/net/corda/tools/shell/InteractiveShellTest.kt
This commit is contained in:
@ -0,0 +1,239 @@
|
||||
package net.corda.tools.shell
|
||||
|
||||
import com.google.common.io.Files
|
||||
import com.jcraft.jsch.ChannelExec
|
||||
import com.jcraft.jsch.JSch
|
||||
import net.corda.core.identity.CordaX500Name
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.core.utilities.getOrThrow
|
||||
import net.corda.node.services.Permissions
|
||||
import net.corda.node.services.Permissions.Companion.all
|
||||
import net.corda.testing.common.internal.withCertificates
|
||||
import net.corda.testing.common.internal.withKeyStores
|
||||
import net.corda.testing.core.ALICE_NAME
|
||||
import net.corda.testing.driver.DriverParameters
|
||||
import net.corda.testing.driver.driver
|
||||
import net.corda.testing.driver.internal.RandomFree
|
||||
import net.corda.testing.internal.useSslRpcOverrides
|
||||
import net.corda.testing.node.User
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQNotConnectedException
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQSecurityException
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.bouncycastle.util.io.Streams
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class InteractiveShellIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun `shell should not log in with invalid credentials`() {
|
||||
val user = User("u", "p", setOf())
|
||||
driver(DriverParameters(isDebug = true, startNodesInProcess = true, portAllocation = RandomFree)) {
|
||||
val nodeFuture = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user), startInSameProcess = true)
|
||||
val node = nodeFuture.getOrThrow()
|
||||
|
||||
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
|
||||
user = "fake", password = "fake",
|
||||
hostAndPort = node.rpcAddress)
|
||||
InteractiveShell.startShell(conf)
|
||||
|
||||
assertThatThrownBy { InteractiveShell.nodeInfo() }.isInstanceOf(ActiveMQSecurityException::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell should log in with valid crentials`() {
|
||||
val user = User("u", "p", setOf())
|
||||
driver {
|
||||
val nodeFuture = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user), startInSameProcess = true)
|
||||
val node = nodeFuture.getOrThrow()
|
||||
|
||||
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
|
||||
user = user.username, password = user.password,
|
||||
hostAndPort = node.rpcAddress)
|
||||
|
||||
InteractiveShell.startShell(conf)
|
||||
InteractiveShell.nodeInfo()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell should log in with ssl`() {
|
||||
val user = User("mark", "dadada", setOf(all()))
|
||||
withCertificates { server, client, createSelfSigned, createSignedBy ->
|
||||
val rootCertificate = createSelfSigned(CordaX500Name("SystemUsers/Node", "IT", "R3 London", "London", "London", "GB"))
|
||||
val markCertificate = createSignedBy(CordaX500Name("shell", "IT", "R3 London", "London", "London", "GB"), rootCertificate)
|
||||
|
||||
// truststore needs to contain root CA for how the driver works...
|
||||
server.keyStore["cordaclienttls"] = rootCertificate
|
||||
server.trustStore["cordaclienttls"] = rootCertificate
|
||||
server.trustStore["shell"] = markCertificate
|
||||
|
||||
client.keyStore["shell"] = markCertificate
|
||||
client.trustStore["cordaclienttls"] = rootCertificate
|
||||
|
||||
withKeyStores(server, client) { nodeSslOptions, clientSslOptions ->
|
||||
var successful = false
|
||||
driver(DriverParameters(isDebug = true, startNodesInProcess = true, portAllocation = RandomFree)) {
|
||||
startNode(rpcUsers = listOf(user), customOverrides = nodeSslOptions.useSslRpcOverrides()).getOrThrow().use { node ->
|
||||
|
||||
val sslConfiguration = ShellSslOptions(clientSslOptions.sslKeystore, clientSslOptions.keyStorePassword,
|
||||
clientSslOptions.trustStoreFile, clientSslOptions.trustStorePassword)
|
||||
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
|
||||
user = user.username, password = user.password,
|
||||
hostAndPort = node.rpcAddress,
|
||||
ssl = sslConfiguration)
|
||||
|
||||
InteractiveShell.startShell(conf)
|
||||
|
||||
InteractiveShell.nodeInfo()
|
||||
successful = true
|
||||
}
|
||||
}
|
||||
assertThat(successful).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell shoud not log in without ssl keystore`() {
|
||||
val user = User("mark", "dadada", setOf("ALL"))
|
||||
withCertificates { server, client, createSelfSigned, createSignedBy ->
|
||||
val rootCertificate = createSelfSigned(CordaX500Name("SystemUsers/Node", "IT", "R3 London", "London", "London", "GB"))
|
||||
val markCertificate = createSignedBy(CordaX500Name("shell", "IT", "R3 London", "London", "London", "GB"), rootCertificate)
|
||||
|
||||
// truststore needs to contain root CA for how the driver works...
|
||||
server.keyStore["cordaclienttls"] = rootCertificate
|
||||
server.trustStore["cordaclienttls"] = rootCertificate
|
||||
server.trustStore["shell"] = markCertificate
|
||||
|
||||
//client key store doesn't have "mark" certificate
|
||||
client.trustStore["cordaclienttls"] = rootCertificate
|
||||
|
||||
withKeyStores(server, client) { nodeSslOptions, clientSslOptions ->
|
||||
driver(DriverParameters(isDebug = true, startNodesInProcess = true, portAllocation = RandomFree)) {
|
||||
startNode(rpcUsers = listOf(user), customOverrides = nodeSslOptions.useSslRpcOverrides()).getOrThrow().use { node ->
|
||||
|
||||
val sslConfiguration = ShellSslOptions(clientSslOptions.sslKeystore, clientSslOptions.keyStorePassword,
|
||||
clientSslOptions.trustStoreFile, clientSslOptions.trustStorePassword)
|
||||
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
|
||||
user = user.username, password = user.password,
|
||||
hostAndPort = node.rpcAddress,
|
||||
ssl = sslConfiguration)
|
||||
|
||||
InteractiveShell.startShell(conf)
|
||||
|
||||
assertThatThrownBy { InteractiveShell.nodeInfo() }.isInstanceOf(ActiveMQNotConnectedException::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ssh runs flows via standalone shell`() {
|
||||
val user = User("u", "p", setOf(Permissions.startFlow<SSHServerTest.FlowICanRun>(),
|
||||
Permissions.invokeRpc(CordaRPCOps::registeredFlows),
|
||||
Permissions.invokeRpc(CordaRPCOps::nodeInfo)))
|
||||
driver {
|
||||
val nodeFuture = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user), startInSameProcess = true)
|
||||
val node = nodeFuture.getOrThrow()
|
||||
|
||||
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
|
||||
user = user.username, password = user.password,
|
||||
hostAndPort = node.rpcAddress,
|
||||
sshdPort = 2224)
|
||||
|
||||
InteractiveShell.startShell(conf)
|
||||
InteractiveShell.nodeInfo()
|
||||
|
||||
val session = JSch().getSession("u", "localhost", 2224)
|
||||
session.setConfig("StrictHostKeyChecking", "no")
|
||||
session.setPassword("p")
|
||||
session.connect()
|
||||
|
||||
assertTrue(session.isConnected)
|
||||
|
||||
val channel = session.openChannel("exec") as ChannelExec
|
||||
channel.setCommand("start FlowICanRun")
|
||||
channel.connect(5000)
|
||||
|
||||
assertTrue(channel.isConnected)
|
||||
|
||||
val response = String(Streams.readAll(channel.inputStream))
|
||||
|
||||
val linesWithDoneCount = response.lines().filter { line -> line.contains("Done") }
|
||||
|
||||
channel.disconnect()
|
||||
session.disconnect()
|
||||
|
||||
// There are ANSI control characters involved, so we want to avoid direct byte to byte matching.
|
||||
assertThat(linesWithDoneCount).hasSize(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ssh run flows via standalone shell over ssl to node`() {
|
||||
val user = User("mark", "dadada", setOf(Permissions.startFlow<SSHServerTest.FlowICanRun>(),
|
||||
Permissions.invokeRpc(CordaRPCOps::registeredFlows),
|
||||
Permissions.invokeRpc(CordaRPCOps::nodeInfo)/*all()*/))
|
||||
withCertificates { server, client, createSelfSigned, createSignedBy ->
|
||||
val rootCertificate = createSelfSigned(CordaX500Name("SystemUsers/Node", "IT", "R3 London", "London", "London", "GB"))
|
||||
val markCertificate = createSignedBy(CordaX500Name("shell", "IT", "R3 London", "London", "London", "GB"), rootCertificate)
|
||||
|
||||
// truststore needs to contain root CA for how the driver works...
|
||||
server.keyStore["cordaclienttls"] = rootCertificate
|
||||
server.trustStore["cordaclienttls"] = rootCertificate
|
||||
server.trustStore["shell"] = markCertificate
|
||||
|
||||
client.keyStore["shell"] = markCertificate
|
||||
client.trustStore["cordaclienttls"] = rootCertificate
|
||||
|
||||
withKeyStores(server, client) { nodeSslOptions, clientSslOptions ->
|
||||
var successful = false
|
||||
driver(DriverParameters(isDebug = true, startNodesInProcess = true, portAllocation = RandomFree)) {
|
||||
startNode(rpcUsers = listOf(user), customOverrides = nodeSslOptions.useSslRpcOverrides()).getOrThrow().use { node ->
|
||||
|
||||
val sslConfiguration = ShellSslOptions(clientSslOptions.sslKeystore, clientSslOptions.keyStorePassword,
|
||||
clientSslOptions.trustStoreFile, clientSslOptions.trustStorePassword)
|
||||
val conf = ShellConfiguration(commandsDirectory = Files.createTempDir().toPath(),
|
||||
user = user.username, password = user.password,
|
||||
hostAndPort = node.rpcAddress,
|
||||
ssl = sslConfiguration,
|
||||
sshdPort = 2223)
|
||||
|
||||
InteractiveShell.startShell(conf)
|
||||
InteractiveShell.nodeInfo()
|
||||
|
||||
val session = JSch().getSession("mark", "localhost", 2223)
|
||||
session.setConfig("StrictHostKeyChecking", "no")
|
||||
session.setPassword("dadada")
|
||||
session.connect()
|
||||
|
||||
assertTrue(session.isConnected)
|
||||
|
||||
val channel = session.openChannel("exec") as ChannelExec
|
||||
channel.setCommand("start FlowICanRun")
|
||||
channel.connect(5000)
|
||||
|
||||
assertTrue(channel.isConnected)
|
||||
|
||||
val response = String(Streams.readAll(channel.inputStream))
|
||||
|
||||
val linesWithDoneCount = response.lines().filter { line -> line.contains("Done") }
|
||||
|
||||
channel.disconnect()
|
||||
session.disconnect() // TODO Simon make sure to close them
|
||||
|
||||
// There are ANSI control characters involved, so we want to avoid direct byte to byte matching.
|
||||
assertThat(linesWithDoneCount).hasSize(1)
|
||||
|
||||
successful = true
|
||||
}
|
||||
}
|
||||
assertThat(successful).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,198 @@
|
||||
/*
|
||||
* R3 Proprietary and Confidential
|
||||
*
|
||||
* Copyright (c) 2018 R3 Limited. All rights reserved.
|
||||
*
|
||||
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
|
||||
*
|
||||
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
|
||||
*/
|
||||
|
||||
package net.corda.tools.shell
|
||||
|
||||
import co.paralleluniverse.fibers.Suspendable
|
||||
import com.jcraft.jsch.ChannelExec
|
||||
import com.jcraft.jsch.JSch
|
||||
import com.jcraft.jsch.JSchException
|
||||
import net.corda.core.flows.FlowLogic
|
||||
import net.corda.core.flows.InitiatingFlow
|
||||
import net.corda.core.flows.StartableByRPC
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.core.utilities.ProgressTracker
|
||||
import net.corda.core.utilities.getOrThrow
|
||||
import net.corda.core.utilities.unwrap
|
||||
import net.corda.node.services.Permissions.Companion.invokeRpc
|
||||
import net.corda.node.services.Permissions.Companion.startFlow
|
||||
import net.corda.testing.core.ALICE_NAME
|
||||
import net.corda.testing.core.DUMMY_NOTARY_NAME
|
||||
import net.corda.testing.driver.DriverParameters
|
||||
import net.corda.testing.driver.driver
|
||||
import net.corda.testing.internal.IntegrationTest
|
||||
import net.corda.testing.internal.IntegrationTestSchemas
|
||||
import net.corda.testing.internal.toDatabaseSchemaName
|
||||
import net.corda.testing.node.User
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.bouncycastle.util.io.Streams
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Test
|
||||
import java.net.ConnectException
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
class SSHServerTest : IntegrationTest() {
|
||||
companion object {
|
||||
@ClassRule @JvmField
|
||||
val databaseSchemas = IntegrationTestSchemas(ALICE_NAME.toDatabaseSchemaName(), DUMMY_NOTARY_NAME.toDatabaseSchemaName())
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun `ssh server does not start be default`() {
|
||||
val user = User("u", "p", setOf())
|
||||
// The driver will automatically pick up the annotated flows below
|
||||
driver {
|
||||
val node = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user))
|
||||
node.getOrThrow()
|
||||
|
||||
val session = JSch().getSession("u", "localhost", 2222)
|
||||
session.setConfig("StrictHostKeyChecking", "no")
|
||||
session.setPassword("p")
|
||||
|
||||
try {
|
||||
session.connect()
|
||||
fail()
|
||||
} catch (e:JSchException) {
|
||||
assertTrue(e.cause is ConnectException)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ssh server starts when configured`() {
|
||||
val user = User("u", "p", setOf())
|
||||
// The driver will automatically pick up the annotated flows below
|
||||
driver {
|
||||
val node = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user),
|
||||
customOverrides = mapOf("sshd" to mapOf("port" to 2222)))
|
||||
node.getOrThrow()
|
||||
|
||||
val session = JSch().getSession("u", "localhost", 2222)
|
||||
session.setConfig("StrictHostKeyChecking", "no")
|
||||
session.setPassword("p")
|
||||
|
||||
session.connect()
|
||||
|
||||
assertTrue(session.isConnected)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ssh server verify credentials`() {
|
||||
val user = User("u", "p", setOf())
|
||||
// The driver will automatically pick up the annotated flows below
|
||||
driver {
|
||||
val node = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user),
|
||||
customOverrides = mapOf("sshd" to mapOf("port" to 2222)))
|
||||
node.getOrThrow()
|
||||
|
||||
val session = JSch().getSession("u", "localhost", 2222)
|
||||
session.setConfig("StrictHostKeyChecking", "no")
|
||||
session.setPassword("p_is_bad_password")
|
||||
|
||||
try {
|
||||
session.connect()
|
||||
fail("Server should reject invalid credentials")
|
||||
} catch (e: JSchException) {
|
||||
//There is no specialized exception for this
|
||||
assertTrue(e.message == "Auth fail")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ssh respects permissions`() {
|
||||
val user = User("u", "p", setOf(startFlow<FlowICanRun>(),
|
||||
invokeRpc(CordaRPCOps::wellKnownPartyFromX500Name)))
|
||||
// The driver will automatically pick up the annotated flows below
|
||||
driver(DriverParameters(isDebug = true)) {
|
||||
val node = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user),
|
||||
customOverrides = mapOf("sshd" to mapOf("port" to 2222)))
|
||||
node.getOrThrow()
|
||||
|
||||
val session = JSch().getSession("u", "localhost", 2222)
|
||||
session.setConfig("StrictHostKeyChecking", "no")
|
||||
session.setPassword("p")
|
||||
session.connect()
|
||||
|
||||
assertTrue(session.isConnected)
|
||||
|
||||
val channel = session.openChannel("exec") as ChannelExec
|
||||
channel.setCommand("start FlowICannotRun otherParty: \"$ALICE_NAME\"")
|
||||
channel.connect()
|
||||
val response = String(Streams.readAll(channel.inputStream))
|
||||
|
||||
channel.disconnect()
|
||||
session.disconnect()
|
||||
|
||||
assertThat(response).matches("(?s)User not authorized to perform RPC call .*")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ssh runs flows`() {
|
||||
val user = User("u", "p", setOf(startFlow<FlowICanRun>()))
|
||||
// The driver will automatically pick up the annotated flows below
|
||||
driver(DriverParameters(isDebug = true)) {
|
||||
val node = startNode(providedName = ALICE_NAME, rpcUsers = listOf(user),
|
||||
customOverrides = mapOf("sshd" to mapOf("port" to 2222)))
|
||||
node.getOrThrow()
|
||||
|
||||
val session = JSch().getSession("u", "localhost", 2222)
|
||||
session.setConfig("StrictHostKeyChecking", "no")
|
||||
session.setPassword("p")
|
||||
session.connect()
|
||||
|
||||
assertTrue(session.isConnected)
|
||||
|
||||
val channel = session.openChannel("exec") as ChannelExec
|
||||
channel.setCommand("start FlowICanRun")
|
||||
channel.connect(5000)
|
||||
|
||||
assertTrue(channel.isConnected)
|
||||
|
||||
val response = String(Streams.readAll(channel.inputStream))
|
||||
|
||||
val linesWithDoneCount = response.lines().filter { line -> line.contains("Done") }
|
||||
|
||||
channel.disconnect()
|
||||
session.disconnect()
|
||||
|
||||
// There are ANSI control characters involved, so we want to avoid direct byte to byte matching.
|
||||
assertThat(linesWithDoneCount).size().isGreaterThanOrEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@StartableByRPC
|
||||
@InitiatingFlow
|
||||
class FlowICanRun : FlowLogic<String>() {
|
||||
|
||||
private val HELLO_STEP = ProgressTracker.Step("Hello")
|
||||
|
||||
@Suspendable
|
||||
override fun call(): String {
|
||||
progressTracker?.currentStep = HELLO_STEP
|
||||
return "bambam"
|
||||
}
|
||||
|
||||
override val progressTracker: ProgressTracker? = ProgressTracker(HELLO_STEP)
|
||||
}
|
||||
|
||||
@StartableByRPC
|
||||
@InitiatingFlow
|
||||
class FlowICannotRun(val otherParty: Party) : FlowLogic<String>() {
|
||||
@Suspendable
|
||||
override fun call(): String = initiateFlow(otherParty).receive<String>().unwrap { it }
|
||||
|
||||
override val progressTracker: ProgressTracker? = ProgressTracker()
|
||||
}
|
||||
}
|
8
tools/shell/src/integration-test/resources/ssl.conf
Normal file
8
tools/shell/src/integration-test/resources/ssl.conf
Normal file
@ -0,0 +1,8 @@
|
||||
user=demo1
|
||||
baseDirectory="/Users/szymonsztuka/Documents/shell-config"
|
||||
hostAndPort="localhost:10006"
|
||||
sshdPort=2223
|
||||
ssl {
|
||||
keyStorePassword=password
|
||||
trustStorePassword=password
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
/*
|
||||
* R3 Proprietary and Confidential
|
||||
*
|
||||
* Copyright (c) 2018 R3 Limited. All rights reserved.
|
||||
*
|
||||
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
|
||||
*
|
||||
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
|
||||
*/
|
||||
|
||||
package net.corda.tools.shell;
|
||||
|
||||
// See the comments at the top of run.java
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import net.corda.core.messaging.CordaRPCOps;
|
||||
import net.corda.tools.shell.utlities.ANSIProgressRenderer;
|
||||
import net.corda.tools.shell.utlities.CRaSHANSIProgressRenderer;
|
||||
import org.crsh.cli.*;
|
||||
import org.crsh.command.*;
|
||||
import org.crsh.text.*;
|
||||
import org.crsh.text.ui.TableElement;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static net.corda.tools.shell.InteractiveShell.runFlowByNameFragment;
|
||||
import static net.corda.tools.shell.InteractiveShell.runStateMachinesView;
|
||||
|
||||
@Man(
|
||||
"Allows you to start flows, list the ones available and to watch flows currently running on the node.\n\n" +
|
||||
"Starting flow is the primary way in which you command the node to change the ledger.\n\n" +
|
||||
"This command is generic, so the right way to use it depends on the flow you wish to start. You can use the 'flow start'\n" +
|
||||
"command with either a full class name, or a substring of the class name that's unambiguous. The parameters to the \n" +
|
||||
"flow constructors (the right one is picked automatically) are then specified using the same syntax as for the run command."
|
||||
)
|
||||
public class FlowShellCommand extends InteractiveShellCommand {
|
||||
@Command
|
||||
@Usage("Start a (work)flow on the node. This is how you can change the ledger.")
|
||||
public void start(
|
||||
@Usage("The class name of the flow to run, or an unambiguous substring") @Argument String name,
|
||||
@Usage("The data to pass as input") @Argument(unquote = false) List<String> input
|
||||
) {
|
||||
startFlow(name, input, out, ops(), ansiProgressRenderer(), objectMapper());
|
||||
}
|
||||
|
||||
// TODO Limit number of flows shown option?
|
||||
@Command
|
||||
@Usage("watch information about state machines running on the node with result information")
|
||||
public void watch(InvocationContext<TableElement> context) throws Exception {
|
||||
runStateMachinesView(out, ops());
|
||||
}
|
||||
|
||||
static void startFlow(@Usage("The class name of the flow to run, or an unambiguous substring") @Argument String name,
|
||||
@Usage("The data to pass as input") @Argument(unquote = false) List<String> input,
|
||||
RenderPrintWriter out,
|
||||
CordaRPCOps rpcOps,
|
||||
ANSIProgressRenderer ansiProgressRenderer,
|
||||
ObjectMapper om) {
|
||||
if (name == null) {
|
||||
out.println("You must pass a name for the flow, see 'man flow'", Color.red);
|
||||
return;
|
||||
}
|
||||
String inp = input == null ? "" : String.join(" ", input).trim();
|
||||
runFlowByNameFragment(name, inp, out, rpcOps, ansiProgressRenderer != null ? ansiProgressRenderer : new CRaSHANSIProgressRenderer(out), om);
|
||||
}
|
||||
|
||||
@Command
|
||||
@Usage("list flows that user can start")
|
||||
public void list(InvocationContext<String> context) throws Exception {
|
||||
for (String name : ops().registeredFlows()) {
|
||||
context.provide(name + System.lineSeparator());
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
/*
|
||||
* R3 Proprietary and Confidential
|
||||
*
|
||||
* Copyright (c) 2018 R3 Limited. All rights reserved.
|
||||
*
|
||||
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
|
||||
*
|
||||
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
|
||||
*/
|
||||
|
||||
package net.corda.tools.shell;
|
||||
|
||||
import net.corda.core.messaging.*;
|
||||
import net.corda.client.jackson.*;
|
||||
import org.crsh.cli.*;
|
||||
import org.crsh.command.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
// Note that this class cannot be converted to Kotlin because CRaSH does not understand InvocationContext<Map<?, ?>> which
|
||||
// is the closest you can get in Kotlin to raw types.
|
||||
|
||||
public class RunShellCommand extends InteractiveShellCommand {
|
||||
@Command
|
||||
@Man(
|
||||
"Runs a method from the CordaRPCOps interface, which is the same interface exposed to RPC clients.\n\n" +
|
||||
|
||||
"You can learn more about what commands are available by typing 'run' just by itself, or by\n" +
|
||||
"consulting the developer guide at https://docs.corda.net/api/kotlin/corda/net.corda.core.messaging/-corda-r-p-c-ops/index.html"
|
||||
)
|
||||
@Usage("runs a method from the CordaRPCOps interface on the node.")
|
||||
public Object main(
|
||||
InvocationContext<Map> context,
|
||||
@Usage("The command to run") @Argument(unquote = false) List<String> command
|
||||
) {
|
||||
StringToMethodCallParser<CordaRPCOps> parser = new StringToMethodCallParser<>(CordaRPCOps.class, objectMapper());
|
||||
|
||||
if (command == null) {
|
||||
emitHelp(context, parser);
|
||||
return null;
|
||||
}
|
||||
|
||||
return InteractiveShell.runRPCFromString(command, out, context, ops(), objectMapper());
|
||||
}
|
||||
|
||||
private void emitHelp(InvocationContext<Map> context, StringToMethodCallParser<CordaRPCOps> parser) {
|
||||
// Sends data down the pipeline about what commands are available. CRaSH will render it nicely.
|
||||
// Each element we emit is a map of column -> content.
|
||||
Map<String, String> cmdsAndArgs = parser.getAvailableCommands();
|
||||
for (Map.Entry<String, String> entry : cmdsAndArgs.entrySet()) {
|
||||
// Skip these entries as they aren't really interesting for the user.
|
||||
if (entry.getKey().equals("startFlowDynamic")) continue;
|
||||
if (entry.getKey().equals("getProtocolVersion")) continue;
|
||||
|
||||
// Use a LinkedHashMap to ensure that the Command column comes first.
|
||||
Map<String, String> m = new LinkedHashMap<>();
|
||||
m.put("Command", entry.getKey());
|
||||
m.put("Parameter types", entry.getValue());
|
||||
try {
|
||||
context.provide(m);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
/*
|
||||
* R3 Proprietary and Confidential
|
||||
*
|
||||
* Copyright (c) 2018 R3 Limited. All rights reserved.
|
||||
*
|
||||
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
|
||||
*
|
||||
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
|
||||
*/
|
||||
|
||||
package net.corda.tools.shell;
|
||||
|
||||
// A simple forwarder to the "flow start" command, for easier typing.
|
||||
|
||||
import net.corda.tools.shell.utlities.ANSIProgressRenderer;
|
||||
import net.corda.tools.shell.utlities.CRaSHANSIProgressRenderer;
|
||||
import org.crsh.cli.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public class StartShellCommand extends InteractiveShellCommand {
|
||||
@Command
|
||||
@Man("An alias for 'flow start'. Example: \"start Yo target: Some other company\"")
|
||||
public void main(@Usage("The class name of the flow to run, or an unambiguous substring") @Argument String name,
|
||||
@Usage("The data to pass as input") @Argument(unquote = false) List<String> input) {
|
||||
ANSIProgressRenderer ansiProgressRenderer = ansiProgressRenderer();
|
||||
FlowShellCommand.startFlow(name, input, out, ops(), ansiProgressRenderer != null ? ansiProgressRenderer : new CRaSHANSIProgressRenderer(out), objectMapper());
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package net.corda.tools.shell
|
||||
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.core.utilities.loggerFor
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQSecurityException
|
||||
import org.crsh.auth.AuthInfo
|
||||
import org.crsh.auth.AuthenticationPlugin
|
||||
import org.crsh.plugin.CRaSHPlugin
|
||||
|
||||
class CordaAuthenticationPlugin(private val rpcOps: (username: String, credential: String) -> CordaRPCOps): CRaSHPlugin<AuthenticationPlugin<String>>(), AuthenticationPlugin<String> {
|
||||
|
||||
companion object {
|
||||
private val logger = loggerFor<CordaAuthenticationPlugin>()
|
||||
}
|
||||
|
||||
override fun getImplementation(): AuthenticationPlugin<String> = this
|
||||
|
||||
override fun getName(): String = "corda"
|
||||
|
||||
override fun authenticate(username: String?, credential: String?): AuthInfo {
|
||||
|
||||
if (username == null || credential == null) {
|
||||
return AuthInfo.UNSUCCESSFUL
|
||||
}
|
||||
try {
|
||||
val ops = rpcOps(username, credential)
|
||||
return CordaSSHAuthInfo(true, ops)
|
||||
} catch (e: ActiveMQSecurityException) {
|
||||
logger.warn(e.message)
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e.message, e)
|
||||
}
|
||||
return AuthInfo.UNSUCCESSFUL
|
||||
}
|
||||
|
||||
override fun getCredentialType(): Class<String> = String::class.java
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package net.corda.tools.shell
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.tools.shell.InteractiveShell.createYamlInputMapper
|
||||
import net.corda.tools.shell.utlities.ANSIProgressRenderer
|
||||
import org.crsh.auth.AuthInfo
|
||||
|
||||
class CordaSSHAuthInfo(val successful: Boolean, val rpcOps: CordaRPCOps, val ansiProgressRenderer: ANSIProgressRenderer? = null) : AuthInfo {
|
||||
override fun isSuccessful(): Boolean = successful
|
||||
|
||||
val yamlInputMapper: ObjectMapper by lazy {
|
||||
createYamlInputMapper(rpcOps)
|
||||
}
|
||||
}
|
@ -0,0 +1,136 @@
|
||||
/*
|
||||
* R3 Proprietary and Confidential
|
||||
*
|
||||
* Copyright (c) 2018 R3 Limited. All rights reserved.
|
||||
*
|
||||
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
|
||||
*
|
||||
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
|
||||
*/
|
||||
|
||||
package net.corda.tools.shell
|
||||
|
||||
import net.corda.core.flows.StateMachineRunId
|
||||
import net.corda.core.internal.concurrent.openFuture
|
||||
import net.corda.core.context.InvocationContext
|
||||
import net.corda.core.messaging.StateMachineUpdate
|
||||
import net.corda.core.messaging.StateMachineUpdate.Added
|
||||
import net.corda.core.messaging.StateMachineUpdate.Removed
|
||||
import net.corda.core.transactions.SignedTransaction
|
||||
import net.corda.core.utilities.Try
|
||||
import org.crsh.text.Color
|
||||
import org.crsh.text.Decoration
|
||||
import org.crsh.text.RenderPrintWriter
|
||||
import org.crsh.text.ui.LabelElement
|
||||
import org.crsh.text.ui.Overflow
|
||||
import org.crsh.text.ui.RowElement
|
||||
import org.crsh.text.ui.TableElement
|
||||
import rx.Subscriber
|
||||
|
||||
class FlowWatchPrintingSubscriber(private val toStream: RenderPrintWriter) : Subscriber<Any>() {
|
||||
private val indexMap = HashMap<StateMachineRunId, Int>()
|
||||
private val table = createStateMachinesTable()
|
||||
val future = openFuture<Unit>()
|
||||
|
||||
init {
|
||||
// The future is public and can be completed by something else to indicate we don't wish to follow
|
||||
// anymore (e.g. the user pressing Ctrl-C).
|
||||
future.then { unsubscribe() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun onCompleted() {
|
||||
// The observable of state machines will never complete.
|
||||
future.set(Unit)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun onNext(t: Any?) {
|
||||
if (t is StateMachineUpdate) {
|
||||
toStream.cls()
|
||||
createStateMachinesRow(t)
|
||||
toStream.print(table)
|
||||
toStream.println("Waiting for completion or Ctrl-C ... ")
|
||||
toStream.flush()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun onError(e: Throwable) {
|
||||
toStream.println("Observable completed with an error")
|
||||
future.setException(e)
|
||||
}
|
||||
|
||||
private fun stateColor(update: StateMachineUpdate): Color {
|
||||
return when (update) {
|
||||
is Added -> Color.blue
|
||||
is Removed -> if (update.result.isSuccess) Color.green else Color.red
|
||||
}
|
||||
}
|
||||
|
||||
private fun createStateMachinesTable(): TableElement {
|
||||
val table = TableElement(1, 2, 1, 2).overflow(Overflow.HIDDEN).rightCellPadding(1)
|
||||
val header = RowElement(true).add("Id", "Flow name", "Initiator", "Status").style(Decoration.bold.fg(Color.black).bg(Color.white))
|
||||
table.add(header)
|
||||
return table
|
||||
}
|
||||
|
||||
// TODO Add progress tracker?
|
||||
private fun createStateMachinesRow(smmUpdate: StateMachineUpdate) {
|
||||
when (smmUpdate) {
|
||||
is Added -> {
|
||||
table.add(RowElement().add(
|
||||
LabelElement(formatFlowId(smmUpdate.id)),
|
||||
LabelElement(formatFlowName(smmUpdate.stateMachineInfo.flowLogicClassName)),
|
||||
LabelElement(formatInvocationContext(smmUpdate.stateMachineInfo.invocationContext)),
|
||||
LabelElement("In progress")
|
||||
).style(stateColor(smmUpdate).fg()))
|
||||
indexMap[smmUpdate.id] = table.rows.size - 1
|
||||
}
|
||||
is Removed -> {
|
||||
val idx = indexMap[smmUpdate.id]
|
||||
if (idx != null) {
|
||||
val oldRow = table.rows[idx]
|
||||
val flowNameLabel = oldRow.getCol(1) as LabelElement
|
||||
val flowInitiatorLabel = oldRow.getCol(2) as LabelElement
|
||||
table.rows[idx] = RowElement().add(
|
||||
LabelElement(formatFlowId(smmUpdate.id)),
|
||||
LabelElement(flowNameLabel.value),
|
||||
LabelElement(flowInitiatorLabel.value),
|
||||
LabelElement(formatFlowResult(smmUpdate.result))
|
||||
).style(stateColor(smmUpdate).fg())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFlowName(flowName: String): String {
|
||||
val camelCaseRegex = Regex("(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
||||
val name = flowName.split('.', '$').last()
|
||||
// Split CamelCase and get rid of "flow" at the end if present.
|
||||
return camelCaseRegex.split(name).filter { it.compareTo("Flow", true) != 0 }.joinToString(" ")
|
||||
}
|
||||
|
||||
private fun formatFlowId(flowId: StateMachineRunId): String {
|
||||
return flowId.toString().removeSurrounding("[", "]")
|
||||
}
|
||||
|
||||
private fun formatInvocationContext(context: InvocationContext): String {
|
||||
return context.principal().name
|
||||
}
|
||||
|
||||
private fun formatFlowResult(flowResult: Try<*>): String {
|
||||
fun successFormat(value: Any?): String {
|
||||
return when (value) {
|
||||
is SignedTransaction -> "Tx ID: " + value.id.toString()
|
||||
is kotlin.Unit -> "No return value"
|
||||
null -> "No return value"
|
||||
else -> value.toString()
|
||||
}
|
||||
}
|
||||
return when (flowResult) {
|
||||
is Try.Success -> successFormat(flowResult.value)
|
||||
is Try.Failure -> flowResult.exception.message ?: flowResult.exception.toString()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,635 @@
|
||||
/*
|
||||
* R3 Proprietary and Confidential
|
||||
*
|
||||
* Copyright (c) 2018 R3 Limited. All rights reserved.
|
||||
*
|
||||
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
|
||||
*
|
||||
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
|
||||
*/
|
||||
|
||||
package net.corda.tools.shell
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.*
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
|
||||
import com.google.common.io.Closeables
|
||||
import net.corda.client.jackson.JacksonSupport
|
||||
import net.corda.client.jackson.StringToMethodCallParser
|
||||
import net.corda.client.rpc.PermissionException
|
||||
import net.corda.client.rpc.internal.createCordaRPCClientWithSslAndClassLoader
|
||||
import net.corda.core.CordaException
|
||||
import net.corda.core.concurrent.CordaFuture
|
||||
import net.corda.core.contracts.UniqueIdentifier
|
||||
import net.corda.core.flows.FlowLogic
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.internal.*
|
||||
import net.corda.core.internal.concurrent.doneFuture
|
||||
import net.corda.core.internal.concurrent.openFuture
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.core.messaging.DataFeed
|
||||
import net.corda.core.messaging.FlowProgressHandle
|
||||
import net.corda.core.messaging.StateMachineUpdate
|
||||
import net.corda.core.node.NodeInfo
|
||||
import net.corda.core.utilities.NetworkHostAndPort
|
||||
import net.corda.nodeapi.internal.config.SSLConfiguration
|
||||
import net.corda.tools.shell.utlities.ANSIProgressRenderer
|
||||
import net.corda.tools.shell.utlities.StdoutANSIProgressRenderer
|
||||
import org.crsh.command.InvocationContext
|
||||
import org.crsh.console.jline.JLineProcessor
|
||||
import org.crsh.console.jline.TerminalFactory
|
||||
import org.crsh.console.jline.console.ConsoleReader
|
||||
import org.crsh.lang.impl.java.JavaLanguage
|
||||
import org.crsh.plugin.CRaSHPlugin
|
||||
import org.crsh.plugin.PluginContext
|
||||
import org.crsh.plugin.PluginLifeCycle
|
||||
import org.crsh.plugin.ServiceLoaderDiscovery
|
||||
import org.crsh.shell.Shell
|
||||
import org.crsh.shell.ShellFactory
|
||||
import org.crsh.shell.impl.command.ExternalResolver
|
||||
import org.crsh.text.Color
|
||||
import org.crsh.text.RenderPrintWriter
|
||||
import org.crsh.util.InterruptHandler
|
||||
import org.crsh.util.Utils
|
||||
import org.crsh.vfs.FS
|
||||
import org.crsh.vfs.spi.file.FileMountFactory
|
||||
import org.crsh.vfs.spi.url.ClassPathMountFactory
|
||||
import org.json.JSONObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import rx.Observable
|
||||
import rx.Subscriber
|
||||
import java.io.*
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.lang.reflect.UndeclaredThrowableException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.*
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Future
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
// TODO: Add command history.
|
||||
// TODO: Command completion.
|
||||
// TODO: Do something sensible with commands that return a future.
|
||||
// TODO: Configure default renderers, send objects down the pipeline, add commands to do json/xml/yaml outputs.
|
||||
// TODO: Add a command to view last N lines/tail/control log4j2 loggers.
|
||||
// TODO: Review or fix the JVM commands which have bitrotted and some are useless.
|
||||
// TODO: Get rid of the 'java' command, it's kind of worthless.
|
||||
// TODO: Fix up the 'dashboard' command which has some rendering issues.
|
||||
// TODO: Resurrect or reimplement the mail plugin.
|
||||
// TODO: Make it notice new shell commands added after the node started.
|
||||
|
||||
data class SSHDConfiguration(val port: Int) {
|
||||
companion object {
|
||||
internal const val INVALID_PORT_FORMAT = "Invalid port: %s"
|
||||
private const val MISSING_PORT_FORMAT = "Missing port: %s"
|
||||
|
||||
/**
|
||||
* Parses a string of the form port into a [SSHDConfiguration].
|
||||
* @throws IllegalArgumentException if the port is missing or the string is garbage.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun parse(str: String): SSHDConfiguration {
|
||||
require(!str.isNullOrBlank()) { SSHDConfiguration.MISSING_PORT_FORMAT.format(str) }
|
||||
val port = try {
|
||||
str.toInt()
|
||||
} catch (ex: NumberFormatException) {
|
||||
throw IllegalArgumentException("Port syntax is invalid, expected port")
|
||||
}
|
||||
return SSHDConfiguration(port)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
require(port in (0..0xffff)) { INVALID_PORT_FORMAT.format(port) }
|
||||
}
|
||||
}
|
||||
|
||||
data class ShellSslOptions(override val sslKeystore: Path, override val keyStorePassword: String, override val trustStoreFile:Path, override val trustStorePassword: String) : SSLConfiguration {
|
||||
override val certificatesDirectory: Path get() = Paths.get("")
|
||||
}
|
||||
|
||||
data class ShellConfiguration(
|
||||
val commandsDirectory: Path,
|
||||
val cordappsDirectory: Path? = null,
|
||||
var user: String = "",
|
||||
var password: String = "",
|
||||
val hostAndPort: NetworkHostAndPort,
|
||||
val ssl: ShellSslOptions? = null,
|
||||
val sshdPort: Int? = null,
|
||||
val sshHostKeyDirectory: Path? = null,
|
||||
val noLocalShell: Boolean = false) {
|
||||
companion object {
|
||||
const val SSH_PORT = 2222
|
||||
const val COMMANDS_DIR = "shell-commands"
|
||||
const val CORDAPPS_DIR = "cordapps"
|
||||
const val SSHD_HOSTKEY_DIR = "ssh"
|
||||
}
|
||||
}
|
||||
|
||||
object InteractiveShell {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private lateinit var rpcOps: (username: String, credentials: String) -> CordaRPCOps
|
||||
private lateinit var connection: CordaRPCOps
|
||||
private var shell: Shell? = null
|
||||
private var classLoader: ClassLoader? = null
|
||||
/**
|
||||
* Starts an interactive shell connected to the local terminal. This shell gives administrator access to the node
|
||||
* internals.
|
||||
*/
|
||||
fun startShell(configuration: ShellConfiguration, classLoader: ClassLoader? = null) {
|
||||
rpcOps = { username: String, credentials: String ->
|
||||
val client = createCordaRPCClientWithSslAndClassLoader(hostAndPort = configuration.hostAndPort,
|
||||
sslConfiguration = configuration.ssl, classLoader = classLoader)
|
||||
client.start(username, credentials).proxy
|
||||
}
|
||||
InteractiveShell.classLoader = classLoader
|
||||
val runSshDaemon = configuration.sshdPort != null
|
||||
|
||||
val config = Properties()
|
||||
if (runSshDaemon) {
|
||||
// Enable SSH access. Note: these have to be strings, even though raw object assignments also work.
|
||||
config["crash.ssh.port"] = configuration.sshdPort?.toString()
|
||||
config["crash.auth"] = "corda"
|
||||
configuration.sshHostKeyDirectory?.apply {
|
||||
val sshKeysDir = configuration.sshHostKeyDirectory
|
||||
sshKeysDir.toFile().mkdirs()
|
||||
config["crash.ssh.keypath"] = (sshKeysDir / "hostkey.pem").toString()
|
||||
config["crash.ssh.keygen"] = "true"
|
||||
}
|
||||
}
|
||||
|
||||
ExternalResolver.INSTANCE.addCommand("run", "Runs a method from the CordaRPCOps interface on the node.", RunShellCommand::class.java)
|
||||
ExternalResolver.INSTANCE.addCommand("flow", "Commands to work with flows. Flows are how you can change the ledger.", FlowShellCommand::class.java)
|
||||
ExternalResolver.INSTANCE.addCommand("start", "An alias for 'flow start'", StartShellCommand::class.java)
|
||||
shell = ShellLifecycle(configuration.commandsDirectory).start(config, configuration.user, configuration.password)
|
||||
}
|
||||
|
||||
fun runLocalShell(onExit: () -> Unit = {}) {
|
||||
val terminal = TerminalFactory.create()
|
||||
val consoleReader = ConsoleReader("Corda", FileInputStream(FileDescriptor.`in`), System.out, terminal)
|
||||
val jlineProcessor = JLineProcessor(terminal.isAnsiSupported, shell, consoleReader, System.out)
|
||||
InterruptHandler { jlineProcessor.interrupt() }.install()
|
||||
thread(name = "Command line shell processor", isDaemon = true) {
|
||||
Emoji.renderIfSupported {
|
||||
jlineProcessor.run()
|
||||
}
|
||||
}
|
||||
thread(name = "Command line shell terminator", isDaemon = true) {
|
||||
// Wait for the shell to finish.
|
||||
jlineProcessor.closed()
|
||||
log.info("Command shell has exited")
|
||||
terminal.restore()
|
||||
onExit.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
class ShellLifecycle(private val shellCommands: Path) : PluginLifeCycle() {
|
||||
fun start(config: Properties, localUserName: String = "", localUserPassword: String = ""): Shell {
|
||||
val classLoader = this.javaClass.classLoader
|
||||
val classpathDriver = ClassPathMountFactory(classLoader)
|
||||
val fileDriver = FileMountFactory(Utils.getCurrentDirectory())
|
||||
|
||||
val extraCommandsPath = shellCommands.toAbsolutePath().createDirectories()
|
||||
val commandsFS = FS.Builder()
|
||||
.register("file", fileDriver)
|
||||
.mount("file:" + extraCommandsPath)
|
||||
.register("classpath", classpathDriver)
|
||||
.mount("classpath:/net/corda/tools/shell/")
|
||||
.mount("classpath:/crash/commands/")
|
||||
.build()
|
||||
val confFS = FS.Builder()
|
||||
.register("classpath", classpathDriver)
|
||||
.mount("classpath:/crash")
|
||||
.build()
|
||||
|
||||
val discovery = object : ServiceLoaderDiscovery(classLoader) {
|
||||
override fun getPlugins(): Iterable<CRaSHPlugin<*>> {
|
||||
// Don't use the Java language plugin (we may not have tools.jar available at runtime), this
|
||||
// will cause any commands using JIT Java compilation to be suppressed. In CRaSH upstream that
|
||||
// is only the 'jmx' command.
|
||||
return super.getPlugins().filterNot { it is JavaLanguage } + CordaAuthenticationPlugin(rpcOps)
|
||||
}
|
||||
}
|
||||
val attributes = emptyMap<String,Any>()
|
||||
val context = PluginContext(discovery, attributes, commandsFS, confFS, classLoader)
|
||||
context.refresh()
|
||||
this.config = config
|
||||
start(context)
|
||||
connection = makeRPCOps(rpcOps, localUserName, localUserPassword)
|
||||
return context.getPlugin(ShellFactory::class.java).create(null, CordaSSHAuthInfo(false, connection, StdoutANSIProgressRenderer))
|
||||
}
|
||||
}
|
||||
|
||||
fun nodeInfo() = try {
|
||||
connection.nodeInfo()
|
||||
} catch (e: UndeclaredThrowableException) {
|
||||
throw e.cause ?: e
|
||||
}
|
||||
|
||||
fun createYamlInputMapper(rpcOps: CordaRPCOps): ObjectMapper {
|
||||
// Return a standard Corda Jackson object mapper, configured to use YAML by default and with extra
|
||||
// serializers.
|
||||
return JacksonSupport.createDefaultMapper(rpcOps, YAMLFactory(), true).apply {
|
||||
val rpcModule = SimpleModule()
|
||||
rpcModule.addDeserializer(InputStream::class.java, InputStreamDeserializer)
|
||||
rpcModule.addDeserializer(UniqueIdentifier::class.java, UniqueIdentifierDeserializer)
|
||||
rpcModule.addDeserializer(UUID::class.java, UUIDDeserializer)
|
||||
registerModule(rpcModule)
|
||||
}
|
||||
}
|
||||
|
||||
private object NodeInfoSerializer : JsonSerializer<NodeInfo>() {
|
||||
|
||||
override fun serialize(nodeInfo: NodeInfo, gen: JsonGenerator, serializers: SerializerProvider) {
|
||||
|
||||
val json = JSONObject()
|
||||
json["addresses"] = nodeInfo.addresses.map { address -> address.serialise() }
|
||||
json["legalIdentities"] = nodeInfo.legalIdentities.map { address -> address.serialise() }
|
||||
json["platformVersion"] = nodeInfo.platformVersion
|
||||
json["serial"] = nodeInfo.serial
|
||||
gen.writeRaw(json.toString())
|
||||
}
|
||||
|
||||
private fun NetworkHostAndPort.serialise() = this.toString()
|
||||
private fun Party.serialise() = JSONObject().put("name", this.name)
|
||||
|
||||
private operator fun JSONObject.set(key: String, value: Any?): JSONObject {
|
||||
return put(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOutputMapper(): ObjectMapper {
|
||||
|
||||
return JacksonSupport.createNonRpcMapper().apply {
|
||||
// Register serializers for stateful objects from libraries that are special to the RPC system and don't
|
||||
// make sense to print out to the screen. For classes we own, annotations can be used instead.
|
||||
val rpcModule = SimpleModule()
|
||||
rpcModule.addSerializer(Observable::class.java, ObservableSerializer)
|
||||
rpcModule.addSerializer(InputStream::class.java, InputStreamSerializer)
|
||||
rpcModule.addSerializer(NodeInfo::class.java, NodeInfoSerializer)
|
||||
registerModule(rpcModule)
|
||||
|
||||
disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
|
||||
enable(SerializationFeature.INDENT_OUTPUT)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This should become the default renderer rather than something used specifically by commands.
|
||||
private val outputMapper by lazy { createOutputMapper() }
|
||||
|
||||
/**
|
||||
* Called from the 'flow' shell command. Takes a name fragment and finds a matching flow, or prints out
|
||||
* the list of options if the request is ambiguous. Then parses [inputData] as constructor arguments using
|
||||
* the [runFlowFromString] method and starts the requested flow. Ctrl-C can be used to cancel.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun runFlowByNameFragment(nameFragment: String, inputData: String, output: RenderPrintWriter, rpcOps: CordaRPCOps, ansiProgressRenderer: ANSIProgressRenderer, om: ObjectMapper) {
|
||||
val matches = try {
|
||||
rpcOps.registeredFlows().filter { nameFragment in it }
|
||||
} catch (e: PermissionException) {
|
||||
output.println(e.message ?: "Access denied", Color.red)
|
||||
return
|
||||
}
|
||||
if (matches.isEmpty()) {
|
||||
output.println("No matching flow found, run 'flow list' to see your options.", Color.red)
|
||||
return
|
||||
} else if (matches.size > 1) {
|
||||
output.println("Ambiguous name provided, please be more specific. Your options are:")
|
||||
matches.forEachIndexed { i, s -> output.println("${i + 1}. $s", Color.yellow) }
|
||||
return
|
||||
}
|
||||
|
||||
val flowClazz: Class<FlowLogic<*>> = if (classLoader != null) {
|
||||
uncheckedCast(Class.forName(matches.single(), true, classLoader))
|
||||
} else {
|
||||
uncheckedCast(Class.forName(matches.single()))
|
||||
}
|
||||
try {
|
||||
// Show the progress tracker on the console until the flow completes or is interrupted with a
|
||||
// Ctrl-C keypress.
|
||||
val stateObservable = runFlowFromString({ clazz, args -> rpcOps.startTrackedFlowDynamic(clazz, *args) }, inputData, flowClazz, om)
|
||||
|
||||
val latch = CountDownLatch(1)
|
||||
ansiProgressRenderer.render(stateObservable, { latch.countDown() })
|
||||
try {
|
||||
// Wait for the flow to end and the progress tracker to notice. By the time the latch is released
|
||||
// the tracker is done with the screen.
|
||||
latch.await()
|
||||
} catch (e: InterruptedException) {
|
||||
// TODO: When the flow framework allows us to kill flows mid-flight, do so here.
|
||||
}
|
||||
} catch (e: NoApplicableConstructor) {
|
||||
output.println("No matching constructor found:", Color.red)
|
||||
e.errors.forEach { output.println("- $it", Color.red) }
|
||||
} catch (e: PermissionException) {
|
||||
output.println(e.message ?: "Access denied", Color.red)
|
||||
} finally {
|
||||
InputStreamDeserializer.closeAll()
|
||||
}
|
||||
}
|
||||
|
||||
class NoApplicableConstructor(val errors: List<String>) : CordaException(this.toString()) {
|
||||
override fun toString() = (listOf("No applicable constructor for flow. Problems were:") + errors).joinToString(System.lineSeparator())
|
||||
}
|
||||
|
||||
// TODO: This utility is generally useful and might be better moved to the node class, or an RPC, if we can commit to making it stable API.
|
||||
/**
|
||||
* Given a [FlowLogic] class and a string in one-line Yaml form, finds an applicable constructor and starts
|
||||
* the flow, returning the created flow logic. Useful for lightweight invocation where text is preferable
|
||||
* to statically typed, compiled code.
|
||||
*
|
||||
* See the [StringToMethodCallParser] class to learn more about limitations and acceptable syntax.
|
||||
*
|
||||
* @throws NoApplicableConstructor if no constructor could be found for the given set of types.
|
||||
*/
|
||||
@Throws(NoApplicableConstructor::class)
|
||||
fun <T> runFlowFromString(invoke: (Class<out FlowLogic<T>>, Array<out Any?>) -> FlowProgressHandle<T>,
|
||||
inputData: String,
|
||||
clazz: Class<out FlowLogic<T>>,
|
||||
om: ObjectMapper): FlowProgressHandle<T> {
|
||||
// For each constructor, attempt to parse the input data as a method call. Use the first that succeeds,
|
||||
// and keep track of the reasons we failed so we can print them out if no constructors are usable.
|
||||
val parser = StringToMethodCallParser(clazz, om)
|
||||
val errors = ArrayList<String>()
|
||||
for (ctor in clazz.constructors) {
|
||||
var paramNamesFromConstructor: List<String>? = null
|
||||
fun getPrototype(): List<String> {
|
||||
val argTypes = ctor.parameterTypes.map { it.simpleName }
|
||||
return paramNamesFromConstructor!!.zip(argTypes).map { (name, type) -> "$name: $type" }
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt construction with the given arguments.
|
||||
paramNamesFromConstructor = parser.paramNamesFromConstructor(ctor)
|
||||
val args = parser.parseArguments(clazz.name, paramNamesFromConstructor!!.zip(ctor.parameterTypes), inputData)
|
||||
if (args.size != ctor.parameterTypes.size) {
|
||||
errors.add("${getPrototype()}: Wrong number of arguments (${args.size} provided, ${ctor.parameterTypes.size} needed)")
|
||||
continue
|
||||
}
|
||||
val flow = ctor.newInstance(*args) as FlowLogic<*>
|
||||
if (flow.progressTracker == null) {
|
||||
errors.add("A flow must override the progress tracker in order to be run from the shell")
|
||||
continue
|
||||
}
|
||||
return invoke(clazz, args)
|
||||
} catch (e: StringToMethodCallParser.UnparseableCallException.MissingParameter) {
|
||||
errors.add("${getPrototype()}: missing parameter ${e.paramName}")
|
||||
} catch (e: StringToMethodCallParser.UnparseableCallException.TooManyParameters) {
|
||||
errors.add("${getPrototype()}: too many parameters")
|
||||
} catch (e: StringToMethodCallParser.UnparseableCallException.ReflectionDataMissing) {
|
||||
val argTypes = ctor.parameterTypes.map { it.simpleName }
|
||||
errors.add("$argTypes: <constructor missing parameter reflection data>")
|
||||
} catch (e: StringToMethodCallParser.UnparseableCallException) {
|
||||
val argTypes = ctor.parameterTypes.map { it.simpleName }
|
||||
errors.add("$argTypes: ${e.message}")
|
||||
}
|
||||
}
|
||||
throw NoApplicableConstructor(errors)
|
||||
}
|
||||
|
||||
// TODO Filtering on error/success when we will have some sort of flow auditing, for now it doesn't make much sense.
|
||||
@JvmStatic
|
||||
fun runStateMachinesView(out: RenderPrintWriter, rpcOps: CordaRPCOps): Any? {
|
||||
val proxy = rpcOps
|
||||
val (stateMachines, stateMachineUpdates) = proxy.stateMachinesFeed()
|
||||
val currentStateMachines = stateMachines.map { StateMachineUpdate.Added(it) }
|
||||
val subscriber = FlowWatchPrintingSubscriber(out)
|
||||
stateMachineUpdates.startWith(currentStateMachines).subscribe(subscriber)
|
||||
var result: Any? = subscriber.future
|
||||
if (result is Future<*>) {
|
||||
if (!result.isDone) {
|
||||
out.cls()
|
||||
out.println("Waiting for completion or Ctrl-C ... ")
|
||||
out.flush()
|
||||
}
|
||||
try {
|
||||
result = result.get()
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
} catch (e: ExecutionException) {
|
||||
throw e.rootCause
|
||||
} catch (e: InvocationTargetException) {
|
||||
throw e.rootCause
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun runRPCFromString(input: List<String>, out: RenderPrintWriter, context: InvocationContext<out Any>, cordaRPCOps: CordaRPCOps, om: ObjectMapper): Any? {
|
||||
val cmd = input.joinToString(" ").trim { it <= ' ' }
|
||||
if (cmd.toLowerCase().startsWith("startflow")) {
|
||||
// The flow command provides better support and startFlow requires special handling anyway due to
|
||||
// the generic startFlow RPC interface which offers no type information with which to parse the
|
||||
// string form of the command.
|
||||
out.println("Please use the 'flow' command to interact with flows rather than the 'run' command.", Color.yellow)
|
||||
return null
|
||||
}
|
||||
|
||||
var result: Any? = null
|
||||
try {
|
||||
InputStreamSerializer.invokeContext = context
|
||||
val parser = StringToMethodCallParser(CordaRPCOps::class.java, om)
|
||||
val call = parser.parse(cordaRPCOps, cmd)
|
||||
result = call.call()
|
||||
if (result != null && result !is kotlin.Unit && result !is Void) {
|
||||
result = printAndFollowRPCResponse(result, out)
|
||||
}
|
||||
if (result is Future<*>) {
|
||||
if (!result.isDone) {
|
||||
out.println("Waiting for completion or Ctrl-C ... ")
|
||||
out.flush()
|
||||
}
|
||||
try {
|
||||
result = result.get()
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
} catch (e: ExecutionException) {
|
||||
throw e.rootCause
|
||||
} catch (e: InvocationTargetException) {
|
||||
throw e.rootCause
|
||||
}
|
||||
}
|
||||
} catch (e: StringToMethodCallParser.UnparseableCallException) {
|
||||
out.println(e.message, Color.red)
|
||||
out.println("Please try 'man run' to learn what syntax is acceptable")
|
||||
} catch (e: Exception) {
|
||||
out.println("RPC failed: ${e.rootCause}", Color.red)
|
||||
} finally {
|
||||
InputStreamSerializer.invokeContext = null
|
||||
InputStreamDeserializer.closeAll()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun printAndFollowRPCResponse(response: Any?, out: PrintWriter): CordaFuture<Unit> {
|
||||
|
||||
val mapElement: (Any?) -> String = { element -> outputMapper.writerWithDefaultPrettyPrinter().writeValueAsString(element) }
|
||||
val mappingFunction: (Any?) -> String = { value ->
|
||||
if (value is Collection<*>) {
|
||||
value.joinToString(",${System.lineSeparator()} ", "[${System.lineSeparator()} ", "${System.lineSeparator()}]") { element ->
|
||||
mapElement(element)
|
||||
}
|
||||
} else {
|
||||
mapElement(value)
|
||||
}
|
||||
}
|
||||
return maybeFollow(response, mappingFunction, out)
|
||||
}
|
||||
|
||||
private class PrintingSubscriber(private val printerFun: (Any?) -> String, private val toStream: PrintWriter) : Subscriber<Any>() {
|
||||
private var count = 0
|
||||
val future = openFuture<Unit>()
|
||||
|
||||
init {
|
||||
// The future is public and can be completed by something else to indicate we don't wish to follow
|
||||
// anymore (e.g. the user pressing Ctrl-C).
|
||||
future.then { unsubscribe() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun onCompleted() {
|
||||
toStream.println("Observable has completed")
|
||||
future.set(Unit)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun onNext(t: Any?) {
|
||||
count++
|
||||
toStream.println("Observation $count: " + printerFun(t))
|
||||
toStream.flush()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun onError(e: Throwable) {
|
||||
toStream.println("Observable completed with an error")
|
||||
e.printStackTrace(toStream)
|
||||
future.setException(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeFollow(response: Any?, printerFun: (Any?) -> String, out: PrintWriter): CordaFuture<Unit> {
|
||||
// Match on a couple of common patterns for "important" observables. It's tough to do this in a generic
|
||||
// way because observables can be embedded anywhere in the object graph, and can emit other arbitrary
|
||||
// object graphs that contain yet more observables. So we just look for top level responses that follow
|
||||
// the standard "track" pattern, and print them until the user presses Ctrl-C
|
||||
if (response == null) return doneFuture(Unit)
|
||||
|
||||
if (response is DataFeed<*, *>) {
|
||||
out.println("Snapshot:")
|
||||
out.println(printerFun(response.snapshot))
|
||||
out.flush()
|
||||
out.println("Updates:")
|
||||
return printNextElements(response.updates, printerFun, out)
|
||||
}
|
||||
if (response is Observable<*>) {
|
||||
return printNextElements(response, printerFun, out)
|
||||
}
|
||||
|
||||
out.println(printerFun(response))
|
||||
return doneFuture(Unit)
|
||||
}
|
||||
|
||||
private fun printNextElements(elements: Observable<*>, printerFun: (Any?) -> String, out: PrintWriter): CordaFuture<Unit> {
|
||||
|
||||
val subscriber = PrintingSubscriber(printerFun, out)
|
||||
uncheckedCast(elements).subscribe(subscriber)
|
||||
return subscriber.future
|
||||
}
|
||||
|
||||
//region Extra serializers
|
||||
//
|
||||
// These serializers are used to enable the user to specify objects that aren't natural data containers in the shell,
|
||||
// and for the shell to print things out that otherwise wouldn't be usefully printable.
|
||||
|
||||
private object ObservableSerializer : JsonSerializer<Observable<*>>() {
|
||||
override fun serialize(value: Observable<*>, gen: JsonGenerator, serializers: SerializerProvider) {
|
||||
gen.writeString("(observable)")
|
||||
}
|
||||
}
|
||||
|
||||
// A file name is deserialized to an InputStream if found.
|
||||
object InputStreamDeserializer : JsonDeserializer<InputStream>() {
|
||||
// Keep track of them so we can close them later.
|
||||
private val streams = Collections.synchronizedSet(HashSet<InputStream>())
|
||||
|
||||
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): InputStream {
|
||||
val stream = object : BufferedInputStream(Files.newInputStream(Paths.get(p.text))) {
|
||||
override fun close() {
|
||||
super.close()
|
||||
streams.remove(this)
|
||||
}
|
||||
}
|
||||
streams += stream
|
||||
return stream
|
||||
}
|
||||
|
||||
fun closeAll() {
|
||||
// Clone the set with toList() here so each closed stream can be removed from the set inside close().
|
||||
streams.toList().forEach { Closeables.closeQuietly(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// An InputStream found in a response triggers a request to the user to provide somewhere to save it.
|
||||
private object InputStreamSerializer : JsonSerializer<InputStream>() {
|
||||
var invokeContext: InvocationContext<*>? = null
|
||||
|
||||
override fun serialize(value: InputStream, gen: JsonGenerator, serializers: SerializerProvider) {
|
||||
try {
|
||||
val toPath = invokeContext!!.readLine("Path to save stream to (enter to ignore): ", true)
|
||||
if (toPath == null || toPath.isBlank()) {
|
||||
gen.writeString("<not saved>")
|
||||
} else {
|
||||
val path = Paths.get(toPath)
|
||||
value.copyTo(path)
|
||||
gen.writeString("<saved to: ${path.toAbsolutePath()}>")
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
value.close()
|
||||
} catch (e: IOException) {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* String value deserialized to [UniqueIdentifier].
|
||||
* Any string value used as [UniqueIdentifier.externalId].
|
||||
* If string contains underscore(i.e. externalId_uuid) then split with it.
|
||||
* Index 0 as [UniqueIdentifier.externalId]
|
||||
* Index 1 as [UniqueIdentifier.id]
|
||||
* */
|
||||
object UniqueIdentifierDeserializer : JsonDeserializer<UniqueIdentifier>() {
|
||||
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): UniqueIdentifier {
|
||||
//Check if externalId and UUID may be separated by underscore.
|
||||
if (p.text.contains("_")) {
|
||||
val ids = p.text.split("_")
|
||||
//Create UUID object from string.
|
||||
val uuid: UUID = UUID.fromString(ids[1])
|
||||
//Create UniqueIdentifier object using externalId and UUID.
|
||||
return UniqueIdentifier(ids[0], uuid)
|
||||
}
|
||||
//Any other string used as externalId.
|
||||
return UniqueIdentifier.fromString(p.text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* String value deserialized to [UUID].
|
||||
* */
|
||||
object UUIDDeserializer : JsonDeserializer<UUID>() {
|
||||
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): UUID {
|
||||
//Create UUID object from string.
|
||||
return UUID.fromString(p.text)
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
/*
|
||||
* R3 Proprietary and Confidential
|
||||
*
|
||||
* Copyright (c) 2018 R3 Limited. All rights reserved.
|
||||
*
|
||||
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
|
||||
*
|
||||
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
|
||||
*/
|
||||
|
||||
package net.corda.tools.shell
|
||||
|
||||
import org.crsh.command.BaseCommand
|
||||
import org.crsh.shell.impl.command.CRaSHSession
|
||||
|
||||
/**
|
||||
* Simply extends CRaSH BaseCommand to add easy access to the RPC ops class.
|
||||
*/
|
||||
open class InteractiveShellCommand : BaseCommand() {
|
||||
fun ops() = ((context.session as CRaSHSession).authInfo as CordaSSHAuthInfo).rpcOps
|
||||
fun ansiProgressRenderer() = ((context.session as CRaSHSession).authInfo as CordaSSHAuthInfo).ansiProgressRenderer
|
||||
fun objectMapper() = ((context.session as CRaSHSession).authInfo as CordaSSHAuthInfo).yamlInputMapper
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
package net.corda.tools.shell
|
||||
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.lang.reflect.Proxy
|
||||
|
||||
fun makeRPCOps(getCordaRPCOps: (username: String, credential: String) -> CordaRPCOps, username: String, credential: String): CordaRPCOps {
|
||||
val cordaRPCOps: CordaRPCOps by lazy {
|
||||
getCordaRPCOps(username, credential)
|
||||
}
|
||||
|
||||
return Proxy.newProxyInstance(CordaRPCOps::class.java.classLoader, arrayOf(CordaRPCOps::class.java), { _, method, args ->
|
||||
try {
|
||||
method.invoke(cordaRPCOps, *(args ?: arrayOf()))
|
||||
} catch (e: InvocationTargetException) {
|
||||
// Unpack exception.
|
||||
throw e.targetException
|
||||
}
|
||||
}
|
||||
) as CordaRPCOps
|
||||
}
|
@ -0,0 +1,110 @@
|
||||
package net.corda.tools.shell
|
||||
|
||||
import com.jcabi.manifests.Manifests
|
||||
import joptsimple.OptionException
|
||||
import net.corda.core.internal.*
|
||||
import org.fusesource.jansi.Ansi
|
||||
import org.fusesource.jansi.AnsiConsole
|
||||
import java.net.URL
|
||||
import java.net.URLClassLoader
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlin.streams.toList
|
||||
import java.io.IOException
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
|
||||
val argsParser = CommandLineOptionParser()
|
||||
val cmdlineOptions = try {
|
||||
argsParser.parse(*args)
|
||||
} catch (e: OptionException) {
|
||||
println("Invalid command line arguments: ${e.message}")
|
||||
argsParser.printHelp(System.out)
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
if (cmdlineOptions.help) {
|
||||
argsParser.printHelp(System.out)
|
||||
return
|
||||
}
|
||||
val config = try {
|
||||
cmdlineOptions.toConfig()
|
||||
} catch(e: Exception) {
|
||||
println("Configuration exception: ${e.message}")
|
||||
exitProcess(1)
|
||||
}
|
||||
StandaloneShell(config).run()
|
||||
}
|
||||
|
||||
class StandaloneShell(private val configuration: ShellConfiguration) {
|
||||
|
||||
private fun getCordappsInDirectory(cordappsDir: Path?): List<URL> =
|
||||
if (cordappsDir == null || !cordappsDir.exists()) {
|
||||
emptyList()
|
||||
} else {
|
||||
cordappsDir.list {
|
||||
it.filter { it.isRegularFile() && it.toString().endsWith(".jar") }.map { it.toUri().toURL() }.toList()
|
||||
}
|
||||
}
|
||||
|
||||
//Workaround in case console is not available
|
||||
@Throws(IOException::class)
|
||||
private fun readLine(format: String, vararg args: Any): String {
|
||||
if (System.console() != null) {
|
||||
return System.console().readLine(format, *args)
|
||||
}
|
||||
print(String.format(format, *args))
|
||||
val reader = BufferedReader(InputStreamReader(System.`in`))
|
||||
return reader.readLine()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun readPassword(format: String, vararg args: Any) =
|
||||
if (System.console() != null) System.console().readPassword(format, *args) else this.readLine(format, *args).toCharArray()
|
||||
|
||||
private fun getManifestEntry(key: String) = if (Manifests.exists(key)) Manifests.read(key) else "Unknown"
|
||||
|
||||
fun run() {
|
||||
val cordappJarPaths = getCordappsInDirectory(configuration.cordappsDirectory)
|
||||
val classLoader: ClassLoader = URLClassLoader(cordappJarPaths.toTypedArray(), javaClass.classLoader)
|
||||
with(configuration) {
|
||||
if (user.isNullOrEmpty()) {
|
||||
user = readLine("User:")
|
||||
}
|
||||
if (password.isNullOrEmpty()) {
|
||||
password = String(readPassword("Password:"))
|
||||
}
|
||||
}
|
||||
InteractiveShell.startShell(configuration, classLoader)
|
||||
try {
|
||||
//connecting to node by requesting node info to fail fast
|
||||
InteractiveShell.nodeInfo()
|
||||
} catch (e: Exception) {
|
||||
println("Cannot login to ${configuration.hostAndPort}, reason: \"${e.message}\"")
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
val exit = CountDownLatch(1)
|
||||
AnsiConsole.systemInstall()
|
||||
println(Ansi.ansi().fgBrightRed().a(
|
||||
""" ______ __""").newline().a(
|
||||
""" / ____/ _________/ /___ _""").newline().a(
|
||||
""" / / __ / ___/ __ / __ `/ """).newline().fgBrightRed().a(
|
||||
"""/ /___ /_/ / / / /_/ / /_/ /""").newline().fgBrightRed().a(
|
||||
"""\____/ /_/ \__,_/\__,_/""").reset().fgBrightDefault().bold()
|
||||
.newline().a("--- ${getManifestEntry("Corda-Vendor")} ${getManifestEntry("Corda-Release-Version")} (${getManifestEntry("Corda-Revision").take(7)}) ---")
|
||||
.newline()
|
||||
.newline().a("Standalone Shell connected to ${configuration.hostAndPort}")
|
||||
.reset())
|
||||
InteractiveShell.runLocalShell {
|
||||
exit.countDown()
|
||||
}
|
||||
configuration.sshdPort?.apply{ println("SSH server listening on port $this.") }
|
||||
|
||||
exit.await()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
@ -0,0 +1,226 @@
|
||||
package net.corda.tools.shell
|
||||
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import joptsimple.OptionParser
|
||||
import joptsimple.util.EnumConverter
|
||||
import net.corda.core.internal.div
|
||||
import net.corda.core.utilities.NetworkHostAndPort
|
||||
import net.corda.nodeapi.internal.config.parseAs
|
||||
import net.corda.tools.shell.ShellConfiguration.Companion.COMMANDS_DIR
|
||||
import org.slf4j.event.Level
|
||||
import java.io.PrintStream
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
// NOTE: Do not use any logger in this class as args parsing is done before the logger is setup.
|
||||
class CommandLineOptionParser {
|
||||
private val optionParser = OptionParser()
|
||||
|
||||
private val configFileArg = optionParser
|
||||
.accepts("config-file", "The path to the shell configuration file, used instead of providing the rest of command line options.")
|
||||
.withOptionalArg()
|
||||
private val cordappsDirectoryArg = optionParser
|
||||
.accepts("cordpass-directory", "The path to directory containing Cordapps jars, Cordapps are require when starting flows.")
|
||||
.withOptionalArg()
|
||||
private val commandsDirectoryArg = optionParser
|
||||
.accepts("commands-directory", "The directory with additional CrAsH shell commands.")
|
||||
.withOptionalArg()
|
||||
private val hostArg = optionParser
|
||||
.acceptsAll(listOf("h","host"), "The host of the Corda node.")
|
||||
.withRequiredArg()
|
||||
private val portArg = optionParser
|
||||
.acceptsAll(listOf("p","port"), "The port of the Corda node.")
|
||||
.withRequiredArg()
|
||||
private val userArg = optionParser
|
||||
.accepts("user", "The RPC user name.")
|
||||
.withOptionalArg()
|
||||
private val passwordArg = optionParser
|
||||
.accepts("password", "The RPC user password.")
|
||||
.withOptionalArg()
|
||||
private val loggerLevel = optionParser
|
||||
.accepts("logging-level", "Enable logging at this level and higher.")
|
||||
.withRequiredArg()
|
||||
.withValuesConvertedBy(object : EnumConverter<Level>(Level::class.java) {})
|
||||
.defaultsTo(Level.INFO)
|
||||
private val sshdPortArg = optionParser
|
||||
.accepts("sshd-port", "Enables SSH server for shell.")
|
||||
.withOptionalArg()
|
||||
private val sshdHostKeyDirectoryArg = optionParser
|
||||
.accepts("sshd-hostkey-directory", "The directory with hostkey.pem file for SSH server.")
|
||||
.withOptionalArg()
|
||||
private val helpArg = optionParser
|
||||
.accepts("help")
|
||||
.forHelp()
|
||||
private val keyStorePasswordArg = optionParser
|
||||
.accepts("keystore-password", "The password to unlock the KeyStore file.")
|
||||
.withOptionalArg()
|
||||
private val keyStoreDirArg = optionParser
|
||||
.accepts("keystore-file", "The path to the KeyStore file.")
|
||||
.withOptionalArg()
|
||||
private val keyStoreTypeArg = optionParser
|
||||
.accepts("keystore-type", "The type of the KeyStore (e.g. JKS).")
|
||||
.withOptionalArg()
|
||||
private val trustStorePasswordArg = optionParser
|
||||
.accepts("truststore-password", "The password to unlock the TrustStore file.")
|
||||
.withOptionalArg()
|
||||
private val trustStoreDirArg = optionParser
|
||||
.accepts("truststore-file", "The path to the TrustStore file.")
|
||||
.withOptionalArg()
|
||||
private val trustStoreTypeArg = optionParser
|
||||
.accepts("truststore-type", "The type of the TrustStore (e.g. JKS).")
|
||||
.withOptionalArg()
|
||||
|
||||
fun parse(vararg args: String): CommandLineOptions {
|
||||
val optionSet = optionParser.parse(*args)
|
||||
return CommandLineOptions(
|
||||
configFile = optionSet.valueOf(configFileArg),
|
||||
host = optionSet.valueOf(hostArg),
|
||||
port = optionSet.valueOf(portArg),
|
||||
user = optionSet.valueOf(userArg),
|
||||
password = optionSet.valueOf(passwordArg),
|
||||
commandsDirectory = (optionSet.valueOf(commandsDirectoryArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
|
||||
cordappsDirectory = (optionSet.valueOf(cordappsDirectoryArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
|
||||
help = optionSet.has(helpArg),
|
||||
loggingLevel = optionSet.valueOf(loggerLevel),
|
||||
sshdPort = optionSet.valueOf(sshdPortArg),
|
||||
sshdHostKeyDirectory = (optionSet.valueOf(sshdHostKeyDirectoryArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
|
||||
keyStorePassword = optionSet.valueOf(keyStorePasswordArg),
|
||||
trustStorePassword = optionSet.valueOf(trustStorePasswordArg),
|
||||
keyStoreFile = (optionSet.valueOf(keyStoreDirArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
|
||||
trustStoreFile = (optionSet.valueOf(trustStoreDirArg))?.let { Paths.get(it).normalize().toAbsolutePath() },
|
||||
keyStoreType = optionSet.valueOf(keyStoreTypeArg),
|
||||
trustStoreType = optionSet.valueOf(trustStoreTypeArg))
|
||||
}
|
||||
|
||||
fun printHelp(sink: PrintStream) = optionParser.printHelpOn(sink)
|
||||
}
|
||||
|
||||
data class CommandLineOptions(val configFile: String?,
|
||||
val commandsDirectory: Path?,
|
||||
val cordappsDirectory: Path?,
|
||||
val host: String?,
|
||||
val port: String?,
|
||||
val user: String?,
|
||||
val password: String?,
|
||||
val help: Boolean,
|
||||
val loggingLevel: Level,
|
||||
val sshdPort: String?,
|
||||
val sshdHostKeyDirectory: Path?,
|
||||
val keyStorePassword: String?,
|
||||
val trustStorePassword: String?,
|
||||
val keyStoreFile: Path?,
|
||||
val trustStoreFile: Path?,
|
||||
val keyStoreType: String?,
|
||||
val trustStoreType: String?) {
|
||||
|
||||
private fun toConfigFile(): Config {
|
||||
val cmdOpts = mutableMapOf<String, Any?>()
|
||||
|
||||
commandsDirectory?.apply { cmdOpts["extensions.commands.path"] = this.toString() }
|
||||
cordappsDirectory?.apply { cmdOpts["extensions.cordapps.path"] = this.toString() }
|
||||
user?.apply { cmdOpts["node.user"] = this }
|
||||
password?.apply { cmdOpts["node.password"] = this }
|
||||
host?.apply { cmdOpts["node.addresses.rpc.host"] = this }
|
||||
port?.apply { cmdOpts["node.addresses.rpc.port"] = this }
|
||||
keyStoreFile?.apply { cmdOpts["ssl.keystore.path"] = this.toString() }
|
||||
keyStorePassword?.apply { cmdOpts["ssl.keystore.password"] = this }
|
||||
keyStoreType?.apply { cmdOpts["ssl.keystore.type"] = this }
|
||||
trustStoreFile?.apply { cmdOpts["ssl.truststore.path"] = this.toString() }
|
||||
trustStorePassword?.apply { cmdOpts["ssl.truststore.password"] = this }
|
||||
trustStoreType?.apply { cmdOpts["ssl.truststore.type"] = this }
|
||||
sshdPort?.apply {
|
||||
cmdOpts["extensions.sshd.port"] = this
|
||||
cmdOpts["extensions.sshd.enabled"] = true
|
||||
}
|
||||
sshdHostKeyDirectory?.apply { cmdOpts["extensions.sshd.hostkeypath"] = this.toString() }
|
||||
|
||||
return ConfigFactory.parseMap(cmdOpts)
|
||||
}
|
||||
|
||||
/** Return configuration parsed from an optional config file (provided by the command line option)
|
||||
* and then overridden by the command line options */
|
||||
fun toConfig(): ShellConfiguration {
|
||||
val fileConfig = configFile?.let { ConfigFactory.parseFile(Paths.get(configFile).toFile()) }
|
||||
?: ConfigFactory.empty()
|
||||
val typeSafeConfig = toConfigFile().withFallback(fileConfig).resolve()
|
||||
val shellConfigFile = typeSafeConfig.parseAs<ShellConfigurationFile.ShellConfigFile>()
|
||||
return shellConfigFile.toShellConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
/** Object representation of Shell configuration file */
|
||||
private class ShellConfigurationFile {
|
||||
data class Rpc(
|
||||
val host: String,
|
||||
val port: Int)
|
||||
|
||||
data class Addresses(
|
||||
val rpc: Rpc
|
||||
)
|
||||
|
||||
data class Node(
|
||||
val addresses: Addresses,
|
||||
val user: String?,
|
||||
val password: String?
|
||||
)
|
||||
|
||||
data class Cordapps(
|
||||
val path: String
|
||||
)
|
||||
|
||||
data class Sshd(
|
||||
val enabled: Boolean,
|
||||
val port: Int,
|
||||
val hostkeypath: String?
|
||||
)
|
||||
|
||||
data class Commands(
|
||||
val path: String
|
||||
)
|
||||
|
||||
data class Extensions(
|
||||
val cordapps: Cordapps,
|
||||
val sshd: Sshd,
|
||||
val commands: Commands?
|
||||
)
|
||||
|
||||
data class KeyStore(
|
||||
val path: String,
|
||||
val type: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class Ssl(
|
||||
val keystore: KeyStore,
|
||||
val truststore: KeyStore
|
||||
)
|
||||
|
||||
data class ShellConfigFile(
|
||||
val node: Node,
|
||||
val extensions: Extensions?,
|
||||
val ssl: Ssl?
|
||||
) {
|
||||
fun toShellConfiguration(): ShellConfiguration {
|
||||
|
||||
val sslOptions =
|
||||
ssl?.let {
|
||||
ShellSslOptions(
|
||||
sslKeystore = Paths.get(it.keystore.path),
|
||||
keyStorePassword = it.keystore.password,
|
||||
trustStoreFile = Paths.get(it.truststore.path),
|
||||
trustStorePassword = it.truststore.password)
|
||||
}
|
||||
|
||||
return ShellConfiguration(
|
||||
commandsDirectory = extensions?.commands?.let { Paths.get(it.path) } ?: Paths.get(".") / COMMANDS_DIR,
|
||||
cordappsDirectory = extensions?.cordapps?.let { Paths.get(it.path) },
|
||||
user = node.user ?: "",
|
||||
password = node.password ?: "",
|
||||
hostAndPort = NetworkHostAndPort(node.addresses.rpc.host, node.addresses.rpc.port),
|
||||
ssl = sslOptions,
|
||||
sshdPort = extensions?.sshd?.let { if (it.enabled) it.port else null },
|
||||
sshHostKeyDirectory = extensions?.sshd?.let { if (it.enabled && it.hostkeypath != null) Paths.get(it.hostkeypath) else null })
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,273 @@
|
||||
/*
|
||||
* R3 Proprietary and Confidential
|
||||
*
|
||||
* Copyright (c) 2018 R3 Limited. All rights reserved.
|
||||
*
|
||||
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
|
||||
*
|
||||
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
|
||||
*/
|
||||
|
||||
package net.corda.tools.shell.utlities
|
||||
|
||||
import net.corda.core.internal.Emoji
|
||||
import net.corda.core.messaging.FlowProgressHandle
|
||||
import org.apache.logging.log4j.LogManager
|
||||
import org.apache.logging.log4j.core.LogEvent
|
||||
import org.apache.logging.log4j.core.LoggerContext
|
||||
import org.apache.logging.log4j.core.appender.AbstractOutputStreamAppender
|
||||
import org.apache.logging.log4j.core.appender.ConsoleAppender
|
||||
import org.apache.logging.log4j.core.appender.OutputStreamManager
|
||||
import org.crsh.text.RenderPrintWriter
|
||||
import org.fusesource.jansi.Ansi
|
||||
import org.fusesource.jansi.AnsiConsole
|
||||
import org.fusesource.jansi.AnsiOutputStream
|
||||
import rx.Subscription
|
||||
|
||||
abstract class ANSIProgressRenderer {
|
||||
|
||||
private var subscriptionIndex: Subscription? = null
|
||||
private var subscriptionTree: Subscription? = null
|
||||
|
||||
protected var usingANSI = false
|
||||
protected var checkEmoji = false
|
||||
|
||||
protected var treeIndex: Int = 0
|
||||
protected var tree: List<Pair<Int,String>> = listOf()
|
||||
|
||||
private var installedYet = false
|
||||
|
||||
private var onDone: () -> Unit = {}
|
||||
|
||||
// prevMessagePrinted is just for non-ANSI mode.
|
||||
private var prevMessagePrinted: String? = null
|
||||
// prevLinesDraw is just for ANSI mode.
|
||||
protected var prevLinesDrawn = 0
|
||||
|
||||
private fun done(error: Throwable?) {
|
||||
if (error == null) _render(null)
|
||||
draw(true, error)
|
||||
onDone()
|
||||
}
|
||||
|
||||
fun render(flowProgressHandle: FlowProgressHandle<*>, onDone: () -> Unit = {}) {
|
||||
this.onDone = onDone
|
||||
_render(flowProgressHandle)
|
||||
}
|
||||
|
||||
protected abstract fun printLine(line:String)
|
||||
|
||||
protected abstract fun printAnsi(ansi:Ansi)
|
||||
|
||||
protected abstract fun setup()
|
||||
|
||||
private fun _render(flowProgressHandle: FlowProgressHandle<*>?) {
|
||||
subscriptionIndex?.unsubscribe()
|
||||
subscriptionTree?.unsubscribe()
|
||||
treeIndex = 0
|
||||
tree = listOf()
|
||||
|
||||
if (!installedYet) {
|
||||
setup()
|
||||
installedYet = true
|
||||
}
|
||||
|
||||
prevMessagePrinted = null
|
||||
prevLinesDrawn = 0
|
||||
draw(true)
|
||||
|
||||
|
||||
flowProgressHandle?.apply {
|
||||
stepsTreeIndexFeed?.apply {
|
||||
treeIndex = snapshot
|
||||
subscriptionIndex = updates.subscribe({
|
||||
treeIndex = it
|
||||
draw(true)
|
||||
}, { done(it) }, { done(null) })
|
||||
}
|
||||
stepsTreeFeed?.apply {
|
||||
tree = snapshot
|
||||
subscriptionTree = updates.subscribe({
|
||||
tree = it
|
||||
draw(true)
|
||||
}, { done(it) }, { done(null) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Synchronized protected fun draw(moveUp: Boolean, error: Throwable? = null) {
|
||||
if (!usingANSI) {
|
||||
val currentMessage = tree.getOrNull(treeIndex)?.second
|
||||
if (currentMessage != null && currentMessage != prevMessagePrinted) {
|
||||
printLine(currentMessage)
|
||||
prevMessagePrinted = currentMessage
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fun printingBody() {
|
||||
// Handle the case where the number of steps in a progress tracker is changed during execution.
|
||||
val ansi = Ansi()
|
||||
if (prevLinesDrawn > 0 && moveUp)
|
||||
ansi.cursorUp(prevLinesDrawn)
|
||||
|
||||
// Put a blank line between any logging and us.
|
||||
ansi.eraseLine()
|
||||
ansi.newline()
|
||||
if (tree.isEmpty()) return
|
||||
var newLinesDrawn = 1 + renderLevel(ansi, error != null)
|
||||
|
||||
if (error != null) {
|
||||
ansi.a("${Emoji.skullAndCrossbones} ${error.message}")
|
||||
ansi.eraseLine(Ansi.Erase.FORWARD)
|
||||
ansi.newline()
|
||||
newLinesDrawn++
|
||||
}
|
||||
|
||||
if (newLinesDrawn < prevLinesDrawn) {
|
||||
// If some steps were removed from the progress tracker, we don't want to leave junk hanging around below.
|
||||
val linesToClear = prevLinesDrawn - newLinesDrawn
|
||||
repeat(linesToClear) {
|
||||
ansi.eraseLine()
|
||||
ansi.newline()
|
||||
}
|
||||
ansi.cursorUp(linesToClear)
|
||||
}
|
||||
prevLinesDrawn = newLinesDrawn
|
||||
|
||||
printAnsi(ansi)
|
||||
}
|
||||
|
||||
if (checkEmoji) {
|
||||
Emoji.renderIfSupported(::printingBody)
|
||||
} else {
|
||||
printingBody()
|
||||
}
|
||||
}
|
||||
|
||||
// Returns number of lines rendered.
|
||||
private fun renderLevel(ansi: Ansi, error: Boolean): Int {
|
||||
with(ansi) {
|
||||
var lines = 0
|
||||
for ((index, step) in tree.withIndex()) {
|
||||
|
||||
val marker = when {
|
||||
index < treeIndex -> "${Emoji.greenTick} "
|
||||
treeIndex == tree.lastIndex -> "${Emoji.greenTick} "
|
||||
index == treeIndex -> "${Emoji.rightArrow} "
|
||||
error -> "${Emoji.noEntry} "
|
||||
else -> " " // Not reached yet.
|
||||
}
|
||||
a(" ".repeat(step.first))
|
||||
a(marker)
|
||||
|
||||
val active = index == treeIndex
|
||||
if (active) bold()
|
||||
a(step.second)
|
||||
if (active) boldOff()
|
||||
|
||||
eraseLine(Ansi.Erase.FORWARD)
|
||||
newline()
|
||||
lines++
|
||||
}
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
class CRaSHANSIProgressRenderer(val renderPrintWriter:RenderPrintWriter) : ANSIProgressRenderer() {
|
||||
|
||||
override fun printLine(line: String) {
|
||||
renderPrintWriter.println(line)
|
||||
}
|
||||
|
||||
override fun printAnsi(ansi: Ansi) {
|
||||
renderPrintWriter.print(ansi)
|
||||
renderPrintWriter.flush()
|
||||
}
|
||||
|
||||
override fun setup() {
|
||||
// We assume SSH always use ANSI.
|
||||
usingANSI = true
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Knows how to render a [FlowProgressHandle] to the terminal using coloured, emoji-fied output. Useful when writing small
|
||||
* command line tools, demos, tests etc. Just call [draw] method and it will go ahead and start drawing
|
||||
* if the terminal supports it. Otherwise it just prints out the name of the step whenever it changes.
|
||||
*
|
||||
* When a progress tracker is on the screen, it takes over the bottom part and reconfigures logging so that, assuming
|
||||
* 1 log event == 1 line, the progress tracker is always glued to the bottom and logging scrolls above it.
|
||||
*
|
||||
* TODO: More thread safety
|
||||
*/
|
||||
object StdoutANSIProgressRenderer : ANSIProgressRenderer() {
|
||||
|
||||
override fun setup() {
|
||||
AnsiConsole.systemInstall()
|
||||
|
||||
checkEmoji = true
|
||||
|
||||
// This line looks weird as hell because the magic code to decide if we really have a TTY or not isn't
|
||||
// actually exposed anywhere as a function (weak sauce). So we have to rely on our knowledge of jansi
|
||||
// implementation details.
|
||||
usingANSI = AnsiConsole.wrapOutputStream(System.out) !is AnsiOutputStream
|
||||
|
||||
if (usingANSI) {
|
||||
// This super ugly code hacks into log4j and swaps out its console appender for our own. It's a bit simpler
|
||||
// than doing things the official way with a dedicated plugin, etc, as it avoids mucking around with all
|
||||
// the config XML and lifecycle goop.
|
||||
val manager = LogManager.getContext(false) as LoggerContext
|
||||
val consoleAppender = manager.configuration.appenders.values.filterIsInstance<ConsoleAppender>().single { it.name == "Console-Appender" }
|
||||
val scrollingAppender = object : AbstractOutputStreamAppender<OutputStreamManager>(
|
||||
consoleAppender.name, consoleAppender.layout, consoleAppender.filter,
|
||||
consoleAppender.ignoreExceptions(), true, consoleAppender.manager) {
|
||||
override fun append(event: LogEvent) {
|
||||
// We lock on the renderer to avoid threads that are logging to the screen simultaneously messing
|
||||
// things up. Of course this slows stuff down a bit, but only whilst this little utility is in use.
|
||||
// Eventually it will be replaced with a real GUI and we can delete all this.
|
||||
synchronized(StdoutANSIProgressRenderer) {
|
||||
if (tree.isNotEmpty()) {
|
||||
val ansi = Ansi.ansi()
|
||||
repeat(prevLinesDrawn) { ansi.eraseLine().cursorUp(1).eraseLine() }
|
||||
System.out.print(ansi)
|
||||
System.out.flush()
|
||||
}
|
||||
|
||||
super.append(event)
|
||||
|
||||
if (tree.isNotEmpty())
|
||||
draw(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
scrollingAppender.start()
|
||||
manager.configuration.appenders[consoleAppender.name] = scrollingAppender
|
||||
val loggerConfigs = manager.configuration.loggers.values
|
||||
for (config in loggerConfigs) {
|
||||
val appenderRefs = config.appenderRefs
|
||||
val consoleAppenders = config.appenders.filter { it.value is ConsoleAppender }.keys
|
||||
consoleAppenders.forEach { config.removeAppender(it) }
|
||||
appenderRefs.forEach { config.addAppender(manager.configuration.appenders[it.ref], it.level, it.filter) }
|
||||
}
|
||||
manager.updateLoggers()
|
||||
}
|
||||
}
|
||||
|
||||
override fun printLine(line:String) {
|
||||
System.out.println(line)
|
||||
}
|
||||
|
||||
override fun printAnsi(ansi: Ansi) {
|
||||
// Need to force a flush here in order to ensure stderr/stdout sync up properly.
|
||||
System.out.print(ansi)
|
||||
System.out.flush()
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
/*
|
||||
* R3 Proprietary and Confidential
|
||||
*
|
||||
* Copyright (c) 2018 R3 Limited. All rights reserved.
|
||||
*
|
||||
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
|
||||
*
|
||||
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
|
||||
*/
|
||||
|
||||
package net.corda.tools.shell.base
|
||||
|
||||
// Note that this file MUST be in a sub-directory called "base" relative to the path
|
||||
// given in the configuration code in InteractiveShell.
|
||||
|
||||
welcome = """
|
||||
|
||||
Welcome to the Corda interactive shell.
|
||||
Useful commands include 'help' to see what is available, and 'bye' to shut down the node.
|
||||
|
||||
"""
|
||||
|
||||
prompt = { ->
|
||||
return "${new Date()}>>> "
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
/*
|
||||
* R3 Proprietary and Confidential
|
||||
*
|
||||
* Copyright (c) 2018 R3 Limited. All rights reserved.
|
||||
*
|
||||
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
|
||||
*
|
||||
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
|
||||
*/
|
||||
|
||||
package net.corda.tools.shell
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonMappingException
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import net.corda.core.contracts.UniqueIdentifier
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.*
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class CustomTypeJsonParsingTests {
|
||||
lateinit var objectMapper: ObjectMapper
|
||||
|
||||
//Dummy classes for testing.
|
||||
data class State(val linearId: UniqueIdentifier) {
|
||||
constructor() : this(UniqueIdentifier("required-for-json-deserializer"))
|
||||
}
|
||||
|
||||
data class UuidState(val uuid: UUID) {
|
||||
//Default constructor required for json deserializer.
|
||||
constructor() : this(UUID.randomUUID())
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
objectMapper = ObjectMapper()
|
||||
val simpleModule = SimpleModule()
|
||||
simpleModule.addDeserializer(UniqueIdentifier::class.java, InteractiveShell.UniqueIdentifierDeserializer)
|
||||
simpleModule.addDeserializer(UUID::class.java, InteractiveShell.UUIDDeserializer)
|
||||
objectMapper.registerModule(simpleModule)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Deserializing UniqueIdentifier by parsing string`() {
|
||||
val id = "26b37265-a1fd-4c77-b2e0-715917ef619f"
|
||||
val json = """{"linearId":"$id"}"""
|
||||
val state = objectMapper.readValue<State>(json)
|
||||
|
||||
assertEquals(id, state.linearId.id.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Deserializing UniqueIdentifier by parsing string with underscore`() {
|
||||
val json = """{"linearId":"extkey564_26b37265-a1fd-4c77-b2e0-715917ef619f"}"""
|
||||
val state = objectMapper.readValue<State>(json)
|
||||
|
||||
assertEquals("extkey564", state.linearId.externalId)
|
||||
assertEquals("26b37265-a1fd-4c77-b2e0-715917ef619f", state.linearId.id.toString())
|
||||
}
|
||||
|
||||
@Test(expected = JsonMappingException::class)
|
||||
fun `Deserializing by parsing string contain invalid uuid with underscore`() {
|
||||
val json = """{"linearId":"extkey564_26b37265-a1fd-4c77-b2e0"}"""
|
||||
objectMapper.readValue<State>(json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Deserializing UUID by parsing string`() {
|
||||
val json = """{"uuid":"26b37265-a1fd-4c77-b2e0-715917ef619f"}"""
|
||||
val state = objectMapper.readValue<UuidState>(json)
|
||||
|
||||
assertEquals("26b37265-a1fd-4c77-b2e0-715917ef619f", state.uuid.toString())
|
||||
}
|
||||
|
||||
@Test(expected = JsonMappingException::class)
|
||||
fun `Deserializing UUID by parsing invalid uuid string`() {
|
||||
val json = """{"uuid":"26b37265-a1fd-4c77-b2e0"}"""
|
||||
objectMapper.readValue<UuidState>(json)
|
||||
}
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
/*
|
||||
* R3 Proprietary and Confidential
|
||||
*
|
||||
* Copyright (c) 2018 R3 Limited. All rights reserved.
|
||||
*
|
||||
* The intellectual and technical concepts contained herein are proprietary to R3 and its suppliers and are protected by trade secret law.
|
||||
*
|
||||
* Distribution of this file or any portion thereof via any medium without the express permission of R3 is strictly prohibited.
|
||||
*/
|
||||
|
||||
package net.corda.tools.shell
|
||||
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
|
||||
import net.corda.client.jackson.JacksonSupport
|
||||
import net.corda.core.contracts.Amount
|
||||
import net.corda.core.crypto.SecureHash
|
||||
import net.corda.core.flows.FlowLogic
|
||||
import net.corda.core.flows.StateMachineRunId
|
||||
import net.corda.core.identity.CordaX500Name
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.internal.concurrent.openFuture
|
||||
import net.corda.core.messaging.FlowProgressHandleImpl
|
||||
import net.corda.core.utilities.ProgressTracker
|
||||
import net.corda.node.services.identity.InMemoryIdentityService
|
||||
import net.corda.testing.internal.DEV_ROOT_CA
|
||||
import net.corda.testing.core.TestIdentity
|
||||
import org.junit.Test
|
||||
import rx.Observable
|
||||
import java.util.*
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class InteractiveShellTest {
|
||||
companion object {
|
||||
private val megaCorp = TestIdentity(CordaX500Name("MegaCorp", "London", "GB"))
|
||||
}
|
||||
|
||||
@Suppress("UNUSED")
|
||||
class FlowA(val a: String) : FlowLogic<String>() {
|
||||
constructor(b: Int?) : this(b.toString())
|
||||
constructor(b: Int?, c: String) : this(b.toString() + c)
|
||||
constructor(amount: Amount<Currency>) : this(amount.toString())
|
||||
constructor(pair: Pair<Amount<Currency>, SecureHash.SHA256>) : this(pair.toString())
|
||||
constructor(party: Party) : this(party.name.toString())
|
||||
|
||||
override val progressTracker = ProgressTracker()
|
||||
override fun call() = a
|
||||
}
|
||||
|
||||
private val ids = InMemoryIdentityService(arrayOf(megaCorp.identity), DEV_ROOT_CA.certificate)
|
||||
private val om = JacksonSupport.createInMemoryMapper(ids, YAMLFactory())
|
||||
|
||||
private fun check(input: String, expected: String) {
|
||||
var output: String? = null
|
||||
InteractiveShell.runFlowFromString( { clazz, args ->
|
||||
|
||||
val instance = clazz.getConstructor(*args.map { it!!::class.java }.toTypedArray()).newInstance(*args) as FlowA
|
||||
output = instance.a
|
||||
val future = openFuture<String>()
|
||||
future.set("ABC")
|
||||
FlowProgressHandleImpl(StateMachineRunId.createRandom(), future, Observable.just("Some string"))
|
||||
}, input, FlowA::class.java, om)
|
||||
assertEquals(expected, output!!, input)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flowStartSimple() {
|
||||
check("a: Hi there", "Hi there")
|
||||
check("b: 12", "12")
|
||||
check("b: 12, c: Yo", "12Yo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flowStartWithComplexTypes() = check("amount: £10", "10.00 GBP")
|
||||
|
||||
@Test
|
||||
fun flowStartWithNestedTypes() = check(
|
||||
"pair: { first: $100.12, second: df489807f81c8c8829e509e1bcb92e6692b9dd9d624b7456435cb2f51dc82587 }",
|
||||
"($100.12, df489807f81c8c8829e509e1bcb92e6692b9dd9d624b7456435cb2f51dc82587)"
|
||||
)
|
||||
|
||||
@Test(expected = InteractiveShell.NoApplicableConstructor::class)
|
||||
fun flowStartNoArgs() = check("", "")
|
||||
|
||||
@Test(expected = InteractiveShell.NoApplicableConstructor::class)
|
||||
fun flowMissingParam() = check("c: Yo", "")
|
||||
|
||||
@Test(expected = InteractiveShell.NoApplicableConstructor::class)
|
||||
fun flowTooManyParams() = check("b: 12, c: Yo, d: Bar", "")
|
||||
|
||||
@Test
|
||||
fun party() = check("party: \"${megaCorp.name}\"", megaCorp.name.toString())
|
||||
}
|
@ -0,0 +1,204 @@
|
||||
package net.corda.tools.shell
|
||||
|
||||
import net.corda.core.utilities.NetworkHostAndPort
|
||||
import org.junit.Test
|
||||
import org.slf4j.event.Level
|
||||
import java.nio.file.Paths
|
||||
import kotlin.test.assertEquals
|
||||
import java.io.File
|
||||
|
||||
class StandaloneShellArgsParserTest {
|
||||
|
||||
private val CONFIG_FILE = File(javaClass.classLoader.getResource("config.conf")!!.file)
|
||||
|
||||
@Test
|
||||
fun args_to_cmd_options() {
|
||||
|
||||
val args = arrayOf("--config-file", "/x/y/z/config.conf",
|
||||
"--commands-directory", "/x/y/commands",
|
||||
"--cordpass-directory", "/x/y/cordapps",
|
||||
"--host", "alocalhost",
|
||||
"--port", "1234",
|
||||
"--user", "demo",
|
||||
"--password", "abcd1234",
|
||||
"--logging-level", "DEBUG",
|
||||
"--sshd-port", "2223",
|
||||
"--sshd-hostkey-directory", "/x/y/ssh",
|
||||
"--help",
|
||||
"--keystore-password", "pass1",
|
||||
"--truststore-password", "pass2",
|
||||
"--keystore-file", "/x/y/keystore.jks",
|
||||
"--truststore-file", "/x/y/truststore.jks",
|
||||
"--truststore-type", "dummy",
|
||||
"--keystore-type", "JKS")
|
||||
|
||||
val expectedOptions = CommandLineOptions(configFile = "/x/y/z/config.conf",
|
||||
commandsDirectory = Paths.get("/x/y/commands"),
|
||||
cordappsDirectory = Paths.get("/x/y/cordapps"),
|
||||
host = "alocalhost",
|
||||
port = "1234",
|
||||
user = "demo",
|
||||
password = "abcd1234",
|
||||
help = true,
|
||||
loggingLevel = Level.DEBUG,
|
||||
sshdPort = "2223",
|
||||
sshdHostKeyDirectory = Paths.get("/x/y/ssh"),
|
||||
keyStorePassword = "pass1",
|
||||
trustStorePassword = "pass2",
|
||||
keyStoreFile = Paths.get("/x/y/keystore.jks"),
|
||||
trustStoreFile = Paths.get("/x/y/truststore.jks"),
|
||||
trustStoreType = "dummy",
|
||||
keyStoreType = "JKS")
|
||||
|
||||
val options = CommandLineOptionParser().parse(*args)
|
||||
|
||||
assertEquals(expectedOptions, options)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun empty_args_to_cmd_options() {
|
||||
val args = emptyArray<String>()
|
||||
|
||||
val expectedOptions = CommandLineOptions(configFile = null,
|
||||
commandsDirectory = null,
|
||||
cordappsDirectory = null,
|
||||
host = null,
|
||||
port = null,
|
||||
user = null,
|
||||
password = null,
|
||||
help = false,
|
||||
loggingLevel = Level.INFO,
|
||||
sshdPort = null,
|
||||
sshdHostKeyDirectory = null,
|
||||
keyStorePassword = null,
|
||||
trustStorePassword = null,
|
||||
keyStoreFile = null,
|
||||
trustStoreFile = null,
|
||||
trustStoreType = null,
|
||||
keyStoreType = null)
|
||||
|
||||
val options = CommandLineOptionParser().parse(*args)
|
||||
|
||||
assertEquals(expectedOptions, options)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun args_to_config() {
|
||||
|
||||
val options = CommandLineOptions(configFile = null,
|
||||
commandsDirectory = Paths.get("/x/y/commands"),
|
||||
cordappsDirectory = Paths.get("/x/y/cordapps"),
|
||||
host = "alocalhost",
|
||||
port = "1234",
|
||||
user = "demo",
|
||||
password = "abcd1234",
|
||||
help = true,
|
||||
loggingLevel = Level.DEBUG,
|
||||
sshdPort = "2223",
|
||||
sshdHostKeyDirectory = Paths.get("/x/y/ssh"),
|
||||
keyStorePassword = "pass1",
|
||||
trustStorePassword = "pass2",
|
||||
keyStoreFile = Paths.get("/x/y/keystore.jks"),
|
||||
trustStoreFile = Paths.get("/x/y/truststore.jks"),
|
||||
keyStoreType = "dummy",
|
||||
trustStoreType = "dummy"
|
||||
)
|
||||
|
||||
val expectedSsl = ShellSslOptions(sslKeystore = Paths.get("/x/y/keystore.jks"),
|
||||
keyStorePassword = "pass1",
|
||||
trustStoreFile = Paths.get("/x/y/truststore.jks"),
|
||||
trustStorePassword = "pass2")
|
||||
val expectedConfig = ShellConfiguration(
|
||||
commandsDirectory = Paths.get("/x/y/commands"),
|
||||
cordappsDirectory = Paths.get("/x/y/cordapps"),
|
||||
user = "demo",
|
||||
password = "abcd1234",
|
||||
hostAndPort = NetworkHostAndPort("alocalhost", 1234),
|
||||
ssl = expectedSsl,
|
||||
sshdPort = 2223,
|
||||
sshHostKeyDirectory = Paths.get("/x/y/ssh"),
|
||||
noLocalShell = false)
|
||||
|
||||
val config = options.toConfig()
|
||||
|
||||
assertEquals(expectedConfig, config)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acmd_options_to_config_from_file() {
|
||||
|
||||
val options = CommandLineOptions(configFile = CONFIG_FILE.absolutePath,
|
||||
commandsDirectory = null,
|
||||
cordappsDirectory = null,
|
||||
host = null,
|
||||
port = null,
|
||||
user = null,
|
||||
password = null,
|
||||
help = false,
|
||||
loggingLevel = Level.DEBUG,
|
||||
sshdPort = null,
|
||||
sshdHostKeyDirectory = null,
|
||||
keyStorePassword = null,
|
||||
trustStorePassword = null,
|
||||
keyStoreFile = null,
|
||||
trustStoreFile = null,
|
||||
keyStoreType = null,
|
||||
trustStoreType = null)
|
||||
|
||||
val expectedSsl = ShellSslOptions(sslKeystore = Paths.get("/x/y/keystore.jks"),
|
||||
keyStorePassword = "pass1",
|
||||
trustStoreFile = Paths.get("/x/y/truststore.jks"),
|
||||
trustStorePassword = "pass2")
|
||||
val expectedConfig = ShellConfiguration(
|
||||
commandsDirectory = Paths.get("/x/y/commands"),
|
||||
cordappsDirectory = Paths.get("/x/y/cordapps"),
|
||||
user = "demo",
|
||||
password = "abcd1234",
|
||||
hostAndPort = NetworkHostAndPort("alocalhost", 1234),
|
||||
ssl = expectedSsl,
|
||||
sshdPort = 2223)
|
||||
|
||||
val config = options.toConfig()
|
||||
|
||||
assertEquals(expectedConfig, config)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmd_options_override_config_from_file() {
|
||||
|
||||
val options = CommandLineOptions(configFile = CONFIG_FILE.absolutePath,
|
||||
commandsDirectory = null,
|
||||
cordappsDirectory = null,
|
||||
host = null,
|
||||
port = null,
|
||||
user = null,
|
||||
password = "blabla",
|
||||
help = false,
|
||||
loggingLevel = Level.DEBUG,
|
||||
sshdPort = null,
|
||||
sshdHostKeyDirectory = null,
|
||||
keyStorePassword = null,
|
||||
trustStorePassword = null,
|
||||
keyStoreFile = Paths.get("/x/y/cmd.jks"),
|
||||
trustStoreFile = null,
|
||||
keyStoreType = null,
|
||||
trustStoreType = null)
|
||||
|
||||
val expectedSsl = ShellSslOptions(sslKeystore = Paths.get("/x/y/cmd.jks"),
|
||||
keyStorePassword = "pass1",
|
||||
trustStoreFile = Paths.get("/x/y/truststore.jks"),
|
||||
trustStorePassword = "pass2")
|
||||
val expectedConfig = ShellConfiguration(
|
||||
commandsDirectory = Paths.get("/x/y/commands"),
|
||||
cordappsDirectory = Paths.get("/x/y/cordapps"),
|
||||
user = "demo",
|
||||
password = "blabla",
|
||||
hostAndPort = NetworkHostAndPort("alocalhost", 1234),
|
||||
ssl = expectedSsl,
|
||||
sshdPort = 2223)
|
||||
|
||||
val config = options.toConfig()
|
||||
|
||||
assertEquals(expectedConfig, config)
|
||||
}
|
||||
}
|
34
tools/shell/src/test/resources/config.conf
Normal file
34
tools/shell/src/test/resources/config.conf
Normal file
@ -0,0 +1,34 @@
|
||||
node {
|
||||
addresses {
|
||||
rpc {
|
||||
host : "alocalhost"
|
||||
port : 1234
|
||||
}
|
||||
}
|
||||
user : demo
|
||||
password : abcd1234
|
||||
}
|
||||
extensions {
|
||||
cordapps {
|
||||
path : "/x/y/cordapps"
|
||||
}
|
||||
sshd {
|
||||
enabled : "true"
|
||||
port : 2223
|
||||
}
|
||||
commands {
|
||||
path : /x/y/commands
|
||||
}
|
||||
}
|
||||
ssl {
|
||||
keystore {
|
||||
path : "/x/y/keystore.jks"
|
||||
type : "JKS"
|
||||
password : "pass1"
|
||||
}
|
||||
truststore {
|
||||
path : "/x/y/truststore.jks"
|
||||
type : "JKS"
|
||||
password : "pass2"
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user