From c64ab4b7a5ba3fc239a0e7a040cde227ab3a05fd Mon Sep 17 00:00:00 2001 From: Chris Rankin Date: Mon, 13 Mar 2017 13:31:38 +0000 Subject: [PATCH] Merged in demobench (pull request #23) Saving & loading of Cordapps with DemoBench profiles. * CORPRIV-664: Implement saving/loading of Cordapps with profiles. * CORPRIV-664: Refactor saving/loading plugins. * CORPRIV-664: Add initial unit tests for model. * CORPRIV-664: Add simple unit tests for NodeController. * CORPRIV-664: Unit test enhancements, e.g. configure JUL properly. * CORPRIV-664: Use Suite instead of abstract test class. * CORPRIV-664: Allow Cordapps to be loaded when each Node is configured. * CORPRIV-664: Document which checked Java exceptions are thrown. * Write JavaPackager output into build/javapackage directory. * CORPRIV-664: Document more checked Java exceptions. * Refactor Web and Explorer classes into their own packages. * Declare WebServer and Explorer constructors as "internal". * Update packaging scripts: tell user where the installer is! * CORPRIV-659: Set "system menu bar" property for MacOSX. * CORPRIV-661: Use "*.profile" for profile files. * Remove unnecessary elements, as they are defaults. * Fix build breakage when on Windows. * Tweaks for EXE packaging script. * Change function to extension function. * Code review fixes. Approved-by: Clinton Alexander --- tools/demobench/build.gradle | 9 +- .../demobench/package-demobench-dmg.sh | 5 +- .../demobench/package-demobench-exe.bat | 7 +- tools/demobench/package-demobench-rpm.sh | 13 ++ .../net/corda/demobench/explorer/Explorer.kt | 74 +++++++ .../{model => explorer}/ExplorerController.kt | 3 +- .../net/corda/demobench/model/Explorer.kt | 64 ------ .../net/corda/demobench/model/HasPlugins.kt | 7 + .../corda/demobench/model/InstallFactory.kt | 72 +++++++ .../net/corda/demobench/model/JVMConfig.kt | 8 +- .../corda/demobench/model/NetworkMapConfig.kt | 6 +- .../net/corda/demobench/model/NodeConfig.kt | 33 +-- .../corda/demobench/model/NodeController.kt | 74 +++++-- .../demobench/model/ServiceController.kt | 27 ++- .../kotlin/net/corda/demobench/model/User.kt | 26 +++ .../net/corda/demobench/model/WebServer.kt | 57 ----- .../demobench/plugin/PluginController.kt | 72 +++++++ .../demobench/profile/ProfileController.kt | 135 ++++++------ .../kotlin/net/corda/demobench/pty/R3Pty.kt | 2 + .../kotlin/net/corda/demobench/rpc/NodeRPC.kt | 3 +- .../corda/demobench/views/DemoBenchView.kt | 12 +- .../net/corda/demobench/views/NodeTabView.kt | 93 +++++--- .../corda/demobench/views/NodeTerminalView.kt | 3 + .../demobench/{model => web}/DBViewer.kt | 11 +- .../net/corda/demobench/web/WebServer.kt | 66 ++++++ .../{model => web}/WebServerController.kt | 5 +- .../org/h2/server/web/LocalWebServer.kt | 2 + .../resources/net/corda/demobench/style.css | 20 ++ .../corda/demobench/views/DemoBenchView.fxml | 26 ++- .../demobench/views/NodeTerminalView.fxml | 38 ++-- .../net/corda/demobench/LoggingTestSuite.kt | 33 +++ .../corda/demobench/model/JVMConfigTest.kt | 47 ++++ .../demobench/model/NetworkMapConfigTest.kt | 19 ++ .../corda/demobench/model/NodeConfigTest.kt | 201 ++++++++++++++++++ .../demobench/model/NodeControllerTest.kt | 169 +++++++++++++++ .../demobench/model/ServiceControllerTest.kt | 42 ++++ .../net/corda/demobench/model/UserTest.kt | 53 +++++ .../test/resources/duplicate-services.conf | 3 + .../src/test/resources/empty-services.conf | 0 .../src/test/resources/logback-test.xml | 14 ++ .../src/test/resources/notary-services.conf | 2 + 41 files changed, 1236 insertions(+), 320 deletions(-) rename package-demobench-dmg.sh => tools/demobench/package-demobench-dmg.sh (52%) rename package-demobench-exe.bat => tools/demobench/package-demobench-exe.bat (61%) create mode 100755 tools/demobench/package-demobench-rpm.sh create mode 100644 tools/demobench/src/main/kotlin/net/corda/demobench/explorer/Explorer.kt rename tools/demobench/src/main/kotlin/net/corda/demobench/{model => explorer}/ExplorerController.kt (83%) delete mode 100644 tools/demobench/src/main/kotlin/net/corda/demobench/model/Explorer.kt create mode 100644 tools/demobench/src/main/kotlin/net/corda/demobench/model/HasPlugins.kt create mode 100644 tools/demobench/src/main/kotlin/net/corda/demobench/model/InstallFactory.kt create mode 100644 tools/demobench/src/main/kotlin/net/corda/demobench/model/User.kt delete mode 100644 tools/demobench/src/main/kotlin/net/corda/demobench/model/WebServer.kt create mode 100644 tools/demobench/src/main/kotlin/net/corda/demobench/plugin/PluginController.kt rename tools/demobench/src/main/kotlin/net/corda/demobench/{model => web}/DBViewer.kt (88%) create mode 100644 tools/demobench/src/main/kotlin/net/corda/demobench/web/WebServer.kt rename tools/demobench/src/main/kotlin/net/corda/demobench/{model => web}/WebServerController.kt (83%) create mode 100644 tools/demobench/src/test/kotlin/net/corda/demobench/LoggingTestSuite.kt create mode 100644 tools/demobench/src/test/kotlin/net/corda/demobench/model/JVMConfigTest.kt create mode 100644 tools/demobench/src/test/kotlin/net/corda/demobench/model/NetworkMapConfigTest.kt create mode 100644 tools/demobench/src/test/kotlin/net/corda/demobench/model/NodeConfigTest.kt create mode 100644 tools/demobench/src/test/kotlin/net/corda/demobench/model/NodeControllerTest.kt create mode 100644 tools/demobench/src/test/kotlin/net/corda/demobench/model/ServiceControllerTest.kt create mode 100644 tools/demobench/src/test/kotlin/net/corda/demobench/model/UserTest.kt create mode 100644 tools/demobench/src/test/resources/duplicate-services.conf create mode 100644 tools/demobench/src/test/resources/empty-services.conf create mode 100644 tools/demobench/src/test/resources/logback-test.xml create mode 100644 tools/demobench/src/test/resources/notary-services.conf diff --git a/tools/demobench/build.gradle b/tools/demobench/build.gradle index 8977461c1b..8deeb45a46 100644 --- a/tools/demobench/build.gradle +++ b/tools/demobench/build.gradle @@ -10,6 +10,7 @@ buildscript { ext.java_home = System.properties.'java.home' ext.pkg_source = "$buildDir/packagesrc" + ext.pkg_outDir = "$buildDir/javapackage" ext.dist_source = "$pkg_source/demobench-$version" ext.pkg_version = "$version".indexOf('-') >= 0 ? "$version".substring(0, "$version".indexOf('-')) : version @@ -99,6 +100,10 @@ jar { } } +test { + systemProperty 'java.util.logging.config.class', 'net.corda.demobench.config.LoggingConfig' +} + distributions { main() { contents { @@ -134,7 +139,7 @@ distributions { task javapackage(dependsOn: 'distZip') { doLast { - delete([pkg_source, "$buildDir/exedir"]) + delete([pkg_source, pkg_outDir]) copy { from(zipTree(distZip.outputs.files.singleFile)) @@ -175,7 +180,7 @@ task javapackage(dependsOn: 'distZip') { classpath: "$pkg_source:$java_home/../lib/ant-javafx.jar" ) - ant.deploy(nativeBundles: packageType, outdir: "$buildDir/exedir", outfile: 'DemoBench', verbose: 'true') { + ant.deploy(nativeBundles: packageType, outdir: pkg_outDir, outfile: 'DemoBench', verbose: 'true') { application(name: 'DemoBench', version: pkg_version, mainClass: mainClassName) info(title: 'DemoBench', vendor: 'R3', description: 'A sales and educational tool for demonstrating Corda.') resources { diff --git a/package-demobench-dmg.sh b/tools/demobench/package-demobench-dmg.sh similarity index 52% rename from package-demobench-dmg.sh rename to tools/demobench/package-demobench-dmg.sh index 18f4c6c1ba..c72c6d3abe 100755 --- a/package-demobench-dmg.sh +++ b/tools/demobench/package-demobench-dmg.sh @@ -7,4 +7,7 @@ if [ -z "$JAVA_HOME" -o ! -x $JAVA_HOME/bin/java ]; then exit 1 fi -exec $DIRNAME/gradlew -PpackageType=dmg javapackage $* +$DIRNAME/../../gradlew -PpackageType=dmg javapackage $* +echo +echo "Wrote installer to '$(find build/javapackage/bundles -type f)'" +echo diff --git a/package-demobench-exe.bat b/tools/demobench/package-demobench-exe.bat similarity index 61% rename from package-demobench-exe.bat rename to tools/demobench/package-demobench-exe.bat index 682fd3de1f..bb6c21191d 100644 --- a/package-demobench-exe.bat +++ b/tools/demobench/package-demobench-exe.bat @@ -8,10 +8,13 @@ if not defined JAVA_HOME goto NoJavaHome set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. -call %DIRNAME%\gradlew -PpackageType=exe javapackage +call %DIRNAME%\..\..\gradlew -PpackageType=exe javapackage +@echo +@echo "Wrote installer to %DIRNAME%\build\javapackage\bundles\" +@echo goto end :NoJavaHome -echo "Please set JAVA_HOME correctly" +@echo "Please set JAVA_HOME correctly" :end diff --git a/tools/demobench/package-demobench-rpm.sh b/tools/demobench/package-demobench-rpm.sh new file mode 100755 index 0000000000..3d14661206 --- /dev/null +++ b/tools/demobench/package-demobench-rpm.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +DIRNAME=$(dirname $0) + +if [ -z "$JAVA_HOME" -o ! -x $JAVA_HOME/bin/java ]; then + echo "Please set JAVA_HOME correctly" + exit 1 +fi + +$DIRNAME/../../gradlew -PpackageType=rpm javapackage $* +echo +echo "Wrote installer to '$(find $DIRNAME/build/javapackage/bundles -type f)'" +echo diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/explorer/Explorer.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/explorer/Explorer.kt new file mode 100644 index 0000000000..828a1476c7 --- /dev/null +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/explorer/Explorer.kt @@ -0,0 +1,74 @@ +package net.corda.demobench.explorer + +import java.io.IOException +import java.util.concurrent.Executors +import net.corda.demobench.loggerFor +import net.corda.demobench.model.NodeConfig +import net.corda.demobench.model.forceDirectory + +class Explorer internal constructor(private val explorerController: ExplorerController) : AutoCloseable { + private companion object { + val log = loggerFor() + } + + private val executor = Executors.newSingleThreadExecutor() + private var process: Process? = null + + @Throws(IOException::class) + fun open(config: NodeConfig, onExit: (NodeConfig) -> Unit) { + val explorerDir = config.explorerDir.toFile() + + if (!explorerDir.forceDirectory()) { + log.warn("Failed to create working directory '{}'", explorerDir.absolutePath) + onExit(config) + return + } + + try { + val p = explorerController.process( + "--host=localhost", + "--port=${config.artemisPort}", + "--username=${config.users[0].user}", + "--password=${config.users[0].password}", + "--certificatesDir=${config.ssl.certificatesDirectory}", + "--keyStorePassword=${config.ssl.keyStorePassword}", + "--trustStorePassword=${config.ssl.trustStorePassword}") + .directory(explorerDir) + .start() + process = p + + log.info("Launched Node Explorer for '{}'", config.legalName) + + // Close these streams because no-one is using them. + safeClose(p.outputStream) + safeClose(p.inputStream) + safeClose(p.errorStream) + + executor.submit { + val exitValue = p.waitFor() + process = null + + log.info("Node Explorer for '{}' has exited (value={})", config.legalName, exitValue) + onExit(config) + } + } catch (e: IOException) { + log.error("Failed to launch Node Explorer for '{}': {}", config.legalName, e.message) + onExit(config) + throw e + } + } + + override fun close() { + executor.shutdown() + process?.destroy() + } + + private fun safeClose(c: AutoCloseable) { + try { + c.close() + } catch (e: Exception) { + log.error("Failed to close stream: '{}'", e.message) + } + } + +} diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/ExplorerController.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/explorer/ExplorerController.kt similarity index 83% rename from tools/demobench/src/main/kotlin/net/corda/demobench/model/ExplorerController.kt rename to tools/demobench/src/main/kotlin/net/corda/demobench/explorer/ExplorerController.kt index b0325be4b8..32813e627b 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/model/ExplorerController.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/explorer/ExplorerController.kt @@ -1,5 +1,6 @@ -package net.corda.demobench.model +package net.corda.demobench.explorer +import net.corda.demobench.model.JVMConfig import tornadofx.Controller class ExplorerController : Controller() { diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/Explorer.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/Explorer.kt deleted file mode 100644 index 8ba46f4e06..0000000000 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/model/Explorer.kt +++ /dev/null @@ -1,64 +0,0 @@ -package net.corda.demobench.model - -import net.corda.demobench.loggerFor -import java.util.concurrent.Executors - -class Explorer(val explorerController: ExplorerController) : AutoCloseable { - private companion object { - val log = loggerFor() - } - - private val executor = Executors.newSingleThreadExecutor() - private var process: Process? = null - - fun open(config: NodeConfig, onExit: (NodeConfig) -> Unit) { - val explorerDir = config.explorerDir.toFile() - - if (!explorerDir.isDirectory && !explorerDir.mkdirs()) { - log.warn("Failed to create working directory '{}'", explorerDir.absolutePath) - onExit(config) - return - } - - val p = explorerController.process( - "--host=localhost", - "--port=${config.artemisPort}", - "--username=${config.users[0]["user"]}", - "--password=${config.users[0]["password"]}", - "--certificatesDir=${config.ssl.certificatesDirectory}", - "--keyStorePassword=${config.ssl.keyStorePassword}", - "--trustStorePassword=${config.ssl.trustStorePassword}") - .directory(explorerDir) - .start() - process = p - - log.info("Launched Node Explorer for '{}'", config.legalName) - - // Close these streams because no-one is using them. - safeClose(p.outputStream) - safeClose(p.inputStream) - safeClose(p.errorStream) - - executor.submit { - val exitValue = p.waitFor() - process = null - - log.info("Node Explorer for '{}' has exited (value={})", config.legalName, exitValue) - onExit(config) - } - } - - override fun close() { - executor.shutdown() - process?.destroy() - } - - private fun safeClose(c: AutoCloseable) { - try { - c.close() - } catch (e: Exception) { - log.error("Failed to close stream: '{}'", e.message) - } - } - -} diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/HasPlugins.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/HasPlugins.kt new file mode 100644 index 0000000000..52a388b7a7 --- /dev/null +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/HasPlugins.kt @@ -0,0 +1,7 @@ +package net.corda.demobench.model + +import java.nio.file.Path + +interface HasPlugins { + val pluginDir: Path +} diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/InstallFactory.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/InstallFactory.kt new file mode 100644 index 0000000000..bebffa6aaf --- /dev/null +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/InstallFactory.kt @@ -0,0 +1,72 @@ +package net.corda.demobench.model + +import com.google.common.net.HostAndPort +import com.typesafe.config.Config +import java.io.IOException +import java.nio.file.* +import tornadofx.Controller + +class InstallFactory : Controller() { + + private val nodeController by inject() + private val serviceController by inject() + + @Throws(IOException::class) + fun toInstallConfig(config: Config, baseDir: Path): InstallConfig { + val artemisPort = config.parsePort("artemisAddress") + val webPort = config.parsePort("webAddress") + val h2Port = config.getInt("h2port") + val extraServices = config.parseExtraServices("extraAdvertisedServiceIds") + val tempDir = Files.createTempDirectory(baseDir, ".node") + + val nodeConfig = NodeConfig( + tempDir, + config.getString("myLegalName"), + artemisPort, + config.getString("nearestCity"), + webPort, + h2Port, + extraServices, + config.getObjectList("rpcUsers").map { toUser(it.unwrapped()) }.toList() + ) + + if (config.hasPath("networkMapService")) { + val nmap = config.getConfig("networkMapService") + nodeConfig.networkMap = NetworkMapConfig(nmap.getString("legalName"), nmap.parsePort("address")) + } else { + log.info("Node '${nodeConfig.legalName}' is the network map") + } + + return InstallConfig(tempDir, nodeConfig) + } + + private fun Config.parsePort(path: String): Int { + val address = this.getString(path) + val port = HostAndPort.fromString(address).port + require(nodeController.isPortValid(port), { "Invalid port $port from '$path'." }) + return port + } + + private fun Config.parseExtraServices(path: String): List { + val services = serviceController.services.toSortedSet() + return this.getString(path).split(",") + .filter { !it.isNullOrEmpty() } + .map { svc -> + require(svc in services, { "Unknown service '$svc'." } ) + svc + }.toList() + } + +} + +/** + * Wraps the configuration information for a Node + * which isn't ready to be instantiated yet. + */ +class InstallConfig internal constructor(val baseDir: Path, private val config: NodeConfig) : HasPlugins { + val key = config.key + override val pluginDir: Path = baseDir.resolve("plugins") + + fun deleteBaseDir(): Boolean = baseDir.toFile().deleteRecursively() + fun installTo(installDir: Path) = config.moveTo(installDir) +} diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/JVMConfig.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/JVMConfig.kt index 4b2eb284db..023f5cd1e7 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/model/JVMConfig.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/JVMConfig.kt @@ -7,6 +7,7 @@ import tornadofx.Controller class JVMConfig : Controller() { val userHome: Path = Paths.get(System.getProperty("user.home")).toAbsolutePath() + val dataHome: Path = userHome.resolve("demobench") val javaPath: Path = Paths.get(System.getProperty("java.home"), "bin", "java") val applicationDir: Path = Paths.get(System.getProperty("user.dir")).toAbsolutePath() @@ -14,13 +15,12 @@ class JVMConfig : Controller() { log.info("Java executable: $javaPath") } - fun commandFor(jarPath: Path, vararg args: String): Array { - return arrayOf(javaPath.toString(), "-jar", jarPath.toString(), *args) + fun commandFor(jarPath: Path, vararg args: String): List { + return listOf(javaPath.toString(), "-jar", jarPath.toString(), *args) } fun processFor(jarPath: Path, vararg args: String): ProcessBuilder { - return ProcessBuilder(commandFor(jarPath, *args).toList()) + return ProcessBuilder(commandFor(jarPath, *args)) } } - diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/NetworkMapConfig.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NetworkMapConfig.kt index 326b9fd60c..d4d4dc268c 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/model/NetworkMapConfig.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NetworkMapConfig.kt @@ -2,11 +2,11 @@ package net.corda.demobench.model open class NetworkMapConfig(val legalName: String, val artemisPort: Int) { - private var keyValue = toKey(legalName) - val key: String get() = keyValue + val key: String = legalName.toKey() } private val WHITESPACE = "\\s++".toRegex() -fun toKey(value: String) = value.replace(WHITESPACE, "").toLowerCase() +fun String.stripWhitespace() = this.replace(WHITESPACE, "") +fun String.toKey() = this.stripWhitespace().toLowerCase() diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeConfig.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeConfig.kt index 5a3b5b8654..81b3640d80 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeConfig.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeConfig.kt @@ -2,7 +2,10 @@ package net.corda.demobench.model import com.typesafe.config.* import java.lang.String.join +import java.io.File import java.nio.file.Path +import java.nio.file.Files +import java.nio.file.StandardCopyOption import net.corda.node.services.config.SSLConfiguration class NodeConfig( @@ -13,24 +16,16 @@ class NodeConfig( val webPort: Int, val h2Port: Int, val extraServices: List, - val users: List> = listOf(defaultUser), + val users: List = listOf(user("guest")), var networkMap: NetworkMapConfig? = null -) : NetworkMapConfig(legalName, artemisPort) { +) : NetworkMapConfig(legalName, artemisPort), HasPlugins { companion object { val renderOptions: ConfigRenderOptions = ConfigRenderOptions.defaults().setOriginComments(false) - - val defaultUser: Map = mapOf( - "user" to "guest", - "password" to "letmein", - "permissions" to listOf( - "StartFlow.net.corda.flows.CashFlow", - "StartFlow.net.corda.flows.IssuerFlow\$IssuanceRequester" - ) - ) } val nodeDir: Path = baseDir.resolve(key) + override val pluginDir: Path = nodeDir.resolve("plugins") val explorerDir: Path = baseDir.resolve("$key-explorer") val ssl: SSLConfiguration = object : SSLConfiguration { @@ -61,7 +56,7 @@ class NodeConfig( .withValue("legalName", valueFor(n.legalName)) } )) .withValue("webAddress", addressValueFor(webPort)) - .withValue("rpcUsers", valueFor(users)) + .withValue("rpcUsers", valueFor(users.map(User::toMap).toList())) .withValue("h2port", valueFor(h2Port)) .withValue("useTestClock", valueFor(true)) @@ -70,13 +65,25 @@ class NodeConfig( fun moveTo(baseDir: Path) = NodeConfig( baseDir, legalName, artemisPort, nearestCity, webPort, h2Port, extraServices, users, networkMap ) + + fun install(plugins: Collection) { + if (plugins.isNotEmpty() && pluginDir.toFile().forceDirectory()) { + plugins.forEach { + Files.copy(it, pluginDir.resolve(it.fileName.toString()), StandardCopyOption.REPLACE_EXISTING) + } + } + } + + fun extendUserPermissions(permissions: Collection) = users.forEach { it.extendPermissions(permissions) } } private fun valueFor(any: T): ConfigValue? = ConfigValueFactory.fromAnyRef(any) private fun addressValueFor(port: Int) = valueFor("localhost:$port") -private fun optional(path: String, obj: T?, body: (Config, T) -> Config): Config { +private inline fun optional(path: String, obj: T?, body: (Config, T) -> Config): Config { val config = ConfigFactory.empty() return if (obj == null) config else body(config, obj).atPath(path) } + +fun File.forceDirectory(): Boolean = this.isDirectory || this.mkdirs() diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeController.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeController.kt index e61ec57707..c45796aaf0 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeController.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeController.kt @@ -3,28 +3,29 @@ package net.corda.demobench.model import java.io.IOException import java.lang.management.ManagementFactory import java.net.ServerSocket +import java.nio.file.Files +import java.nio.file.Path import java.text.SimpleDateFormat import java.util.* import java.util.concurrent.atomic.AtomicInteger +import java.util.logging.Level +import net.corda.demobench.plugin.PluginController import net.corda.demobench.pty.R3Pty import tornadofx.Controller class NodeController : Controller() { - private companion object { + companion object { const val firstPort = 10000 const val minPort = 1024 const val maxPort = 65535 } private val jvm by inject() + private val pluginController by inject() - private var baseDir = baseDirFor(ManagementFactory.getRuntimeMXBean().startTime) - private val pluginDir = jvm.applicationDir.resolve("plugins") - - private val bankOfCorda = pluginDir.resolve("bank-of-corda.jar").toFile() - - private val cordaPath = jvm.applicationDir.resolve("corda").resolve("corda.jar") - private val command = jvm.commandFor(cordaPath) + private var baseDir: Path = baseDirFor(ManagementFactory.getRuntimeMXBean().startTime) + private val cordaPath: Path = jvm.applicationDir.resolve("corda").resolve("corda.jar") + private val command = jvm.commandFor(cordaPath).toTypedArray() private val nodes = LinkedHashMap() private val port = AtomicInteger(firstPort) @@ -40,6 +41,9 @@ class NodeController : Controller() { log.info("Corda JAR: $cordaPath") } + /** + * Validate a Node configuration provided by [net.corda.demobench.views.NodeTabView]. + */ fun validate(nodeData: NodeData): NodeConfig? { val config = NodeConfig( baseDir, @@ -85,15 +89,15 @@ class NodeController : Controller() { } } - fun isPortValid(port: Int): Boolean = (port >= minPort) && (port <= maxPort) + fun isPortValid(port: Int) = (port >= minPort) && (port <= maxPort) fun keyExists(key: String) = nodes.keys.contains(key) - fun nameExists(name: String) = keyExists(toKey(name)) + fun nameExists(name: String) = keyExists(name.toKey()) fun hasNetworkMap(): Boolean = networkMapConfig != null - fun chooseNetworkMap(config: NodeConfig) { + private fun chooseNetworkMap(config: NodeConfig) { if (hasNetworkMap()) { config.networkMap = networkMapConfig } else { @@ -105,24 +109,24 @@ class NodeController : Controller() { fun runCorda(pty: R3Pty, config: NodeConfig): Boolean { val nodeDir = config.nodeDir.toFile() - if (nodeDir.isDirectory || nodeDir.mkdirs()) { + if (nodeDir.forceDirectory()) { try { + // Install any built-in plugins into the working directory. + pluginController.populate(config) + + // Ensure that the users have every permission that they need. + config.extendUserPermissions(pluginController.permissionsFor(config)) + // Write this node's configuration file into its working directory. val confFile = nodeDir.resolve("node.conf") confFile.writeText(config.toText()) - // Nodes cannot issue cash unless they contain the "Bank of Corda" plugin. - if (config.isCashIssuer && bankOfCorda.isFile) { - log.info("Installing 'Bank of Corda' plugin") - bankOfCorda.copyTo(nodeDir.resolve("plugins").resolve(bankOfCorda.name), overwrite=true) - } - // Execute the Corda node pty.run(command, System.getenv(), nodeDir.toString()) log.info("Launched node: ${config.legalName}") return true } catch (e: Exception) { - log.severe("Failed to launch Corda:" + e) + log.log(Level.SEVERE, "Failed to launch Corda: ${e.message}", e) return false } } else { @@ -139,11 +143,16 @@ class NodeController : Controller() { nodes.clear() } + /** + * Add a [NodeConfig] object that has been loaded from a profile. + */ fun register(config: NodeConfig): Boolean { if (nodes.putIfAbsent(config.key, config) != null) { return false } + updatePort(config) + if ((networkMapConfig == null) && config.isNetworkMap()) { networkMapConfig = config } @@ -151,9 +160,32 @@ class NodeController : Controller() { return true } - fun relocate(config: NodeConfig) = config.moveTo(baseDir) + /** + * Creates a node directory that can host a running instance of Corda. + */ + @Throws(IOException::class) + fun install(config: InstallConfig): NodeConfig { + val installed = config.installTo(baseDir) - private fun baseDirFor(time: Long) = jvm.userHome.resolve("demobench").resolve(localFor(time)) + pluginController.userPluginsFor(config).forEach { + val pluginDir = Files.createDirectories(installed.pluginDir) + val plugin = Files.copy(it, pluginDir.resolve(it.fileName.toString())) + log.info("Installed: $plugin") + } + + if (!config.deleteBaseDir()) { + log.warning("Failed to remove '${config.baseDir}'") + } + + return installed + } + + private fun updatePort(config: NodeConfig) { + val nextPort = 1 + arrayOf(config.artemisPort, config.webPort, config.h2Port).max() as Int + port.getAndUpdate { Math.max(nextPort, it) } + } + + private fun baseDirFor(time: Long): Path = jvm.dataHome.resolve(localFor(time)) private fun localFor(time: Long) = SimpleDateFormat("yyyyMMddHHmmss").format(Date(time)) } diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/ServiceController.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/ServiceController.kt index 0094217d54..62c4ee76ed 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/model/ServiceController.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/ServiceController.kt @@ -1,13 +1,15 @@ package net.corda.demobench.model -import tornadofx.Controller +import java.io.IOException import java.io.InputStreamReader import java.net.URL import java.util.* +import java.util.logging.Level +import tornadofx.Controller -class ServiceController : Controller() { +class ServiceController(resourceName: String = "/services.conf") : Controller() { - val services: List = loadConf(javaClass.classLoader.getResource("services.conf")) + val services: List = loadConf(resources.url(resourceName)) val notaries: List = services.filter { it.startsWith("corda.notary.") }.toList() @@ -18,16 +20,21 @@ class ServiceController : Controller() { if (url == null) { return emptyList() } else { - val set = TreeSet() - InputStreamReader(url.openStream()).useLines { sq -> - sq.forEach { line -> - val service = line.trim() - set.add(service) + try { + val set = TreeSet() + InputStreamReader(url.openStream()).useLines { sq -> + sq.forEach { line -> + val service = line.trim() + set.add(service) - log.info("Supports: $service") + log.info("Supports: $service") + } } + return set.toList() + } catch (e: IOException) { + log.log(Level.SEVERE, "Failed to load $url: ${e.message}", e) + return emptyList() } - return set.toList() } } diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/User.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/User.kt new file mode 100644 index 0000000000..c42988c58e --- /dev/null +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/User.kt @@ -0,0 +1,26 @@ +package net.corda.demobench.model + +import java.util.* + +data class User(val user: String, val password: String, var permissions: List) { + fun extendPermissions(extra: Collection) { + val extended = LinkedHashSet(permissions) + extended.addAll(extra) + permissions = extended.toList() + } + + fun toMap() = mapOf( + "user" to user, + "password" to password, + "permissions" to permissions + ) +} + +@Suppress("UNCHECKED_CAST") +fun toUser(map: Map) = User( + map.getOrElse("user", { "none" }) as String, + map.getOrElse("password", { "none" }) as String, + map.getOrElse("permissions", { emptyList() }) as List +) + +fun user(name: String) = User(name, "letmein", listOf("StartFlow.net.corda.flows.CashFlow")) diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/WebServer.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/WebServer.kt deleted file mode 100644 index 2e38b9517e..0000000000 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/model/WebServer.kt +++ /dev/null @@ -1,57 +0,0 @@ -package net.corda.demobench.model - -import net.corda.demobench.loggerFor -import java.util.concurrent.Executors - -class WebServer(val webServerController: WebServerController) : AutoCloseable { - private companion object { - val log = loggerFor() - } - - private val executor = Executors.newSingleThreadExecutor() - private var process: Process? = null - - fun open(config: NodeConfig, onExit: (NodeConfig) -> Unit) { - val nodeDir = config.nodeDir.toFile() - - if (!nodeDir.isDirectory) { - log.warn("Working directory '{}' does not exist.", nodeDir.absolutePath) - onExit(config) - return - } - - val p = webServerController.process() - .directory(nodeDir) - .start() - process = p - - log.info("Launched Web Server for '{}'", config.legalName) - - // Close these streams because no-one is using them. - safeClose(p.outputStream) - safeClose(p.inputStream) - safeClose(p.errorStream) - - executor.submit { - val exitValue = p.waitFor() - process = null - - log.info("Web Server for '{}' has exited (value={})", config.legalName, exitValue) - onExit(config) - } - } - - override fun close() { - executor.shutdown() - process?.destroy() - } - - private fun safeClose(c: AutoCloseable) { - try { - c.close() - } catch (e: Exception) { - log.error("Failed to close stream: '{}'", e.message) - } - } - -} \ No newline at end of file diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/plugin/PluginController.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/plugin/PluginController.kt new file mode 100644 index 0000000000..6cdc80e078 --- /dev/null +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/plugin/PluginController.kt @@ -0,0 +1,72 @@ +package net.corda.demobench.plugin + +import java.io.IOException +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import java.util.stream.Stream +import kotlinx.support.jdk8.collections.stream +import kotlinx.support.jdk8.streams.toList +import net.corda.core.node.CordaPluginRegistry +import net.corda.demobench.model.HasPlugins +import net.corda.demobench.model.JVMConfig +import net.corda.demobench.model.NodeConfig +import tornadofx.* + +class PluginController : Controller() { + + private val jvm by inject() + private val pluginDir: Path = jvm.applicationDir.resolve("plugins") + private val bankOfCorda = pluginDir.resolve("bank-of-corda.jar").toFile() + + /** + * Install any built-in plugins that this node requires. + */ + @Throws(IOException::class) + fun populate(config: NodeConfig) { + // Nodes cannot issue cash unless they contain the "Bank of Corda" plugin. + if (config.isCashIssuer && bankOfCorda.isFile) { + bankOfCorda.copyTo(config.pluginDir.resolve(bankOfCorda.name).toFile(), overwrite=true) + log.info("Installed 'Bank of Corda' plugin") + } + } + + /** + * Generate the set of user permissions that this node's plugins require. + */ + @Throws(IOException::class) + fun permissionsFor(config: HasPlugins): List = walkPlugins(config.pluginDir) + .map { plugin -> classLoaderFor(plugin) } + .flatMap { cl -> cl.use(URLClassLoader::flowsFor).stream() } + .map { flow -> "StartFlow.$flow" } + .toList() + + /** + * Generates a stream of a node's non-built-it plugins. + */ + @Throws(IOException::class) + fun userPluginsFor(config: HasPlugins): Stream = walkPlugins(config.pluginDir) + .filter { bankOfCorda.name != it.fileName.toString() } + + private fun walkPlugins(pluginDir: Path): Stream { + return if (Files.isDirectory(pluginDir)) + Files.walk(pluginDir, 1).filter(Path::isPlugin) + else + Stream.empty() + } + + private fun classLoaderFor(jarPath: Path) = URLClassLoader(arrayOf(jarPath.toUri().toURL()), javaClass.classLoader) + +} + +private fun URLClassLoader.flowsFor(): List = ServiceLoader.load(CordaPluginRegistry::class.java, this) + .flatMap { plugin -> + val registry = constructorFor(plugin).newInstance() as CordaPluginRegistry + registry.requiredFlows.keys + } + +private fun constructorFor(type: Any) = type.javaClass.constructors.filter { it.parameterCount == 0 }.single() + +fun Path.isPlugin(): Boolean = Files.isReadable(this) && this.fileName.toString().endsWith(".jar") +fun Path.inPluginsDir(): Boolean = (this.parent != null) && this.parent.endsWith("plugins/") diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/profile/ProfileController.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/profile/ProfileController.kt index a820fae9bf..0d32a2b396 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/profile/ProfileController.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/profile/ProfileController.kt @@ -1,38 +1,47 @@ package net.corda.demobench.profile -import com.google.common.net.HostAndPort import com.typesafe.config.Config import com.typesafe.config.ConfigFactory import java.io.File +import java.io.IOException import java.net.URI import java.nio.charset.StandardCharsets.UTF_8 import java.nio.file.* import java.util.* import java.util.function.BiPredicate +import java.util.logging.Level import java.util.stream.StreamSupport import javafx.stage.FileChooser import javafx.stage.FileChooser.ExtensionFilter import kotlinx.support.jdk8.collections.spliterator import net.corda.demobench.model.* +import net.corda.demobench.plugin.PluginController +import net.corda.demobench.plugin.inPluginsDir +import net.corda.demobench.plugin.isPlugin import tornadofx.Controller class ProfileController : Controller() { private val jvm by inject() - private val baseDir = jvm.userHome.resolve("demobench") + private val baseDir: Path = jvm.dataHome private val nodeController by inject() - private val serviceController by inject() + private val pluginController by inject() + private val installFactory by inject() private val chooser = FileChooser() init { chooser.title = "DemoBench Profiles" chooser.initialDirectory = baseDir.toFile() - chooser.extensionFilters.add(ExtensionFilter("DemoBench profiles (*.zip)", "*.zip", "*.ZIP")) + chooser.extensionFilters.add(ExtensionFilter("DemoBench profiles (*.profile)", "*.profile", "*.PROFILE")) } + /** + * Saves the active node configurations into a ZIP file, along with their Cordapps. + */ + @Throws(IOException::class) fun saveProfile(): Boolean { - val target = forceExtension(chooser.showSaveDialog(null) ?: return false, ".zip") - log.info("Save profile as: $target") + val target = forceExtension(chooser.showSaveDialog(null) ?: return false, ".profile") + log.info("Saving profile as: $target") val configs = nodeController.activeNodes @@ -40,12 +49,29 @@ class ProfileController : Controller() { // dialogue has already confirmed that this is OK. target.delete() - FileSystems.newFileSystem(URI.create("jar:" + target.toURI()), mapOf("create" to "true")).use { fs -> - configs.forEach { config -> - val nodeDir = Files.createDirectories(fs.getPath(config.key)) - val file = Files.write(nodeDir.resolve("node.conf"), config.toText().toByteArray(UTF_8)) - log.info("Wrote: $file") + // Write the profile as a ZIP file. + try { + FileSystems.newFileSystem(URI.create("jar:" + target.toURI()), mapOf("create" to "true")).use { fs -> + configs.forEach { config -> + // Write the configuration file. + val nodeDir = Files.createDirectories(fs.getPath(config.key)) + val file = Files.write(nodeDir.resolve("node.conf"), config.toText().toByteArray(UTF_8)) + log.info("Wrote: $file") + + // Write all of the non-built-in plugins. + val pluginDir = Files.createDirectory(nodeDir.resolve("plugins")) + pluginController.userPluginsFor(config).forEach { + val plugin = Files.copy(it, pluginDir.resolve(it.fileName.toString())) + log.info("Wrote: $plugin") + } + } } + + log.info("Profile saved.") + } catch (e: IOException) { + log.log(Level.SEVERE, "Failed to save profile '$target': '${e.message}'", e) + target.delete() + throw e } return true @@ -55,24 +81,50 @@ class ProfileController : Controller() { return if (target.extension.isEmpty()) File(target.parent, target.name + ext) else target } - fun openProfile(): List? { + /** + * Parses a profile (ZIP) file. + */ + @Throws(IOException::class) + fun openProfile(): List? { val chosen = chooser.showOpenDialog(null) ?: return null log.info("Selected profile: $chosen") - val configs = LinkedList() + val configs = LinkedList() FileSystems.newFileSystem(chosen.toPath(), null).use { fs -> + // Identify the nodes first... StreamSupport.stream(fs.rootDirectories.spliterator(), false) - .flatMap { Files.find(it, 2, BiPredicate { p, attr -> "node.conf" == p?.fileName.toString() }) } - .forEach { file -> + .flatMap { Files.find(it, 2, BiPredicate { p, attr -> "node.conf" == p?.fileName.toString() && attr.isRegularFile }) } + .map { file -> try { - // Java seems to "walk" through the ZIP file backwards. - // So add new config to the front of the list, so that - // our final list is ordered to match the file. - configs.addFirst(toNodeConfig(parse(file))) + val config = installFactory.toInstallConfig(parse(file), baseDir) log.info("Loaded: $file") + config } catch (e: Exception) { - log.severe("Failed to parse '$file': ${e.message}") + log.log(Level.SEVERE, "Failed to parse '$file': ${e.message}", e) + throw e + } + // Java seems to "walk" through the ZIP file backwards. + // So add new config to the front of the list, so that + // our final list is ordered to match the file. + }.forEach { configs.addFirst(it) } + + val nodeIndex = configs.map { it.key to it }.toMap() + + // Now extract all of the plugins from the ZIP file, + // and copy them to a temporary location. + StreamSupport.stream(fs.rootDirectories.spliterator(), false) + .flatMap { Files.find(it, 3, BiPredicate { p, attr -> p.inPluginsDir() && p.isPlugin() && attr.isRegularFile }) } + .forEach { plugin -> + val config = nodeIndex[plugin.getName(0).toString()] ?: return@forEach + + try { + val pluginDir = Files.createDirectories(config.pluginDir) + Files.copy(plugin, pluginDir.resolve(plugin.fileName.toString())) + log.info("Loaded: $plugin") + } catch (e: Exception) { + log.log(Level.SEVERE, "Failed to extract '$plugin': ${e.message}", e) + configs.forEach { c -> c.deleteBaseDir() } throw e } } @@ -81,51 +133,8 @@ class ProfileController : Controller() { return configs } - private fun toNodeConfig(config: Config): NodeConfig { - val artemisPort = config.parsePort("artemisAddress") - val webPort = config.parsePort("webAddress") - val h2Port = config.getInt("h2port") - val extraServices = config.parseExtraServices("extraAdvertisedServiceIds") - - val nodeConfig = NodeConfig( - baseDir, // temporary value - config.getString("myLegalName"), - artemisPort, - config.getString("nearestCity"), - webPort, - h2Port, - extraServices, - config.getObjectList("rpcUsers").map { it.unwrapped() }.toList() - ) - - if (config.hasPath("networkMapService")) { - val nmap = config.getConfig("networkMapService") - nodeConfig.networkMap = NetworkMapConfig(nmap.getString("legalName"), nmap.parsePort("address")) - } else { - log.info("Node '${nodeConfig.legalName}' is the network map") - } - - return nodeConfig - } - private fun parse(path: Path): Config = Files.newBufferedReader(path).use { return ConfigFactory.parseReader(it) } - private fun Config.parsePort(path: String): Int { - val address = this.getString(path) - val port = HostAndPort.fromString(address).port - require(nodeController.isPortValid(port), { "Invalid port $port from '$path'." }) - return port - } - - private fun Config.parseExtraServices(path: String): List { - val services = serviceController.services.toSortedSet() - return this.getString(path).split(",") - .filter { it -> !it.isNullOrEmpty() } - .map { svc -> - require(svc in services, { "Unknown service '$svc'." } ) - svc - }.toList() - } } diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/pty/R3Pty.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/pty/R3Pty.kt index d817b022c0..4dfa11579d 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/pty/R3Pty.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/pty/R3Pty.kt @@ -7,6 +7,7 @@ import com.pty4j.PtyProcess import net.corda.demobench.loggerFor import java.awt.* +import java.io.IOException import java.nio.charset.StandardCharsets.UTF_8 import java.util.* import java.util.concurrent.Executors @@ -39,6 +40,7 @@ class R3Pty(val name: String, settings: SettingsProvider, dimension: Dimension, } } + @Throws(IOException::class) fun run(args: Array, envs: Map, workingDir: String?) { check(!terminal.isSessionRunning, { "${terminal.sessionName} is already running" }) diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/rpc/NodeRPC.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/rpc/NodeRPC.kt index 23dd0a0671..f22466950f 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/rpc/NodeRPC.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/rpc/NodeRPC.kt @@ -22,8 +22,7 @@ class NodeRPC(config: NodeConfig, start: () -> Unit, invoke: (CordaRPCOps) -> Un val setupTask = object : TimerTask() { override fun run() { try { - rpcClient.start(config.users[0].getOrElse("user") { "none" } as String, - config.users[0].getOrElse("password") { "none" } as String) + rpcClient.start(config.users[0].user, config.users[0].password) val ops = rpcClient.proxy() // Cancel the "setup" task now that we've created the RPC client. diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/views/DemoBenchView.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/views/DemoBenchView.kt index cef277c1b8..14d5970dff 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/views/DemoBenchView.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/views/DemoBenchView.kt @@ -7,7 +7,7 @@ import javafx.scene.control.Button import javafx.scene.control.MenuItem import javafx.scene.control.Tab import javafx.scene.control.TabPane -import net.corda.demobench.model.NodeConfig +import net.corda.demobench.model.InstallConfig import net.corda.demobench.model.NodeController import net.corda.demobench.profile.ProfileController import net.corda.demobench.ui.CloseableTab @@ -58,10 +58,8 @@ class DemoBenchView : View("Corda Demo Bench") { private fun configureProfileOpen() = menuOpen.setOnAction { try { - val profile = profileController.openProfile() - if (profile != null) { - loadProfile(profile) - } + val profile = profileController.openProfile() ?: return@setOnAction + loadProfile(profile) } catch (e: Exception) { ExceptionDialog(e).apply { initOwner(root.scene.window) }.showAndWait() } @@ -88,13 +86,13 @@ class DemoBenchView : View("Corda Demo Bench") { return nodeTabView } - private fun loadProfile(nodes: List) { + private fun loadProfile(nodes: List) { closeAllTabs() nodeController.reset() nodes.forEach { val nodeTabView = createNodeTabView(false) - nodeTabView.launch(nodeController.relocate(it)) + nodeTabView.launch(nodeController.install(it)) } enableAddNodes() diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/views/NodeTabView.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/views/NodeTabView.kt index 4695ea13f4..d67caf348f 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/views/NodeTabView.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/views/NodeTabView.kt @@ -1,14 +1,15 @@ package net.corda.demobench.views +import java.nio.file.Path import java.text.DecimalFormat +import java.util.* import javafx.application.Platform import javafx.scene.control.SelectionMode.MULTIPLE +import javafx.scene.input.KeyCode import javafx.scene.layout.Pane +import javafx.stage.FileChooser import javafx.util.converter.NumberStringConverter -import net.corda.demobench.model.NodeConfig -import net.corda.demobench.model.NodeController -import net.corda.demobench.model.NodeDataModel -import net.corda.demobench.model.ServiceController +import net.corda.demobench.model.* import net.corda.demobench.ui.CloseableTab import tornadofx.* @@ -16,16 +17,30 @@ class NodeTabView : Fragment() { override val root = stackpane {} private val main by inject() - private val showConfig by param() + private val showConfig by param(true) + + private companion object : Component() { + const val textWidth = 200.0 + const val numberWidth = 100.0 + const val maxNameLength = 10 - private companion object { val integerFormat = DecimalFormat() val notNumber = "[^\\d]".toRegex() + + val jvm by inject() + + init { + integerFormat.isGroupingUsed = false + } } - private val model = NodeDataModel() private val nodeController by inject() private val serviceController by inject() + private val chooser = FileChooser() + + private val model = NodeDataModel() + private val cordapps = LinkedList().observable() + private val availableServices: List = if (nodeController.hasNetworkMap()) serviceController.services else serviceController.notaries private val nodeTerminalView = find() private val nodeConfigView = stackpane { @@ -33,6 +48,8 @@ class NodeTabView : Fragment() { form { fieldset("Configuration") { + isFillWidth = false + field("Node Name", op = { nodeNameField() }) field("Nearest City", op = { nearestCityField() }) field("P2P Port", op = { p2pPortField() }) @@ -40,10 +57,37 @@ class NodeTabView : Fragment() { field("Database Port", op = { databasePortField() }) } - fieldset("Services") { - listview(availableServices.observable()) { - selectionModel.selectionMode = MULTIPLE - model.item.extraServices.set(selectionModel.selectedItems) + hbox { + styleClass.addAll("node-panel") + + fieldset("Services") { + styleClass.addAll("services-panel") + + listview(availableServices.observable()) { + selectionModel.selectionMode = MULTIPLE + model.item.extraServices.set(selectionModel.selectedItems) + } + } + + fieldset("Cordapps") { + styleClass.addAll("cordapps-panel") + + listview(cordapps) { + setOnKeyPressed { key -> + if ((key.code == KeyCode.DELETE) && !selectionModel.isEmpty) { + cordapps.remove(selectionModel.selectedItem) + } + key.consume() + } + } + button("Add Cordapp") { + setOnAction { + val app = (chooser.showOpenDialog(null) ?: return@setOnAction).toPath() + if (!cordapps.contains(app)) { + cordapps.add(app) + } + } + } } } @@ -61,12 +105,7 @@ class NodeTabView : Fragment() { val nodeTab = CloseableTab("New Node", root) - private val availableServices: List - get() = if (nodeController.hasNetworkMap()) serviceController.services else serviceController.notaries - init { - integerFormat.isGroupingUsed = false - // Ensure that we destroy the terminal along with the tab. nodeTab.setOnCloseRequest { nodeTerminalView.destroy() @@ -78,11 +117,14 @@ class NodeTabView : Fragment() { model.artemisPort.value = nodeController.nextPort model.webPort.value = nodeController.nextPort model.h2Port.value = nodeController.nextPort + + chooser.title = "Cordapps" + chooser.initialDirectory = jvm.dataHome.toFile() + chooser.extensionFilters.add(FileChooser.ExtensionFilter("Cordapps (*.jar)", "*.jar", "*.JAR")) } private fun Pane.nodeNameField() = textfield(model.legalName) { - minWidth = 200.0 - maxWidth = 200.0 + minWidth = textWidth validator { if (it == null) { error("Node name is required") @@ -92,7 +134,7 @@ class NodeTabView : Fragment() { error("Node name is required") } else if (nodeController.nameExists(name)) { error("Node with this name already exists") - } else if (name.length > 10) { + } else if (name.length > maxNameLength) { error("Name is too long") } else { null @@ -102,8 +144,7 @@ class NodeTabView : Fragment() { } private fun Pane.nearestCityField() = textfield(model.nearestCity) { - minWidth = 200.0 - maxWidth = 200.0 + minWidth = textWidth validator { if (it == null) { error("Nearest city is required") @@ -116,8 +157,7 @@ class NodeTabView : Fragment() { } private fun Pane.p2pPortField() = textfield(model.artemisPort, NumberStringConverter(integerFormat)) { - minWidth = 100.0 - maxWidth = 100.0 + minWidth = numberWidth validator { if ((it == null) || it.isEmpty()) { error("Port number required") @@ -139,8 +179,7 @@ class NodeTabView : Fragment() { } private fun Pane.webPortField() = textfield(model.webPort, NumberStringConverter(integerFormat)) { - minWidth = 100.0 - maxWidth = 100.0 + minWidth = numberWidth validator { if ((it == null) || it.isEmpty()) { error("Port number required") @@ -162,8 +201,7 @@ class NodeTabView : Fragment() { } private fun Pane.databasePortField() = textfield(model.h2Port, NumberStringConverter(integerFormat)) { - minWidth = 100.0 - maxWidth = 100.0 + minWidth = numberWidth validator { if ((it == null) || it.isEmpty()) { error("Port number required") @@ -192,6 +230,7 @@ class NodeTabView : Fragment() { val config = nodeController.validate(model.item) if (config != null) { nodeConfigView.isVisible = false + config.install(cordapps) launchNode(config) } } diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/views/NodeTerminalView.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/views/NodeTerminalView.kt index ad6a6ec604..ba4055732b 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/views/NodeTerminalView.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/views/NodeTerminalView.kt @@ -10,10 +10,13 @@ import javafx.scene.control.Button import javafx.scene.control.Label import javafx.scene.layout.VBox import javax.swing.SwingUtilities +import net.corda.demobench.explorer.ExplorerController import net.corda.demobench.model.* import net.corda.demobench.pty.R3Pty import net.corda.demobench.rpc.NodeRPC import net.corda.demobench.ui.PropertyLabel +import net.corda.demobench.web.DBViewer +import net.corda.demobench.web.WebServerController import tornadofx.Fragment class NodeTerminalView : Fragment() { diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/DBViewer.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/web/DBViewer.kt similarity index 88% rename from tools/demobench/src/main/kotlin/net/corda/demobench/model/DBViewer.kt rename to tools/demobench/src/main/kotlin/net/corda/demobench/web/DBViewer.kt index 6047cd5ec0..215d02b265 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/model/DBViewer.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/web/DBViewer.kt @@ -1,11 +1,13 @@ -package net.corda.demobench.model +package net.corda.demobench.web +import java.sql.SQLException +import java.util.concurrent.Executors +import kotlin.reflect.jvm.jvmName import net.corda.demobench.loggerFor +import org.h2.Driver import org.h2.server.web.LocalWebServer import org.h2.tools.Server import org.h2.util.JdbcUtils -import java.util.concurrent.Executors -import kotlin.reflect.jvm.jvmName class DBViewer : AutoCloseable { private companion object { @@ -35,9 +37,10 @@ class DBViewer : AutoCloseable { webServer.shutdown() } + @Throws(SQLException::class) fun openBrowser(h2Port: Int) { val conn = JdbcUtils.getConnection( - org.h2.Driver::class.jvmName, + Driver::class.jvmName, "jdbc:h2:tcp://localhost:$h2Port/node", "sa", "" diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/web/WebServer.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/web/WebServer.kt new file mode 100644 index 0000000000..81ee60319c --- /dev/null +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/web/WebServer.kt @@ -0,0 +1,66 @@ +package net.corda.demobench.web + +import java.io.IOException +import java.util.concurrent.Executors +import net.corda.demobench.loggerFor +import net.corda.demobench.model.NodeConfig + +class WebServer internal constructor(private val webServerController: WebServerController) : AutoCloseable { + private companion object { + val log = loggerFor() + } + + private val executor = Executors.newSingleThreadExecutor() + private var process: Process? = null + + @Throws(IOException::class) + fun open(config: NodeConfig, onExit: (NodeConfig) -> Unit) { + val nodeDir = config.nodeDir.toFile() + + if (!nodeDir.isDirectory) { + log.warn("Working directory '{}' does not exist.", nodeDir.absolutePath) + onExit(config) + return + } + + try { + val p = webServerController.process() + .directory(nodeDir) + .start() + process = p + + log.info("Launched Web Server for '{}'", config.legalName) + + // Close these streams because no-one is using them. + safeClose(p.outputStream) + safeClose(p.inputStream) + safeClose(p.errorStream) + + executor.submit { + val exitValue = p.waitFor() + process = null + + log.info("Web Server for '{}' has exited (value={})", config.legalName, exitValue) + onExit(config) + } + } catch (e: IOException) { + log.error("Failed to launch Web Server for '{}': {}", config.legalName, e.message) + onExit(config) + throw e + } + } + + override fun close() { + executor.shutdown() + process?.destroy() + } + + private fun safeClose(c: AutoCloseable) { + try { + c.close() + } catch (e: Exception) { + log.error("Failed to close stream: '{}'", e.message) + } + } + +} diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/WebServerController.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/web/WebServerController.kt similarity index 83% rename from tools/demobench/src/main/kotlin/net/corda/demobench/model/WebServerController.kt rename to tools/demobench/src/main/kotlin/net/corda/demobench/web/WebServerController.kt index 60718e74d4..578035015d 100644 --- a/tools/demobench/src/main/kotlin/net/corda/demobench/model/WebServerController.kt +++ b/tools/demobench/src/main/kotlin/net/corda/demobench/web/WebServerController.kt @@ -1,5 +1,6 @@ -package net.corda.demobench.model +package net.corda.demobench.web +import net.corda.demobench.model.JVMConfig import tornadofx.Controller class WebServerController : Controller() { @@ -15,4 +16,4 @@ class WebServerController : Controller() { fun webServer() = WebServer(this) -} \ No newline at end of file +} diff --git a/tools/demobench/src/main/kotlin/org/h2/server/web/LocalWebServer.kt b/tools/demobench/src/main/kotlin/org/h2/server/web/LocalWebServer.kt index dade325775..312291a4d1 100644 --- a/tools/demobench/src/main/kotlin/org/h2/server/web/LocalWebServer.kt +++ b/tools/demobench/src/main/kotlin/org/h2/server/web/LocalWebServer.kt @@ -1,6 +1,7 @@ package org.h2.server.web import java.sql.Connection +import java.sql.SQLException class LocalWebServer : WebServer() { @@ -8,6 +9,7 @@ class LocalWebServer : WebServer() { * Create a new session that will not kill the entire * web server if/when we disconnect it. */ + @Throws(SQLException::class) override fun addSession(conn: Connection): String { val session = createNewSession("local") session.setConnection(conn) diff --git a/tools/demobench/src/main/resources/net/corda/demobench/style.css b/tools/demobench/src/main/resources/net/corda/demobench/style.css index d5453f6a36..3db609fef6 100644 --- a/tools/demobench/src/main/resources/net/corda/demobench/style.css +++ b/tools/demobench/src/main/resources/net/corda/demobench/style.css @@ -1,4 +1,5 @@ /* + * https://docs.oracle.com/javafx/2/api/javafx/scene/doc-files/cssref.html * https://r3-cev.atlassian.net/wiki/display/RH/Color+Palettes */ @@ -21,3 +22,22 @@ -fx-background-radius: 5px; -fx-opacity: 80%; } + +.node-panel { + -fx-spacing: 20px; +} + +.services-panel { + -fx-pref-width: 600px; + -fx-spacing: 5px; +} + +.cordapps-panel { + -fx-pref-width: 600px; + -fx-spacing: 5px; +} + +.list-cell { + -fx-control-inner-background: white; + -fx-control-inner-background-alt: gainsboro; +} diff --git a/tools/demobench/src/main/resources/net/corda/demobench/views/DemoBenchView.fxml b/tools/demobench/src/main/resources/net/corda/demobench/views/DemoBenchView.fxml index 4ec4a4ecaa..d068ba9efd 100644 --- a/tools/demobench/src/main/resources/net/corda/demobench/views/DemoBenchView.fxml +++ b/tools/demobench/src/main/resources/net/corda/demobench/views/DemoBenchView.fxml @@ -10,20 +10,18 @@ - - - - - - - - - - - - + + diff --git a/tools/demobench/src/main/resources/net/corda/demobench/views/NodeTerminalView.fxml b/tools/demobench/src/main/resources/net/corda/demobench/views/NodeTerminalView.fxml index 0b2e85587d..ac628de248 100644 --- a/tools/demobench/src/main/resources/net/corda/demobench/views/NodeTerminalView.fxml +++ b/tools/demobench/src/main/resources/net/corda/demobench/views/NodeTerminalView.fxml @@ -8,27 +8,19 @@ - - - - - - - - - - - - - - - -