From c966769ec7654596eea36d1fbc56cbf20d4e2233 Mon Sep 17 00:00:00 2001 From: Peter Somogyvari Date: Thu, 25 Mar 2021 18:04:46 -0700 Subject: [PATCH] feat(corda-connector): scp jars to nodes #621 Implements transferring the cordapp jar files onto Corda nodes via SSH+SCP. It supports deploying to multiple nodes in a single request via an array of deployment configuration parameters that the caller can specify. Credentials are travelling on the wire in plain text right now which is of course unacceptable and a follow-up issue has been created to rectify this by adding keychain support or some other type of solution that does not make it necessary for the caller to send up SSH and RPC credentials with their deployment request. Another thing that we'll have to fix prior to GA is that right now there is no SSH host key verification. For development mode Corda nodes: There's possibility to declare which cordapp jar contains database migrations for H2 and the deployment endpoint can run these as well. The graceful shutdown method is implemented on our RPC connection class because this is not yet supported on the version of Corda that we are targeting with the connector (v4.5). The new test that verifies that all is working well is called deploy-cordapp-jars-to-nodes.test.ts and what it does is very similar to the older test called jvm-kotlin-spring-server.test.ts but this one does its invocations with a contract that has been built and deployed from scratch not like the old test which relied on the jars already being present in the AIO Corda container by default. The current commit is also tagged in the container registry as: hyperledger/cactus-connector-corda-server:2021-03-16-feat-621 Fixes #621 Signed-off-by: Peter Somogyvari --- .../kotlin-spring/.openapi-generator/FILES | 3 + .../kotlin/gen/kotlin-spring/build.gradle.kts | 2 + ...piPluginLedgerConnectorCordaServiceImpl.kt | 295 +++++++++++++---- .../server/impl/InMemoryHostKeyVerifier.kt | 74 +++++ .../corda/server/impl/NodeRPCConnection.kt | 50 ++- .../server/model/CordaNodeSshCredentials.kt | 47 +++ .../corda/server/model/CordaRpcCredentials.kt | 42 +++ .../server/model/CordappDeploymentConfig.kt | 53 +++ .../model/DeployContractJarsV1Request.kt | 7 + .../connector/corda/server/model/JarFile.kt | 4 + .../src/main/json/openapi.json | 128 ++++++- .../generated/openapi/typescript-axios/api.ts | 123 +++++++ .../deploy-cordapp-jars-to-nodes.test.ts | 313 ++++++++++++++++++ .../jvm-kotlin-spring-server.test.ts | 15 +- .../integration/tap-parallel-not-ok | 0 .../typescript/corda/corda-test-ledger.ts | 11 +- .../main/typescript/corda/cordapp-jar-file.ts | 5 + .../src/main/typescript/public-api.ts | 2 + 18 files changed, 1103 insertions(+), 71 deletions(-) create mode 100644 packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/impl/InMemoryHostKeyVerifier.kt create mode 100644 packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordaNodeSshCredentials.kt create mode 100644 packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordaRpcCredentials.kt create mode 100644 packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordappDeploymentConfig.kt create mode 100644 packages/cactus-plugin-ledger-connector-corda/src/test/typescript/integration/deploy-cordapp-jars-to-nodes.test.ts create mode 100644 packages/cactus-plugin-ledger-connector-corda/src/test/typescript/integration/tap-parallel-not-ok create mode 100644 packages/cactus-test-tooling/src/main/typescript/corda/cordapp-jar-file.ts diff --git a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/.openapi-generator/FILES b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/.openapi-generator/FILES index d11078177b..c5163f9e93 100644 --- a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/.openapi-generator/FILES +++ b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/.openapi-generator/FILES @@ -4,7 +4,10 @@ settings.gradle src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/api/ApiPluginLedgerConnectorCorda.kt src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/api/ApiPluginLedgerConnectorCordaService.kt src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/api/ApiUtil.kt +src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordaNodeSshCredentials.kt +src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordaRpcCredentials.kt src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordaX500Name.kt +src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordappDeploymentConfig.kt src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordappInfo.kt src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/DeployContractJarsBadRequestV1Response.kt src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/DeployContractJarsSuccessV1Response.kt diff --git a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/build.gradle.kts b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/build.gradle.kts index 16716448ee..eeb2ddd800 100644 --- a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/build.gradle.kts +++ b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/build.gradle.kts @@ -62,6 +62,8 @@ dependencies { implementation("com.fasterxml.jackson.core:jackson-annotations:2.12.1") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.12.1") + implementation("com.hierynomus:sshj:0.31.0") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.springframework.boot:spring-boot-starter-test") { exclude(module = "junit") diff --git a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/impl/ApiPluginLedgerConnectorCordaServiceImpl.kt b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/impl/ApiPluginLedgerConnectorCordaServiceImpl.kt index 443bb76492..55d398a72e 100644 --- a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/impl/ApiPluginLedgerConnectorCordaServiceImpl.kt +++ b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/impl/ApiPluginLedgerConnectorCordaServiceImpl.kt @@ -8,25 +8,39 @@ import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.springframework.stereotype.Service import net.corda.core.flows.FlowLogic -import net.corda.core.messaging.CordaRPCOps; +import net.corda.core.messaging.CordaRPCOps import net.corda.core.messaging.FlowProgressHandle -import net.corda.core.node.NodeDiagnosticInfo import net.corda.core.transactions.SignedTransaction import net.corda.core.utilities.loggerFor +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.connection.channel.direct.Session +import net.schmizz.sshj.transport.TransportException +import net.schmizz.sshj.transport.verification.PromiscuousVerifier +import net.schmizz.sshj.userauth.method.AuthPassword +import net.schmizz.sshj.userauth.password.PasswordUtils +import net.schmizz.sshj.xfer.InMemorySourceFile import org.hyperledger.cactus.plugin.ledger.connector.corda.server.api.ApiPluginLedgerConnectorCordaService import org.hyperledger.cactus.plugin.ledger.connector.corda.server.model.* import org.xeustechnologies.jcl.JarClassLoader -import java.lang.IllegalArgumentException +import java.io.IOException +import java.io.InputStream +import java.lang.Exception import java.lang.IllegalStateException import java.lang.RuntimeException import java.lang.reflect.Constructor import java.util.* import java.util.concurrent.TimeUnit +import kotlin.IllegalArgumentException - -@Service() +// TODO Look into this project for powering the connector of ours: +// https://github.com/180Protocol/codaptor +@Service class ApiPluginLedgerConnectorCordaServiceImpl( - val rpc: NodeRPCConnection = NodeRPCConnection("localhost", "user1", "test", 10006) + // FIXME: We already have the code/annotations set up "the spring boot way" so that credentials do not need + // to be hardcoded like this. Not even sure if these magic strings here actually get used at all or if spring just + // overwrites the bean property with whatever it constructed internally based on the configuration. + // Either way, these magic strings gotta go. + val rpc: NodeRPCConnection ) : ApiPluginLedgerConnectorCordaService { companion object { @@ -88,53 +102,66 @@ class ApiPluginLedgerConnectorCordaServiceImpl( val clazz = getOrInferType(jvmObject.jvmType.fqClassName) - if (jvmObject.jvmTypeKind == JvmTypeKind.REFERENCE) { - if (jvmObject.jvmCtorArgs == null) { - throw IllegalArgumentException("jvmObject.jvmCtorArgs cannot be null when jvmObject.jvmTypeKind == JvmTypeKind.REFERENCE") + when (jvmObject.jvmTypeKind) { + JvmTypeKind.REFERENCE -> { + if (jvmObject.jvmCtorArgs == null) { + throw IllegalArgumentException("jvmObject.jvmCtorArgs cannot be null when jvmObject.jvmTypeKind == JvmTypeKind.REFERENCE") + } + val constructorArgs: Array = jvmObject.jvmCtorArgs.map { x -> instantiate(x) }.toTypedArray() + + when { + List::class.java.isAssignableFrom(clazz) -> { + return listOf(*constructorArgs) + } + Currency::class.java.isAssignableFrom(clazz) -> { + // FIXME introduce a more dynamic/flexible way of handling classes with no public constructors.... + return Currency.getInstance(jvmObject.jvmCtorArgs.first().primitiveValue as String) + } + Array::class.java.isAssignableFrom(clazz) -> { + // TODO verify that this actually works and also + // if we need it at all since we already have lists covered + return arrayOf(*constructorArgs) + } + else -> { + val constructorArgTypes: List> = + jvmObject.jvmCtorArgs.map { x -> getOrInferType(x.jvmType.fqClassName) } + val constructor: Constructor<*> + try { + constructor = clazz.constructors + .filter { c -> c.parameterCount == constructorArgTypes.size } + .single { c -> + c.parameterTypes + .mapIndexed { index, clazz -> clazz.isAssignableFrom(constructorArgTypes[index]) } + .all { x -> x } + } + } catch (ex: NoSuchElementException) { + val argTypes = jvmObject.jvmCtorArgs.joinToString(",") { x -> x.jvmType.fqClassName } + val className = jvmObject.jvmType.fqClassName + val constructorsAsStrings = clazz.constructors + .mapIndexed { i, c -> "$className->Constructor#${i + 1}(${c.parameterTypes.joinToString { p -> p.name }})" } + .joinToString(" ;; ") + val targetConstructor = "Cannot find matching constructor for ${className}(${argTypes})" + val availableConstructors = + "Searched among the ${clazz.constructors.size} available constructors: $constructorsAsStrings" + throw RuntimeException("$targetConstructor --- $availableConstructors") + } + + logger.info("Constructor=${constructor}") + constructorArgs.forEachIndexed { index, it -> logger.info("Constructor ARGS: #${index} -> $it") } + val instance = constructor.newInstance(*constructorArgs) + logger.info("Instantiated REFERENCE OK {}", instance) + return instance + } + } + } - val constructorArgs: Array = jvmObject.jvmCtorArgs.map { x -> instantiate(x) }.toTypedArray() - - if (List::class.java.isAssignableFrom(clazz)) { - return listOf(*constructorArgs) - } else if (Currency::class.java.isAssignableFrom(clazz)) { - // FIXME introduce a more dynamic/flexible way of handling classes with no public constructors.... - return Currency.getInstance(jvmObject.jvmCtorArgs.first().primitiveValue as String) - } else if (Array::class.java.isAssignableFrom(clazz)) { - // TODO verify that this actually works and also - // if we need it at all since we already have lists covered - return arrayOf(*constructorArgs) + JvmTypeKind.PRIMITIVE -> { + logger.info("Instantiated PRIMITIVE OK {}", jvmObject.primitiveValue) + return jvmObject.primitiveValue } - val constructorArgTypes: List> = jvmObject.jvmCtorArgs.map { x -> getOrInferType(x.jvmType.fqClassName) } - val constructor: Constructor<*> - try { - constructor = clazz.constructors - .filter { c -> c.parameterCount == constructorArgTypes.size } - .single { c -> - c.parameterTypes - .mapIndexed { index, clazz -> clazz.isAssignableFrom(constructorArgTypes[index]) } - .all { x -> x } - } - } catch (ex: NoSuchElementException) { - val argTypes = jvmObject.jvmCtorArgs.joinToString(",") { x -> x.jvmType.fqClassName } - val className = jvmObject.jvmType.fqClassName - val constructorsAsStrings = clazz.constructors - .mapIndexed { i, c -> "$className->Constructor#${i + 1}(${c.parameterTypes.joinToString { p -> p.name }})" } - .joinToString(" ;; ") - val targetConstructor = "Cannot find matching constructor for ${className}(${argTypes})" - val availableConstructors = "Searched among the ${clazz.constructors.size} available constructors: $constructorsAsStrings" - throw RuntimeException("$targetConstructor --- $availableConstructors") + else -> { + throw IllegalArgumentException("Unknown jvmObject.jvmTypeKind (${jvmObject.jvmTypeKind})") } - - logger.info("Constructor=${constructor}") - constructorArgs.forEachIndexed { index, it -> logger.info("Constructor ARGS: #${index} -> $it") } - val instance = constructor.newInstance(*constructorArgs) - logger.info("Instantiated REFERENCE OK {}", instance) - return instance - } else if (jvmObject.jvmTypeKind == JvmTypeKind.PRIMITIVE) { - logger.info("Instantiated PRIMITIVE OK {}", jvmObject.primitiveValue) - return jvmObject.primitiveValue - } else { - throw IllegalArgumentException("Unknown jvmObject.jvmTypeKind (${jvmObject.jvmTypeKind})") } } @@ -150,7 +177,7 @@ class ApiPluginLedgerConnectorCordaServiceImpl( } val timeoutMs: Long = req.timeoutMs?.toLong() ?: 60000 - + logger.debug("Invoking flow with timeout of $timeoutMs ms ...") val progress: List = when (req.flowInvocationType) { FlowInvocationType.TRACKED_FLOW_DYNAMIC -> (flowHandle as FlowProgressHandle<*>) .progress @@ -159,6 +186,7 @@ class ApiPluginLedgerConnectorCordaServiceImpl( .first() FlowInvocationType.FLOW_DYNAMIC -> emptyList() } + logger.debug("Starting to wait for flow completion now...") val returnValue = flowHandle.returnValue.get(timeoutMs, TimeUnit.MILLISECONDS) val id = flowHandle.id @@ -170,11 +198,11 @@ class ApiPluginLedgerConnectorCordaServiceImpl( // 2021-03-01 06:58:25.608 ERROR 7 --- [nio-8080-exec-7] o.a.c.c.C.[.[.[/].[dispatcherServlet]: // Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception // [Request processing failed; nested exception is org.springframework.http.converter.HttpMessageNotWritableException: - // Could not write JSON: Failed to deserialise group OUTPUTS_GROUP at index 0 in transaction: + // Could not write JSON: Failed to deserialize group OUTPUTS_GROUP at index 0 in transaction: // net.corda.samples.obligation.states.IOUState: Interface net.corda.core.contracts.LinearState // requires a field named participants but that isn't found in the schema or any superclass schemas; // nested exception is com.fasterxml.jackson.databind.JsonMappingException: - // Failed to deserialise group OUTPUTS_GROUP at index 0 in transaction: net.corda.samples.obligation.states.IOUState: + // Failed to deserialize group OUTPUTS_GROUP at index 0 in transaction: net.corda.samples.obligation.states.IOUState: // Interface net.corda.core.contracts.LinearState requires a field named participants but that isn't found in // the schema or any superclass schemas (through reference chain: // org.hyperledger.cactus.plugin.ledger.connector.corda.server.model.InvokeContractV1Response["returnValue"]-> @@ -183,13 +211,162 @@ class ApiPluginLedgerConnectorCordaServiceImpl( return InvokeContractV1Response(id.toString(), progress, (returnValue as SignedTransaction).id) } + // FIXME - make it clear in the documentation that this deployment endpoint is not recommended for production + // because it does not support taking all the precautionary steps that are recommended by the official docs here + // https://docs.corda.net/docs/corda-enterprise/4.6/node-upgrade-notes.html#step-1-drain-the-node + // The other solution is of course to make it so that this endpoint is a fully fledged, robust, production ready + // implementation and that would be preferred over the longer term, but maybe it's actually just scope creep... override fun deployContractJarsV1(deployContractJarsV1Request: DeployContractJarsV1Request?): DeployContractJarsSuccessV1Response { + if (deployContractJarsV1Request == null) { + throw IllegalArgumentException("DeployContractJarsV1Request cannot be null") + } try { val decoder = Base64.getDecoder() - val deployedJarFileNames = deployContractJarsV1Request!!.jarFiles.map { + + deployContractJarsV1Request.cordappDeploymentConfigs.forEachIndexed { index, cdc -> + val cred = cdc.sshCredentials + logger.debug("Creating new SSHClient object for CordappDeploymentConfig #$index...") + val ssh = SSHClient() + // FIXME we need to support host key verification as a minimum in order to claim that we + // are secure by default + // ssh.addHostKeyVerifier(cred.hostKeyEntry) + ssh.addHostKeyVerifier(PromiscuousVerifier()) + + logger.debug("Connecting with new SSHClient object for CordappDeploymentConfig #$index...") + val maxTries = 10; + val tryIntervalMs = 1000L; + var tries = 0 + + // TODO: pull this out to be a class level function instead of an inline one + fun tryConnectingToSshHost () { + tries++ + try { + ssh.connect(cred.hostname, cred.port) + } catch (ex: TransportException) { + if (tries < maxTries) { + Thread.sleep(tryIntervalMs) + tryConnectingToSshHost() + } else { + throw RuntimeException("Fed up after $maxTries retries while connecting to SSH host:", ex) + } + } + } + tryConnectingToSshHost() + + try { + // FIXME - plain text passwords sent in in the request is the worst possible solution (but was also + // the one that we could quickly get to work with) + // Need to implement public key authentication, also need to support pulling credentials from the keychain + // at least support pulling the private key being retrieved from the keychain and also to be specified + // as a file on the node's file system and also as an environment variable. + // Also need to document which one of these options is the most secure and that one has to be the default + // so that we are adhering to the "secure by default" design principle of ours. + ssh.auth(cred.username, AuthPassword(PasswordUtils.createOneOff(cred.password.toCharArray()))) + + logger.debug("Deploying to Node {} at host {}:{}:{}", index, cred.hostname, cred.port, cdc.cordappDir) + + try { + val nodeRPCConnection = NodeRPCConnection( + cdc.rpcCredentials.hostname, + cdc.rpcCredentials.username, + cdc.rpcCredentials.password, + cdc.rpcCredentials.port + ) + + nodeRPCConnection.initialiseNodeRPCConnection() + nodeRPCConnection.gracefulShutdown() + + } catch (ex: Exception) { + throw RuntimeException("Failed to gracefully shut down the node prior to cordapp deployment onto ${cred.hostname}:${cred.port}:${cdc.cordappDir}", ex) + } + + try { + deployContractJarsV1Request.jarFiles.map { + val jarFileString = decoder.decode(it.contentBase64) + + // TODO refactor this: write an actual class that implements the interface and then use that + // instead of creating anonymous classes inline... + val localSourceFile = object : InMemorySourceFile() { + + private val filename = it.filename + private val contentBase64 = it.contentBase64 + private val byteArray: ByteArray = decoder.decode(contentBase64) + private val inputStream: InputStream + + init { + inputStream = byteArray.inputStream() + } + + override fun getName(): String { + return filename + } + + override fun getLength(): Long { + val jarFileLength = byteArray.size.toLong() + logger.debug("jarFileLength: $jarFileLength for $filename") + return jarFileLength + } + + override fun getInputStream(): InputStream { + return inputStream + } + } + + val taskDescription = "SCP upload ${it.filename} (size=${jarFileString.size}) onto ${cred.hostname}:${cred.port}:${cdc.cordappDir}" + logger.debug("Starting $taskDescription") + ssh.newSCPFileTransfer().upload(localSourceFile, cdc.cordappDir) + logger.debug("Finished $taskDescription") + + if (it.hasDbMigrations) { + logger.debug("${it.filename} has db migrations declared, executing those now...") + val session = ssh.startSession() + session.allocateDefaultPTY() + val migrateCmd = "java -jar ${cdc.cordaJarPath} run-migration-scripts --app-schemas --base-directory=${cdc.nodeBaseDirPath}" + logger.debug("migrateCmd=$migrateCmd") + val migrateCmdRes = session.exec(migrateCmd) + val migrateCmdOut = net.schmizz.sshj.common.IOUtils.readFully(migrateCmdRes.inputStream).toString() + logger.debug("migrateCmdOut=${migrateCmdOut}") + session.close() + logger.debug("Closed the db migrations CMD SSH session successfully.") + } + it.filename + } + } catch (ex: Exception) { + throw RuntimeException("Failed to upload jars to corda node.", ex) + } + + val session: Session = ssh.startSession() + try { + val startNodeTask = "Starting of Corda node ${cred.hostname}:${cred.port} with CMD=${cdc.cordaNodeStartCmd}" + logger.debug("$startNodeTask ...") + session.allocateDefaultPTY() + val startNodeRes = session.exec(cdc.cordaNodeStartCmd) + val startNodeOut = net.schmizz.sshj.common.IOUtils.readFully(startNodeRes.inputStream).toString() + logger.debug("$startNodeTask successfully finished with: {}", startNodeOut) + } catch (ex: Exception) { + throw RuntimeException("Failed to start the node after the cordapp deployment onto ${cred.hostname}:${cred.port}:${cdc.cordappDir}", ex) + } finally { + try { + session.close() + logger.debug("Closed Corda Start CMD SSH session successfully.") + } catch (e: IOException) { + logger.warn("SSH session failed to close, but this might be normal based on the SSHJ docs/examples: ", e) + } + } + } finally { + logger.debug("Disconnecting from SSH host ${cred.hostname}:${cred.port}...") + try { + ssh.disconnect() + logger.debug("Disconnected OK from SSH host ${cred.hostname}:${cred.port}") + } catch (ex: Exception) { + logger.warn("Disconnect failed from SSH host ${cred.hostname}:${cred.port}. Ignoring since we are done anyway...") + } + } + } + val deployedJarFileNames = deployContractJarsV1Request.jarFiles.map { val jarFileInputStream = decoder.decode(it.contentBase64).inputStream() jcl.add(jarFileInputStream) - // FIXME SSH jar upload needs to be implemented as well + logger.info("Added jar to classpath of Corda Connector Plugin Server: ${it.filename}") it.filename } @@ -201,15 +378,15 @@ class ApiPluginLedgerConnectorCordaServiceImpl( } override fun diagnoseNodeV1(diagnoseNodeV1Request: DiagnoseNodeV1Request?): DiagnoseNodeV1Response { - val reader = mapper.readerFor(object : TypeReference() {}) + val reader = mapper.readerFor(object : TypeReference() {}) val nodeDiagnosticInfoCorda = rpc.proxy.nodeDiagnosticInfo() val json = writer.writeValueAsString(nodeDiagnosticInfoCorda) logger.debug("NodeDiagnosticInfo JSON=\n{}", json) - val nodeDiagnosticInfoCactus = reader.readValue(json) - logger.debug("Responding with marshalled org.hyperledger.cactus.plugin.ledger.connector.corda.server.model.NodeDiagnosticInfo: {}", nodeDiagnosticInfoCactus) + val nodeDiagnosticInfoCactus = reader.readValue(json) + logger.debug("Responding with marshalled ${NodeDiagnosticInfo::class.qualifiedName}: {}", nodeDiagnosticInfoCactus) return DiagnoseNodeV1Response(nodeDiagnosticInfo = nodeDiagnosticInfoCactus) } @@ -232,6 +409,6 @@ class ApiPluginLedgerConnectorCordaServiceImpl( val nodeInfoList = reader.readValue>(networkMapJson) logger.info("Returning {} NodeInfo elements in response.", nodeInfoList.size) - return nodeInfoList; + return nodeInfoList } } diff --git a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/impl/InMemoryHostKeyVerifier.kt b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/impl/InMemoryHostKeyVerifier.kt new file mode 100644 index 0000000000..3f033bd302 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/impl/InMemoryHostKeyVerifier.kt @@ -0,0 +1,74 @@ +package org.hyperledger.cactus.plugin.ledger.connector.corda.server.impl + +import net.corda.core.utilities.loggerFor +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.transport.verification.HostKeyVerifier +import net.schmizz.sshj.transport.verification.OpenSSHKnownHosts +import net.schmizz.sshj.transport.verification.OpenSSHKnownHosts.KnownHostEntry +import java.io.* +import java.lang.RuntimeException +import java.nio.charset.Charset +import java.security.PublicKey +import java.util.* + +// TODO: Once we are able to support host key verification this can be either +// deleted or just left alone if it actually ends up being used by the +// fix that makes it so that we can do host key verification. +class InMemoryHostKeyVerifier(inputStream: InputStream?, charset: Charset?) : + HostKeyVerifier { + private val entries: MutableList = ArrayList() + + companion object { + val logger = loggerFor() + } + + init { + + // we construct the OpenSSHKnownHosts instance with a dummy file that does not exist because + // that's the only way to trick it into doing nothing on the file system which is what we want + // since this implementation is about providing an in-memory host key verification process... + val nonExistentFilePath = UUID.randomUUID().toString() + val hostsFile = File(nonExistentFilePath) + val openSSHKnownHosts = OpenSSHKnownHosts(hostsFile) + + // we just wanted an EntryFactory which we could not instantiate without instantiating the OpenSSHKnownHosts + // class as well (which is a limitation of Kotlin compared to Java it seems). + val entryFactory: OpenSSHKnownHosts.EntryFactory = openSSHKnownHosts.EntryFactory() + val reader = BufferedReader(InputStreamReader(inputStream, charset)) + while (reader.ready()) { + val line = reader.readLine() + try { + logger.debug("Parsing line {}", line) + val entry = entryFactory.parseEntry(line) + if (entry != null) { + entries.add(entry) + logger.debug("Added entry {}", entry) + } + } catch (e: Exception) { + throw RuntimeException("Failed to init InMemoryHostKeyVerifier", e) + } + } + logger.info("Parsing of host key entries successful.") + } + + override fun verify(hostname: String, port: Int, key: PublicKey): Boolean { + logger.debug("Verifying {}:{} {}", hostname, port, key) + val type = KeyType.fromKey(key) + if (type === KeyType.UNKNOWN) { + logger.debug("Rejecting key due to unknown key type {}", type) + return false + } + for (e in entries) { + try { + if (e.appliesTo(type, hostname) && e.verify(key)) { + logger.debug("Accepting key type {} for host {} with key of {}", type, hostname, key) + return true + } + } catch (ioe: IOException) { + throw RuntimeException("Crashed while attempting to verify key type $type for host $hostname ", ioe) + } + } + logger.debug("Rejecting due to none of the {} entries being acceptable.", entries.size) + return false + } +} \ No newline at end of file diff --git a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/impl/NodeRPCConnection.kt b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/impl/NodeRPCConnection.kt index 2f28d887e3..f900f3569d 100644 --- a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/impl/NodeRPCConnection.kt +++ b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/impl/NodeRPCConnection.kt @@ -2,9 +2,13 @@ package org.hyperledger.cactus.plugin.ledger.connector.corda.server.impl import net.corda.client.rpc.CordaRPCClient +import net.corda.client.rpc.CordaRPCClientConfiguration import net.corda.client.rpc.CordaRPCConnection +import net.corda.client.rpc.GracefulReconnect import net.corda.core.messaging.CordaRPCOps +import net.corda.core.messaging.pendingFlowsCount import net.corda.core.utilities.NetworkHostAndPort +import net.corda.core.utilities.loggerFor import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import javax.annotation.PostConstruct @@ -15,6 +19,7 @@ import java.net.InetAddress import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.validation.annotation.Validated +import java.util.concurrent.CountDownLatch import javax.validation.constraints.NotEmpty import javax.validation.constraints.NotNull @@ -48,11 +53,23 @@ open class NodeRPCConnection( final lateinit var proxy: CordaRPCOps private set + companion object { + val logger = loggerFor(); + } + @PostConstruct fun initialiseNodeRPCConnection() { val rpcAddress = NetworkHostAndPort(host, rpcPort) - val rpcClient = CordaRPCClient(rpcAddress) - rpcConnection = rpcClient.start(username, password) + val rpcClient = CordaRPCClient(haAddressPool = listOf(rpcAddress)) + + var numReconnects = 0 + val gracefulReconnect = GracefulReconnect( + onDisconnect={ logger.info("GracefulReconnect:onDisconnect()")}, + onReconnect={ logger.info("GracefulReconnect:onReconnect() #${++numReconnects}")}, + maxAttempts = 30 + ) + + rpcConnection = rpcClient.start(username, password, gracefulReconnect = gracefulReconnect) proxy = rpcConnection.proxy } @@ -60,4 +77,33 @@ open class NodeRPCConnection( override fun close() { rpcConnection.notifyServerAndClose() } + + fun gracefulShutdown() { + logger.debug(("Beginning graceful shutdown...")) + val latch = CountDownLatch(1) + @Suppress("DEPRECATION") + val subscription = proxy.pendingFlowsCount().updates + .doAfterTerminate(latch::countDown) + .subscribe( + // For each update. + { (completed, total) -> logger.info("...remaining flows: $completed / $total") }, + // On error. + { + logger.error(it.message) + throw it + }, + // When completed. + { + // This will only show up in the standalone Shell, because the embedded one + // is killed as part of a node's shutdown. + logger.info("...done shutting down gracefully.") + } + ) + proxy.terminate(true) + latch.await() + logger.debug("Concluded graceful shutdown OK") + // Unsubscribe or we hold up the shutdown + subscription.unsubscribe() + rpcConnection.forceClose() + } } \ No newline at end of file diff --git a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordaNodeSshCredentials.kt b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordaNodeSshCredentials.kt new file mode 100644 index 0000000000..a81aa7e5dc --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordaNodeSshCredentials.kt @@ -0,0 +1,47 @@ +package org.hyperledger.cactus.plugin.ledger.connector.corda.server.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import javax.validation.constraints.DecimalMax +import javax.validation.constraints.DecimalMin +import javax.validation.constraints.Max +import javax.validation.constraints.Min +import javax.validation.constraints.NotNull +import javax.validation.constraints.Pattern +import javax.validation.constraints.Size +import javax.validation.Valid + +/** + * + * @param hostKeyEntry + * @param username + * @param password + * @param hostname + * @param port + */ +data class CordaNodeSshCredentials( + + @get:NotNull + @get:Size(min=1,max=65535) + @field:JsonProperty("hostKeyEntry") val hostKeyEntry: kotlin.String, + + @get:NotNull + @get:Size(min=1,max=32) + @field:JsonProperty("username") val username: kotlin.String, + + @get:NotNull + @get:Size(min=1,max=4096) + @field:JsonProperty("password") val password: kotlin.String, + + @get:NotNull + @get:Size(min=1,max=4096) + @field:JsonProperty("hostname") val hostname: kotlin.String, + + @get:NotNull + @get:Min(1) + @get:Max(65535) + @field:JsonProperty("port") val port: kotlin.Int +) { + +} + diff --git a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordaRpcCredentials.kt b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordaRpcCredentials.kt new file mode 100644 index 0000000000..bc6fcb6a07 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordaRpcCredentials.kt @@ -0,0 +1,42 @@ +package org.hyperledger.cactus.plugin.ledger.connector.corda.server.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import javax.validation.constraints.DecimalMax +import javax.validation.constraints.DecimalMin +import javax.validation.constraints.Max +import javax.validation.constraints.Min +import javax.validation.constraints.NotNull +import javax.validation.constraints.Pattern +import javax.validation.constraints.Size +import javax.validation.Valid + +/** + * + * @param hostname + * @param port + * @param username + * @param password + */ +data class CordaRpcCredentials( + + @get:NotNull + @get:Size(min=1,max=65535) + @field:JsonProperty("hostname") val hostname: kotlin.String, + + @get:NotNull + @get:Min(1) + @get:Max(65535) + @field:JsonProperty("port") val port: kotlin.Int, + + @get:NotNull + @get:Size(min=1,max=1024) + @field:JsonProperty("username") val username: kotlin.String, + + @get:NotNull + @get:Size(min=1,max=65535) + @field:JsonProperty("password") val password: kotlin.String +) { + +} + diff --git a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordappDeploymentConfig.kt b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordappDeploymentConfig.kt new file mode 100644 index 0000000000..e3858e487d --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/CordappDeploymentConfig.kt @@ -0,0 +1,53 @@ +package org.hyperledger.cactus.plugin.ledger.connector.corda.server.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import org.hyperledger.cactus.plugin.ledger.connector.corda.server.model.CordaNodeSshCredentials +import org.hyperledger.cactus.plugin.ledger.connector.corda.server.model.CordaRpcCredentials +import javax.validation.constraints.DecimalMax +import javax.validation.constraints.DecimalMin +import javax.validation.constraints.Max +import javax.validation.constraints.Min +import javax.validation.constraints.NotNull +import javax.validation.constraints.Pattern +import javax.validation.constraints.Size +import javax.validation.Valid + +/** + * + * @param sshCredentials + * @param rpcCredentials + * @param cordaNodeStartCmd The shell command to execute in order to start back up a Corda node after having placed new jars in the cordapp directory of said node. + * @param cordappDir The absolute file system path where the Corda Node is expecting deployed Cordapp jar files to be placed. + * @param cordaJarPath The absolute file system path where the corda.jar file of the node can be found. This is used to execute database schema migrations where applicable (H2 database in use in development environments). + * @param nodeBaseDirPath The absolute file system path where the base directory of the Corda node can be found. This is used to pass in to corda.jar when being invoked for certain tasks such as executing database schema migrations for a deployed contract. + */ +data class CordappDeploymentConfig( + + @get:NotNull + @field:Valid + @field:JsonProperty("sshCredentials") val sshCredentials: CordaNodeSshCredentials, + + @get:NotNull + @field:Valid + @field:JsonProperty("rpcCredentials") val rpcCredentials: CordaRpcCredentials, + + @get:NotNull + @get:Size(min=1,max=65535) + @field:JsonProperty("cordaNodeStartCmd") val cordaNodeStartCmd: kotlin.String, + + @get:NotNull + @get:Size(min=1,max=2048) + @field:JsonProperty("cordappDir") val cordappDir: kotlin.String, + + @get:NotNull + @get:Size(min=1,max=2048) + @field:JsonProperty("cordaJarPath") val cordaJarPath: kotlin.String, + + @get:NotNull + @get:Size(min=1,max=2048) + @field:JsonProperty("nodeBaseDirPath") val nodeBaseDirPath: kotlin.String +) { + +} + diff --git a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/DeployContractJarsV1Request.kt b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/DeployContractJarsV1Request.kt index 2d7bc83a96..3af4789d38 100644 --- a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/DeployContractJarsV1Request.kt +++ b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/DeployContractJarsV1Request.kt @@ -2,6 +2,7 @@ package org.hyperledger.cactus.plugin.ledger.connector.corda.server.model import java.util.Objects import com.fasterxml.jackson.annotation.JsonProperty +import org.hyperledger.cactus.plugin.ledger.connector.corda.server.model.CordappDeploymentConfig import org.hyperledger.cactus.plugin.ledger.connector.corda.server.model.JarFile import javax.validation.constraints.DecimalMax import javax.validation.constraints.DecimalMin @@ -14,10 +15,16 @@ import javax.validation.Valid /** * + * @param cordappDeploymentConfigs The list of deployment configurations pointing to the nodes where the provided cordapp jar files are to be deployed . * @param jarFiles */ data class DeployContractJarsV1Request( + @get:NotNull + @field:Valid + @get:Size(min=1,max=1024) + @field:JsonProperty("cordappDeploymentConfigs") val cordappDeploymentConfigs: kotlin.collections.List, + @get:NotNull @field:Valid @field:JsonProperty("jarFiles") val jarFiles: kotlin.collections.List diff --git a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/JarFile.kt b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/JarFile.kt index 061ffb6956..b244081b96 100644 --- a/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/JarFile.kt +++ b/packages/cactus-plugin-ledger-connector-corda/src/main-server/kotlin/gen/kotlin-spring/src/main/kotlin/org/hyperledger/cactus/plugin/ledger/connector/corda/server/model/JarFile.kt @@ -14,6 +14,7 @@ import javax.validation.Valid /** * * @param filename + * @param hasDbMigrations Indicates whether the cordapp jar in question contains any embedded migrations that Cactus can/should execute between copying the jar into the cordapp directory and starting the node back up. * @param contentBase64 */ data class JarFile( @@ -22,6 +23,9 @@ data class JarFile( @get:Size(min=1,max=255) @field:JsonProperty("filename") val filename: kotlin.String, + @get:NotNull + @field:JsonProperty("hasDbMigrations") val hasDbMigrations: kotlin.Boolean, + @get:NotNull @get:Size(min=1,max=1073741824) @field:JsonProperty("contentBase64") val contentBase64: kotlin.String diff --git a/packages/cactus-plugin-ledger-connector-corda/src/main/json/openapi.json b/packages/cactus-plugin-ledger-connector-corda/src/main/json/openapi.json index 5649b70086..f21abf0227 100644 --- a/packages/cactus-plugin-ledger-connector-corda/src/main/json/openapi.json +++ b/packages/cactus-plugin-ledger-connector-corda/src/main/json/openapi.json @@ -172,7 +172,8 @@ "type": "object", "required": [ "filename", - "contentBase64" + "contentBase64", + "hasDbMigrations" ], "additionalProperties": true, "properties": { @@ -182,6 +183,11 @@ "minLength": 1, "maxLength": 255 }, + "hasDbMigrations": { + "description": "Indicates whether the cordapp jar in question contains any embedded migrations that Cactus can/should execute between copying the jar into the cordapp directory and starting the node back up.", + "type": "boolean", + "nullable": false + }, "contentBase64": { "type": "string", "format": "base64", @@ -244,12 +250,130 @@ } } }, + "CordappDeploymentConfig": { + "type": "object", + "required": ["sshCredentials", "rpcCredentials", "cordappDir", "cordaNodeStartCmd", "cordaJarPath", "nodeBaseDirPath"], + "properties": { + "sshCredentials": { + "$ref": "#/components/schemas/CordaNodeSshCredentials" + }, + "rpcCredentials": { + "$ref": "#/components/schemas/CordaRpcCredentials" + }, + "cordaNodeStartCmd": { + "type": "string", + "description": "The shell command to execute in order to start back up a Corda node after having placed new jars in the cordapp directory of said node.", + "example": "./build/nodes/runNodes", + "minLength": 1, + "maxLength": 65535, + "nullable": false + }, + "cordappDir": { + "type": "string", + "description": "The absolute file system path where the Corda Node is expecting deployed Cordapp jar files to be placed.", + "minLength": 1, + "maxLength": 2048, + "nullable": false + }, + "cordaJarPath": { + "type": "string", + "description": "The absolute file system path where the corda.jar file of the node can be found. This is used to execute database schema migrations where applicable (H2 database in use in development environments).", + "minLength": 1, + "maxLength": 2048, + "nullable": false + }, + "nodeBaseDirPath": { + "type": "string", + "description": "The absolute file system path where the base directory of the Corda node can be found. This is used to pass in to corda.jar when being invoked for certain tasks such as executing database schema migrations for a deployed contract.", + "minLength": 1, + "maxLength": 2048, + "nullable": false + } + } + }, + "CordaRpcCredentials": { + "type": "object", + "required": ["hostname", "port", "username", "password"], + "properties": { + "hostname": { + "type": "string", + "minLength": 1, + "maxLength": 65535, + "nullable": false + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "nullable": false + }, + "username": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "nullable": false + }, + "password": { + "type": "string", + "minLength": 1, + "maxLength": 65535, + "nullable": false + } + } + }, + "CordaNodeSshCredentials": { + "type": "object", + "required": ["username", "password", "hostname", "port", "hostKeyEntry"], + "properties": { + "hostKeyEntry": { + "type": "string", + "example": "localhost ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPmhSBtMctNa4hsZt8QGlsYSE5/gMkjeand69Vj4ir13", + "minLength": 1, + "maxLength": 65535, + "nullable": false + }, + "username": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "nullable": false + }, + "password": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "nullable": false + }, + "hostname": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "nullable": false + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "nullable": false + } + } + }, "DeployContractJarsV1Request": { "type": "object", "required": [ - "jarFiles" + "jarFiles", + "cordappDeploymentConfigs" ], "properties": { + "cordappDeploymentConfigs": { + "type": "array", + "description": "The list of deployment configurations pointing to the nodes where the provided cordapp jar files are to be deployed .", + "minLength": 1, + "maxLength": 1024, + "items": { + "$ref": "#/components/schemas/CordappDeploymentConfig" + } + }, "jarFiles": { "type": "array", "nullable": false, diff --git a/packages/cactus-plugin-ledger-connector-corda/src/main/typescript/generated/openapi/typescript-axios/api.ts b/packages/cactus-plugin-ledger-connector-corda/src/main/typescript/generated/openapi/typescript-axios/api.ts index 97f2e4346e..b115c4c8a5 100644 --- a/packages/cactus-plugin-ledger-connector-corda/src/main/typescript/generated/openapi/typescript-axios/api.ts +++ b/packages/cactus-plugin-ledger-connector-corda/src/main/typescript/generated/openapi/typescript-axios/api.ts @@ -19,6 +19,74 @@ import globalAxios, { AxiosPromise, AxiosInstance } from 'axios'; // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError } from './base'; +/** + * + * @export + * @interface CordaNodeSshCredentials + */ +export interface CordaNodeSshCredentials { + /** + * + * @type {string} + * @memberof CordaNodeSshCredentials + */ + hostKeyEntry: string; + /** + * + * @type {string} + * @memberof CordaNodeSshCredentials + */ + username: string; + /** + * + * @type {string} + * @memberof CordaNodeSshCredentials + */ + password: string; + /** + * + * @type {string} + * @memberof CordaNodeSshCredentials + */ + hostname: string; + /** + * + * @type {number} + * @memberof CordaNodeSshCredentials + */ + port: number; +} +/** + * + * @export + * @interface CordaRpcCredentials + */ +export interface CordaRpcCredentials { + /** + * + * @type {string} + * @memberof CordaRpcCredentials + */ + hostname: string; + /** + * + * @type {number} + * @memberof CordaRpcCredentials + */ + port: number; + /** + * + * @type {string} + * @memberof CordaRpcCredentials + */ + username: string; + /** + * + * @type {string} + * @memberof CordaRpcCredentials + */ + password: string; +} /** * * @export @@ -68,6 +136,49 @@ export interface CordaX500Name { */ x500Principal: X500Principal; } +/** + * + * @export + * @interface CordappDeploymentConfig + */ +export interface CordappDeploymentConfig { + /** + * + * @type {CordaNodeSshCredentials} + * @memberof CordappDeploymentConfig + */ + sshCredentials: CordaNodeSshCredentials; + /** + * + * @type {CordaRpcCredentials} + * @memberof CordappDeploymentConfig + */ + rpcCredentials: CordaRpcCredentials; + /** + * The shell command to execute in order to start back up a Corda node after having placed new jars in the cordapp directory of said node. + * @type {string} + * @memberof CordappDeploymentConfig + */ + cordaNodeStartCmd: string; + /** + * The absolute file system path where the Corda Node is expecting deployed Cordapp jar files to be placed. + * @type {string} + * @memberof CordappDeploymentConfig + */ + cordappDir: string; + /** + * The absolute file system path where the corda.jar file of the node can be found. This is used to execute database schema migrations where applicable (H2 database in use in development environments). + * @type {string} + * @memberof CordappDeploymentConfig + */ + cordaJarPath: string; + /** + * The absolute file system path where the base directory of the Corda node can be found. This is used to pass in to corda.jar when being invoked for certain tasks such as executing database schema migrations for a deployed contract. + * @type {string} + * @memberof CordappDeploymentConfig + */ + nodeBaseDirPath: string; +} /** * A CordappInfo describes a single CorDapp currently installed on the node * @export @@ -161,6 +272,12 @@ export interface DeployContractJarsSuccessV1Response { * @interface DeployContractJarsV1Request */ export interface DeployContractJarsV1Request { + /** + * The list of deployment configurations pointing to the nodes where the provided cordapp jar files are to be deployed . + * @type {Array} + * @memberof DeployContractJarsV1Request + */ + cordappDeploymentConfigs: Array; /** * * @type {Array} @@ -274,6 +391,12 @@ export interface JarFile { * @memberof JarFile */ filename: string; + /** + * Indicates whether the cordapp jar in question contains any embedded migrations that Cactus can/should execute between copying the jar into the cordapp directory and starting the node back up. + * @type {boolean} + * @memberof JarFile + */ + hasDbMigrations: boolean; /** * * @type {string} diff --git a/packages/cactus-plugin-ledger-connector-corda/src/test/typescript/integration/deploy-cordapp-jars-to-nodes.test.ts b/packages/cactus-plugin-ledger-connector-corda/src/test/typescript/integration/deploy-cordapp-jars-to-nodes.test.ts new file mode 100644 index 0000000000..508c671b0a --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-corda/src/test/typescript/integration/deploy-cordapp-jars-to-nodes.test.ts @@ -0,0 +1,313 @@ +import test, { Test } from "tape-promise/tape"; +import { v4 as internalIpV4 } from "internal-ip"; + +import { CordaTestLedger } from "@hyperledger/cactus-test-tooling"; +import { LogLevelDesc } from "@hyperledger/cactus-common"; +import { + SampleCordappEnum, + CordaConnectorContainer, +} from "@hyperledger/cactus-test-tooling"; + +import { + CordappDeploymentConfig, + DefaultApi as CordaApi, + DeployContractJarsV1Request, + FlowInvocationType, + InvokeContractV1Request, + JvmTypeKind, +} from "../../../main/typescript/generated/openapi/typescript-axios/index"; + +const logLevel: LogLevelDesc = "TRACE"; + +test("Tests are passing on the JVM side", async (t: Test) => { + const ledger = new CordaTestLedger({ + imageName: "hyperledger/cactus-corda-4-6-all-in-one-obligation", + imageVersion: "2021-03-19-feat-686", + // imageName: "caio", + // imageVersion: "latest", + logLevel, + }); + t.ok(ledger, "CordaTestLedger instantaited OK"); + + test.onFinish(async () => { + await ledger.stop(); + await ledger.destroy(); + }); + const ledgerContainer = await ledger.start(); + t.ok(ledgerContainer, "CordaTestLedger container truthy post-start() OK"); + + const corDappsDirPartyA = await ledger.getCorDappsDirPartyA(); + const corDappsDirPartyB = await ledger.getCorDappsDirPartyB(); + t.comment(`corDappsDirPartyA=${corDappsDirPartyA}`); + t.comment(`corDappsDirPartyB=${corDappsDirPartyB}`); + + await ledger.logDebugPorts(); + const partyARpcPort = await ledger.getRpcAPublicPort(); + const partyBRpcPort = await ledger.getRpcBPublicPort(); + + const jarFiles = await ledger.pullCordappJars( + SampleCordappEnum.BASIC_CORDAPP, + ); + t.comment(`Fetched ${jarFiles.length} cordapp jars OK`); + + const internalIpOrUndefined = await internalIpV4(); + t.ok(internalIpOrUndefined, "Determined LAN IPv4 address successfully OK"); + const internalIp = internalIpOrUndefined as string; + t.comment(`Internal IP (based on default gateway): ${internalIp}`); + + // TODO: parse the gradle build files to extract the credentials? + const partyARpcUsername = "user1"; + const partyARpcPassword = "password"; + const partyBRpcUsername = partyARpcUsername; + const partyBRpcPassword = partyARpcPassword; + const springAppConfig = { + logging: { + level: { + root: "INFO", + "net.corda": "INFO", + "org.hyperledger.cactus": "DEBUG", + }, + }, + cactus: { + corda: { + node: { host: internalIp }, + rpc: { + port: partyARpcPort, + username: partyARpcUsername, + password: partyARpcPassword, + }, + }, + }, + }; + const springApplicationJson = JSON.stringify(springAppConfig); + const envVarSpringAppJson = `SPRING_APPLICATION_JSON=${springApplicationJson}`; + t.comment(envVarSpringAppJson); + + const connector = new CordaConnectorContainer({ + logLevel, + imageName: "hyperledger/cactus-connector-corda-server", + imageVersion: "2021-03-16-feat-621", + envVars: [envVarSpringAppJson], + }); + t.ok(CordaConnectorContainer, "CordaConnectorContainer instantiated OK"); + + test.onFinish(async () => { + try { + const logBuffer = ((await connectorContainer.logs({ + follow: false, + stdout: true, + stderr: true, + })) as unknown) as Buffer; + const logs = logBuffer.toString("utf-8"); + t.comment(`[CordaConnectorServer] ${logs}`); + } finally { + try { + await connector.stop(); + } finally { + await connector.destroy(); + } + } + }); + + // FIXME health checks with JMX appear to be working but this wait still seems + // to be necessary in order to make it work on the CI server (locally it + // works just fine without this as well...) + // await new Promise((r) => setTimeout(r, 120000)); + const connectorContainer = await connector.start(); + t.ok(connectorContainer, "CordaConnectorContainer started OK"); + + await connector.logDebugPorts(); + const apiUrl = await connector.getApiLocalhostUrl(); + const apiClient = new CordaApi({ basePath: apiUrl }); + + const flowsRes1 = await apiClient.listFlowsV1(); + t.ok(flowsRes1.status === 200, "flowsRes1.status === 200 OK"); + t.ok(flowsRes1.data, "flowsRes1.data truthy OK"); + t.ok(flowsRes1.data.flowNames, "flowsRes1.data.flowNames truthy OK"); + t.comment(`apiClient.listFlowsV1() => ${JSON.stringify(flowsRes1.data)}`); + const flowNamesPreDeploy = flowsRes1.data.flowNames; + + const sshConfig = await ledger.getSshConfig(); + const hostKeyEntry = "not-used-right-now-so-this-does-not-matter... ;-("; + + const cdcA: CordappDeploymentConfig = { + cordappDir: corDappsDirPartyA, + cordaNodeStartCmd: "supervisorctl start corda-a", + cordaJarPath: + "/samples-kotlin/Advanced/obligation-cordapp/build/nodes/ParticipantA/corda.jar", + nodeBaseDirPath: + "/samples-kotlin/Advanced/obligation-cordapp/build/nodes/ParticipantA/", + rpcCredentials: { + hostname: internalIp, + port: partyARpcPort, + username: partyARpcUsername, + password: partyARpcPassword, + }, + sshCredentials: { + hostKeyEntry, + hostname: internalIp, + password: "root", + port: sshConfig.port as number, + username: sshConfig.username as string, + }, + }; + + const cdcB: CordappDeploymentConfig = { + cordappDir: corDappsDirPartyB, + cordaNodeStartCmd: "supervisorctl start corda-b", + cordaJarPath: + "/samples-kotlin/Advanced/obligation-cordapp/build/nodes/ParticipantB/corda.jar", + nodeBaseDirPath: + "/samples-kotlin/Advanced/obligation-cordapp/build/nodes/ParticipantB/", + rpcCredentials: { + hostname: internalIp, + port: partyBRpcPort, + username: partyBRpcUsername, + password: partyBRpcPassword, + }, + sshCredentials: { + hostKeyEntry, + hostname: internalIp, + password: "root", + port: sshConfig.port as number, + username: sshConfig.username as string, + }, + }; + + const cordappDeploymentConfigs: CordappDeploymentConfig[] = [cdcA, cdcB]; + const depReq: DeployContractJarsV1Request = { + jarFiles, + cordappDeploymentConfigs, + }; + const depRes = await apiClient.deployContractJarsV1(depReq); + t.ok(depRes, "Jar deployment response truthy OK"); + t.equal(depRes.status, 200, "Jar deployment status code === 200 OK"); + t.ok(depRes.data, "Jar deployment response body truthy OK"); + t.ok(depRes.data.deployedJarFiles, "Jar deployment body deployedJarFiles OK"); + t.equal( + depRes.data.deployedJarFiles.length, + jarFiles.length, + "Deployed jar file count equals count in request OK", + ); + + const flowsRes2 = await apiClient.listFlowsV1(); + t.ok(flowsRes2.status === 200, "flowsRes2.status === 200 OK"); + t.comment(`apiClient.listFlowsV1() => ${JSON.stringify(flowsRes2.data)}`); + t.ok(flowsRes2.data, "flowsRes2.data truthy OK"); + t.ok(flowsRes2.data.flowNames, "flowsRes2.data.flowNames truthy OK"); + const flowNamesPostDeploy = flowsRes2.data.flowNames; + t.notDeepLooseEqual( + flowNamesPostDeploy, + flowNamesPreDeploy, + "New flows detected post Cordapp Jar deployment OK", + ); + + // let's see if this makes a difference and if yes, then we know that the issue + // is a race condition for sure + // await new Promise((r) => setTimeout(r, 120000)); + t.comment("Fetching network map for Corda network..."); + const networkMapRes = await apiClient.networkMapV1(); + t.ok(networkMapRes, "networkMapRes truthy OK"); + t.ok(networkMapRes.status, "networkMapRes.status truthy OK"); + t.ok(networkMapRes.data, "networkMapRes.data truthy OK"); + t.true(Array.isArray(networkMapRes.data), "networkMapRes.data isArray OK"); + t.true(networkMapRes.data.length > 0, "networkMapRes.data not empty OK"); + + // const partyA = networkMapRes.data.find((it) => + // it.legalIdentities.some((it2) => it2.name.organisation === "ParticipantA"), + // ); + // const partyAPublicKey = partyA?.legalIdentities[0].owningKey; + + const partyB = networkMapRes.data.find((it) => + it.legalIdentities.some((it2) => it2.name.organisation === "ParticipantB"), + ); + const partyBPublicKey = partyB?.legalIdentities[0].owningKey; + + const req: InvokeContractV1Request = ({ + timeoutMs: 60000, + flowFullClassName: "net.corda.samples.example.flows.ExampleFlow$Initiator", + flowInvocationType: FlowInvocationType.FLOWDYNAMIC, + params: [ + { + jvmTypeKind: JvmTypeKind.PRIMITIVE, + jvmType: { + fqClassName: "java.lang.Integer", + }, + primitiveValue: 42, + }, + { + jvmTypeKind: JvmTypeKind.REFERENCE, + jvmType: { + fqClassName: "net.corda.core.identity.Party", + }, + jvmCtorArgs: [ + { + jvmTypeKind: JvmTypeKind.REFERENCE, + jvmType: { + fqClassName: "net.corda.core.identity.CordaX500Name", + }, + jvmCtorArgs: [ + { + jvmTypeKind: JvmTypeKind.PRIMITIVE, + jvmType: { + fqClassName: "java.lang.String", + }, + primitiveValue: "ParticipantB", + }, + { + jvmTypeKind: JvmTypeKind.PRIMITIVE, + jvmType: { + fqClassName: "java.lang.String", + }, + primitiveValue: "New York", + }, + { + jvmTypeKind: JvmTypeKind.PRIMITIVE, + jvmType: { + fqClassName: "java.lang.String", + }, + primitiveValue: "US", + }, + ], + }, + { + jvmTypeKind: JvmTypeKind.REFERENCE, + jvmType: { + fqClassName: + "org.hyperledger.cactus.plugin.ledger.connector.corda.server.impl.PublicKeyImpl", + }, + jvmCtorArgs: [ + { + jvmTypeKind: JvmTypeKind.PRIMITIVE, + jvmType: { + fqClassName: "java.lang.String", + }, + primitiveValue: partyBPublicKey?.algorithm, + }, + { + jvmTypeKind: JvmTypeKind.PRIMITIVE, + jvmType: { + fqClassName: "java.lang.String", + }, + primitiveValue: partyBPublicKey?.format, + }, + { + jvmTypeKind: JvmTypeKind.PRIMITIVE, + jvmType: { + fqClassName: "java.lang.String", + }, + primitiveValue: partyBPublicKey?.encoded, + }, + ], + }, + ], + }, + ], + } as unknown) as InvokeContractV1Request; + + const res = await apiClient.invokeContractV1(req); + t.ok(res, "InvokeContractV1Request truthy OK"); + t.equal(res.status, 200, "InvokeContractV1Request status code === 200 OK"); + + t.end(); +}); diff --git a/packages/cactus-plugin-ledger-connector-corda/src/test/typescript/integration/jvm-kotlin-spring-server.test.ts b/packages/cactus-plugin-ledger-connector-corda/src/test/typescript/integration/jvm-kotlin-spring-server.test.ts index 235f91b558..26b58ae25d 100644 --- a/packages/cactus-plugin-ledger-connector-corda/src/test/typescript/integration/jvm-kotlin-spring-server.test.ts +++ b/packages/cactus-plugin-ledger-connector-corda/src/test/typescript/integration/jvm-kotlin-spring-server.test.ts @@ -12,7 +12,9 @@ import { } from "@hyperledger/cactus-test-tooling"; import { + CordappDeploymentConfig, DefaultApi as CordaApi, + DeployContractJarsV1Request, FlowInvocationType, InvokeContractV1Request, JvmTypeKind, @@ -56,7 +58,7 @@ test(testCase, async (t: Test) => { logging: { level: { root: "INFO", - "org.hyperledger.cactus.plugin.ledger.connector.corda.server": "DEBUG", + "org.hyperledger.cactus": "DEBUG", }, }, cactus: { @@ -100,7 +102,7 @@ test(testCase, async (t: Test) => { // FIXME health checks with JMX appear to be working but this wait still seems // to be necessary in order to make it work on the CI server (locally it // works just fine without this as well...) - await new Promise((r) => setTimeout(r, 120000)); + // await new Promise((r) => setTimeout(r, 120000)); const connectorContainer = await connector.start(); t.ok(connectorContainer, "CordaConnectorContainer started OK"); @@ -129,7 +131,12 @@ test(testCase, async (t: Test) => { t.comment(`apiClient.diagnoseNodeV1() => ${JSON.stringify(diagRes.data)}`); - const depRes = await apiClient.deployContractJarsV1({ jarFiles }); + const cordappDeploymentConfigs: CordappDeploymentConfig[] = []; + const depReq: DeployContractJarsV1Request = { + jarFiles, + cordappDeploymentConfigs, + }; + const depRes = await apiClient.deployContractJarsV1(depReq); t.ok(depRes, "Jar deployment response truthy OK"); t.equal(depRes.status, 200, "Jar deployment status code === 200 OK"); t.ok(depRes.data, "Jar deployment response body truthy OK"); @@ -419,7 +426,7 @@ test(testCase, async (t: Test) => { // const fiveMinMs = 5 * 60 * 1000; // const timeoutError = new Error("JVM Gradle tests timed out"); // const timer = setTimeout(() => reject(timeoutError), fiveMinMs); - // gradleProcess.once("close", (code) => { + // gradleProcess.once("close", (code: number) => { // clearInterval(timer); // t.comment(`[Gradle] child process exited with code ${code}`); // resolve(code); diff --git a/packages/cactus-plugin-ledger-connector-corda/src/test/typescript/integration/tap-parallel-not-ok b/packages/cactus-plugin-ledger-connector-corda/src/test/typescript/integration/tap-parallel-not-ok new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/cactus-test-tooling/src/main/typescript/corda/corda-test-ledger.ts b/packages/cactus-test-tooling/src/main/typescript/corda/corda-test-ledger.ts index 1600b5069d..cdcfabb2e3 100644 --- a/packages/cactus-test-tooling/src/main/typescript/corda/corda-test-ledger.ts +++ b/packages/cactus-test-tooling/src/main/typescript/corda/corda-test-ledger.ts @@ -15,11 +15,12 @@ import { Checks, Bools, } from "@hyperledger/cactus-common"; -import { Base64File } from "../common/base64-file"; + import { SampleCordappEnum, SAMPLE_CORDAPP_ROOT_DIRS, } from "./sample-cordapp-enum"; +import { ICordappJarFile } from "./cordapp-jar-file"; /* * Contains options for Corda container @@ -247,14 +248,14 @@ export class CordaTestLedger implements ITestLedger { public async pullCordappJars( sampleCordapp: SampleCordappEnum, - ): Promise { + ): Promise { const fnTag = `${this.className}.pullCordappJars()`; Checks.truthy(sampleCordapp, `${fnTag}:sampleCordapp`); await this.buildCordapp(sampleCordapp); const container = this.getContainer(); const cordappRootDir = SAMPLE_CORDAPP_ROOT_DIRS[sampleCordapp]; - const jars: Base64File[] = []; + const jars: ICordappJarFile[] = []; { const jarRelativePath = "contracts/build/libs/contracts-1.0.jar"; @@ -264,6 +265,7 @@ export class CordaTestLedger implements ITestLedger { jars.push({ contentBase64: jar.toString("base64"), filename: `${sampleCordapp}_contracts-1.0.jar`, + hasDbMigrations: false, }); this.log.debug(`Pulled jar (%o bytes) %o`, jarPath, jar.length); } @@ -276,6 +278,7 @@ export class CordaTestLedger implements ITestLedger { jars.push({ contentBase64: jar.toString("base64"), filename: `${sampleCordapp}_workflows-1.0.jar`, + hasDbMigrations: true, }); this.log.debug(`Pulled jar (%o bytes) %o`, jarPath, jar.length); } @@ -298,7 +301,7 @@ export class CordaTestLedger implements ITestLedger { const cwd = SAMPLE_CORDAPP_ROOT_DIRS[sampleCordapp]; try { - const cmd = `./gradlew build`; + const cmd = `./gradlew build -x test`; this.log.debug(`${fnTag}:CMD=%o, CWD=%o`, cmd, cwd); const response = await ssh.execCommand(cmd, { cwd }); const { code, signal, stderr, stdout } = response; diff --git a/packages/cactus-test-tooling/src/main/typescript/corda/cordapp-jar-file.ts b/packages/cactus-test-tooling/src/main/typescript/corda/cordapp-jar-file.ts new file mode 100644 index 0000000000..df109c5b7c --- /dev/null +++ b/packages/cactus-test-tooling/src/main/typescript/corda/cordapp-jar-file.ts @@ -0,0 +1,5 @@ +import { Base64File } from "../common/base64-file"; + +export interface ICordappJarFile extends Base64File { + hasDbMigrations: boolean; +} diff --git a/packages/cactus-test-tooling/src/main/typescript/public-api.ts b/packages/cactus-test-tooling/src/main/typescript/public-api.ts index 75b77ee5a2..83209874df 100755 --- a/packages/cactus-test-tooling/src/main/typescript/public-api.ts +++ b/packages/cactus-test-tooling/src/main/typescript/public-api.ts @@ -22,6 +22,8 @@ export { JOI_SCHEMA as CORDA_TEST_LEDGER_OPTIONS_JOI_SCHEMA, } from "./corda/corda-test-ledger"; +export { ICordappJarFile } from "./corda/cordapp-jar-file"; + export * from "./quorum/i-quorum-genesis-options"; export { Containers,