From 881509ab0dc00aa7d9dc8ace98af70ce9df35c3f Mon Sep 17 00:00:00 2001 From: Stefano Franz Date: Tue, 3 Jul 2018 12:58:43 +0100 Subject: [PATCH] CORDA-1717 - backport bootstrapper to 3.2 (#3496) --- .idea/compiler.xml | 2 + settings.gradle | 1 + tools/network-bootstrapper/build.gradle | 69 ++++ .../java/net/corda/bootstrapper/GuiUtils.java | 48 +++ .../net/corda/bootstrapper/Constants.kt | 57 +++ .../kotlin/net/corda/bootstrapper/Main.kt | 34 ++ .../net/corda/bootstrapper/NetworkBuilder.kt | 234 ++++++++++++ .../bootstrapper/backends/AzureBackend.kt | 63 +++ .../corda/bootstrapper/backends/Backend.kt | 48 +++ .../bootstrapper/backends/DockerBackend.kt | 25 ++ .../bootstrapper/cli/CommandLineInterface.kt | 70 ++++ .../corda/bootstrapper/cli/CommandParsers.kt | 55 +++ .../containers/instance/InstanceInfo.kt | 7 + .../containers/instance/Instantiator.kt | 18 + .../instance/azure/AzureInstantiator.kt | 85 +++++ .../instance/docker/DockerInstantiator.kt | 99 +++++ .../containers/push/ContainerPusher.kt | 9 + .../push/azure/AzureContainerPusher.kt | 62 +++ .../push/azure/AzureRegistryLocator.kt | 55 +++ .../push/docker/DockerContainerPusher.kt | 15 + .../net/corda/bootstrapper/context/Context.kt | 69 ++++ .../corda/bootstrapper/docker/DockerUtils.kt | 35 ++ .../bootstrapper/gui/BootstrapperView.kt | 361 ++++++++++++++++++ .../kotlin/net/corda/bootstrapper/gui/Gui.kt | 11 + .../net/corda/bootstrapper/nodes/BuiltNode.kt | 22 ++ .../corda/bootstrapper/nodes/CopiedNode.kt | 40 ++ .../net/corda/bootstrapper/nodes/FoundNode.kt | 53 +++ .../net/corda/bootstrapper/nodes/NodeAdder.kt | 26 ++ .../corda/bootstrapper/nodes/NodeBuilder.kt | 48 +++ .../corda/bootstrapper/nodes/NodeCopier.kt | 102 +++++ .../corda/bootstrapper/nodes/NodeFinder.kt | 32 ++ .../corda/bootstrapper/nodes/NodeInstance.kt | 31 ++ .../bootstrapper/nodes/NodeInstanceRequest.kt | 24 ++ .../bootstrapper/nodes/NodeInstantiator.kt | 95 +++++ .../corda/bootstrapper/nodes/NodePusher.kt | 19 + .../corda/bootstrapper/nodes/PushedNode.kt | 25 ++ .../bootstrapper/notaries/CopiedNotary.kt | 14 + .../bootstrapper/notaries/NotaryCopier.kt | 70 ++++ .../bootstrapper/notaries/NotaryFinder.kt | 25 ++ .../serialization/SerializationHelper.kt | 30 ++ .../net/corda/bootstrapper/volumes/Volume.kt | 56 +++ .../volumes/azure/AzureSmbVolume.kt | 81 ++++ .../volumes/docker/LocalVolume.kt | 39 ++ .../src/main/resources/node-Dockerfile | 37 ++ .../src/main/resources/node_info_watcher.sh | 12 + .../src/main/resources/node_killer.sh | 3 + .../src/main/resources/notary-Dockerfile | 39 ++ .../src/main/resources/rpc-settings.conf | 4 + .../src/main/resources/run-corda-node.sh | 26 ++ .../src/main/resources/run-corda-notary.sh | 25 ++ .../src/main/resources/ssh.conf | 3 + .../src/main/resources/views/bootstrapper.css | 13 + .../src/main/resources/views/cordalogo.png | Bin 0 -> 33254 bytes .../src/main/resources/views/mainPane.fxml | 53 +++ 54 files changed, 2579 insertions(+) create mode 100644 tools/network-bootstrapper/build.gradle create mode 100644 tools/network-bootstrapper/src/main/java/net/corda/bootstrapper/GuiUtils.java create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/Constants.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/Main.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/NetworkBuilder.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/backends/AzureBackend.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/backends/Backend.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/backends/DockerBackend.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/cli/CommandLineInterface.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/cli/CommandParsers.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/InstanceInfo.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/Instantiator.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/azure/AzureInstantiator.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/docker/DockerInstantiator.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/ContainerPusher.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/azure/AzureContainerPusher.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/azure/AzureRegistryLocator.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/docker/DockerContainerPusher.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/context/Context.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/docker/DockerUtils.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/gui/BootstrapperView.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/gui/Gui.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/BuiltNode.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/CopiedNode.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/FoundNode.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeAdder.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeBuilder.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeCopier.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeFinder.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeInstance.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeInstanceRequest.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeInstantiator.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodePusher.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/PushedNode.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/notaries/CopiedNotary.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/notaries/NotaryCopier.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/notaries/NotaryFinder.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/serialization/SerializationHelper.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/volumes/Volume.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/volumes/azure/AzureSmbVolume.kt create mode 100644 tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/volumes/docker/LocalVolume.kt create mode 100644 tools/network-bootstrapper/src/main/resources/node-Dockerfile create mode 100755 tools/network-bootstrapper/src/main/resources/node_info_watcher.sh create mode 100755 tools/network-bootstrapper/src/main/resources/node_killer.sh create mode 100644 tools/network-bootstrapper/src/main/resources/notary-Dockerfile create mode 100644 tools/network-bootstrapper/src/main/resources/rpc-settings.conf create mode 100755 tools/network-bootstrapper/src/main/resources/run-corda-node.sh create mode 100755 tools/network-bootstrapper/src/main/resources/run-corda-notary.sh create mode 100644 tools/network-bootstrapper/src/main/resources/ssh.conf create mode 100644 tools/network-bootstrapper/src/main/resources/views/bootstrapper.css create mode 100644 tools/network-bootstrapper/src/main/resources/views/cordalogo.png create mode 100644 tools/network-bootstrapper/src/main/resources/views/mainPane.fxml diff --git a/.idea/compiler.xml b/.idea/compiler.xml index dba1dad5e2..8bca37ce65 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -87,6 +87,8 @@ + + diff --git a/settings.gradle b/settings.gradle index a4f87dee28..b0cf419424 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,6 +34,7 @@ include 'tools:demobench' include 'tools:loadtest' include 'tools:graphs' include 'tools:bootstrapper' +include 'tools:network-bootstrapper' include 'example-code' project(':example-code').projectDir = file("$settingsDir/docs/source/example-code") include 'samples:attachment-demo' diff --git a/tools/network-bootstrapper/build.gradle b/tools/network-bootstrapper/build.gradle new file mode 100644 index 0000000000..2b0089499b --- /dev/null +++ b/tools/network-bootstrapper/build.gradle @@ -0,0 +1,69 @@ +buildscript { + + ext.tornadofx_version = '1.7.15' + ext.controlsfx_version = '8.40.12' + + repositories { + mavenLocal() + mavenCentral() + jcenter() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-noarg:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.1' + } +} + +repositories { + mavenLocal() + mavenCentral() + jcenter() +} + + +apply plugin: 'kotlin' +apply plugin: 'idea' +apply plugin: 'java' +apply plugin: 'application' +apply plugin: 'com.github.johnrengelman.shadow' + +dependencies { + compile "com.microsoft.azure:azure:1.8.0" + compile "com.github.docker-java:docker-java:3.0.6" + + testCompile "org.jetbrains.kotlin:kotlin-test" + testCompile "org.jetbrains.kotlin:kotlin-test-junit" + + compile project(':node-api') + compile project(':node') + + compile group: "com.typesafe", name: "config", version: typesafe_config_version + compile group: "com.fasterxml.jackson.dataformat", name: "jackson-dataformat-yaml", version: "2.9.0" + compile group: "com.fasterxml.jackson.core", name: "jackson-databind", version: "2.9.0" + compile "com.fasterxml.jackson.module:jackson-module-kotlin:2.9.+" + compile group: 'info.picocli', name: 'picocli', version: '3.0.1' + + // TornadoFX: A lightweight Kotlin framework for working with JavaFX UI's. + compile "no.tornado:tornadofx:$tornadofx_version" + + // ControlsFX: Extra controls for JavaFX. + compile "org.controlsfx:controlsfx:$controlsfx_version" +} + +shadowJar { + baseName = 'network-bootstrapper' + classifier = null + version = null + zip64 true + mainClassName = 'net.corda.bootstrapper.Main' +} + +task buildNetworkBootstrapper(dependsOn: shadowJar) { +} + +configurations { + compile.exclude group: "log4j", module: "log4j" + compile.exclude group: "org.apache.logging.log4j" +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/java/net/corda/bootstrapper/GuiUtils.java b/tools/network-bootstrapper/src/main/java/net/corda/bootstrapper/GuiUtils.java new file mode 100644 index 0000000000..204a9529fa --- /dev/null +++ b/tools/network-bootstrapper/src/main/java/net/corda/bootstrapper/GuiUtils.java @@ -0,0 +1,48 @@ +package net.corda.bootstrapper; + +import javafx.scene.control.Alert; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.stage.StageStyle; + +import java.io.PrintWriter; +import java.io.StringWriter; + +public class GuiUtils { + + public static void showException(String title, String message, Throwable exception) { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.initStyle(StageStyle.UTILITY); + alert.setTitle("Exception"); + alert.setHeaderText(title); + alert.setContentText(message); + + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + exception.printStackTrace(pw); + String exceptionText = sw.toString(); + + Label label = new Label("Details:"); + + TextArea textArea = new TextArea(exceptionText); + textArea.setEditable(false); + textArea.setWrapText(true); + + textArea.setMaxWidth(Double.MAX_VALUE); + textArea.setMaxHeight(Double.MAX_VALUE); + GridPane.setVgrow(textArea, Priority.ALWAYS); + GridPane.setHgrow(textArea, Priority.ALWAYS); + + GridPane expContent = new GridPane(); + expContent.setMaxWidth(Double.MAX_VALUE); + expContent.add(label, 0, 0); + expContent.add(textArea, 0, 1); + + alert.getDialogPane().setExpandableContent(expContent); + + alert.showAndWait(); + } + +} diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/Constants.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/Constants.kt new file mode 100644 index 0000000000..129d56f25b --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/Constants.kt @@ -0,0 +1,57 @@ +package net.corda.bootstrapper + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.* +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import com.microsoft.azure.management.resources.ResourceGroup +import com.microsoft.azure.management.resources.fluentcore.arm.Region +import java.io.Closeable + +class Constants { + + companion object { + val NODE_P2P_PORT = 10020 + val NODE_SSHD_PORT = 12222 + val NODE_RPC_PORT = 10003 + val NODE_RPC_ADMIN_PORT = 10005 + + val BOOTSTRAPPER_DIR_NAME = ".bootstrapper" + + fun getContextMapper(): ObjectMapper { + val objectMapper = ObjectMapper(YAMLFactory()).registerKotlinModule() + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + objectMapper.registerModule(object : SimpleModule() {}.let { + it.addSerializer(Region::class.java, object : JsonSerializer() { + override fun serialize(value: Region, gen: JsonGenerator, serializers: SerializerProvider?) { + gen.writeString(value.name()) + } + }) + it.addDeserializer(Region::class.java, object : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Region { + return Region.findByLabelOrName(p.valueAsString) + } + }) + }) + return objectMapper + } + + val ALPHA_NUMERIC_ONLY_REGEX = "[^\\p{IsAlphabetic}\\p{IsDigit}]".toRegex() + val ALPHA_NUMERIC_DOT_AND_UNDERSCORE_ONLY_REGEX = "[^\\p{IsAlphabetic}\\p{IsDigit}._]".toRegex() + val REGION_ARG_NAME = "REGION" + + fun ResourceGroup.restFriendlyName(): String { + return this.name().replace(ALPHA_NUMERIC_ONLY_REGEX, "").toLowerCase() + } + } +} + +fun T.useAndClose(block: (T) -> R): R { + return try { + block(this) + } finally { + this.close() + } +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/Main.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/Main.kt new file mode 100644 index 0000000000..eb38bf11cb --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/Main.kt @@ -0,0 +1,34 @@ +@file:JvmName("Main") +package net.corda.bootstrapper + +import javafx.application.Application +import net.corda.bootstrapper.backends.Backend +import net.corda.bootstrapper.backends.Backend.BackendType.AZURE +import net.corda.bootstrapper.cli.AzureParser +import net.corda.bootstrapper.cli.CliParser +import net.corda.bootstrapper.cli.CommandLineInterface +import net.corda.bootstrapper.gui.Gui +import net.corda.bootstrapper.serialization.SerializationEngine +import picocli.CommandLine + +val baseArgs = CliParser() + +fun main(args: Array) { + SerializationEngine.init() + CommandLine(baseArgs).parse(*args) + + if (baseArgs.gui) { + Application.launch(Gui::class.java) + return + } + + val argParser: CliParser = when (baseArgs.backendType) { + AZURE -> { + val azureArgs = AzureParser() + CommandLine(azureArgs).parse(*args) + azureArgs + } + Backend.BackendType.LOCAL_DOCKER -> baseArgs + } + CommandLineInterface().run(argParser) +} diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/NetworkBuilder.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/NetworkBuilder.kt new file mode 100644 index 0000000000..6b4ee0220f --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/NetworkBuilder.kt @@ -0,0 +1,234 @@ +package net.corda.bootstrapper + +import net.corda.bootstrapper.backends.Backend +import net.corda.bootstrapper.context.Context +import net.corda.bootstrapper.nodes.* +import net.corda.bootstrapper.notaries.NotaryCopier +import net.corda.bootstrapper.notaries.NotaryFinder +import java.io.File +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap + +interface NetworkBuilder { + + companion object { + fun instance(): NetworkBuilder { + return NetworkBuilderImpl() + } + } + + fun onNodeLocated(callback: (FoundNode) -> Unit): NetworkBuilder + fun onNodeCopied(callback: (CopiedNode) -> Unit): NetworkBuilder + fun onNodeBuild(callback: (BuiltNode) -> Unit): NetworkBuilder + fun onNodePushed(callback: (PushedNode) -> Unit): NetworkBuilder + fun onNodeInstance(callback: (NodeInstance) -> Unit): NetworkBuilder + + fun withNodeCounts(map: Map): NetworkBuilder + fun withNetworkName(networtName: String): NetworkBuilder + fun withBasedir(baseDir: File): NetworkBuilder + fun withBackend(backendType: Backend.BackendType): NetworkBuilder + fun withBackendOptions(options: Map): NetworkBuilder + + fun build(): CompletableFuture, Context>> + fun onNodeStartBuild(callback: (FoundNode) -> Unit): NetworkBuilder + fun onNodePushStart(callback: (BuiltNode) -> Unit): NetworkBuilder + fun onNodeInstancesRequested(callback: (List) -> Unit): NetworkBuilder + +} + +private class NetworkBuilderImpl : NetworkBuilder { + + + @Volatile + private var onNodeLocatedCallback: ((FoundNode) -> Unit) = {} + @Volatile + private var onNodeCopiedCallback: ((CopiedNode) -> Unit) = {} + @Volatile + private var onNodeBuildStartCallback: (FoundNode) -> Unit = {} + @Volatile + private var onNodeBuiltCallback: ((BuiltNode) -> Unit) = {} + @Volatile + private var onNodePushStartCallback: ((BuiltNode) -> Unit) = {} + @Volatile + private var onNodePushedCallback: ((PushedNode) -> Unit) = {} + @Volatile + private var onNodeInstanceRequestedCallback: (List) -> Unit = {} + @Volatile + private var onNodeInstanceCallback: ((NodeInstance) -> Unit) = {} + + @Volatile + private var nodeCounts = mapOf() + @Volatile + private lateinit var networkName: String + @Volatile + private var workingDir: File? = null + private val cacheDirName = Constants.BOOTSTRAPPER_DIR_NAME + @Volatile + private var backendType = Backend.BackendType.LOCAL_DOCKER + @Volatile + private var backendOptions: Map = mapOf() + + override fun onNodeLocated(callback: (FoundNode) -> Unit): NetworkBuilder { + this.onNodeLocatedCallback = callback + return this + } + + override fun onNodeCopied(callback: (CopiedNode) -> Unit): NetworkBuilder { + this.onNodeCopiedCallback = callback + return this + } + + + override fun onNodeStartBuild(callback: (FoundNode) -> Unit): NetworkBuilder { + this.onNodeBuildStartCallback = callback + return this; + } + + override fun onNodeBuild(callback: (BuiltNode) -> Unit): NetworkBuilder { + this.onNodeBuiltCallback = callback + return this + } + + override fun onNodePushed(callback: (PushedNode) -> Unit): NetworkBuilder { + this.onNodePushedCallback = callback + return this + } + + override fun onNodeInstancesRequested(callback: (List) -> Unit): NetworkBuilder { + this.onNodeInstanceRequestedCallback = callback + return this + } + + override fun onNodeInstance(callback: (NodeInstance) -> Unit): NetworkBuilder { + this.onNodeInstanceCallback = callback; + return this + } + + override fun withNodeCounts(map: Map): NetworkBuilder { + nodeCounts = ConcurrentHashMap(map.entries.map { it.key.toLowerCase() to it.value }.toMap()) + return this + } + + override fun withNetworkName(networtName: String): NetworkBuilder { + this.networkName = networtName + return this + } + + override fun withBasedir(baseDir: File): NetworkBuilder { + this.workingDir = baseDir + return this + } + + override fun withBackend(backendType: Backend.BackendType): NetworkBuilder { + this.backendType = backendType + return this + } + + override fun withBackendOptions(options: Map): NetworkBuilder { + this.backendOptions = HashMap(options) + return this + } + + override fun onNodePushStart(callback: (BuiltNode) -> Unit): NetworkBuilder { + this.onNodePushStartCallback = callback; + return this; + } + + + override fun build(): CompletableFuture, Context>> { + val cacheDir = File(workingDir, cacheDirName) + val baseDir = workingDir!! + val context = Context(networkName, backendType, backendOptions) + if (cacheDir.exists()) cacheDir.deleteRecursively() + val (containerPusher, instantiator, volume) = Backend.fromContext(context, cacheDir) + val nodeFinder = NodeFinder(baseDir) + val notaryFinder = NotaryFinder(baseDir) + val notaryCopier = NotaryCopier(cacheDir) + + val nodeInstantiator = NodeInstantiator(instantiator, context) + val nodeBuilder = NodeBuilder() + val nodeCopier = NodeCopier(cacheDir) + val nodePusher = NodePusher(containerPusher, context) + + val nodeDiscoveryFuture = CompletableFuture.supplyAsync { + val foundNodes = nodeFinder.findNodes() + .map { it to nodeCounts.getOrDefault(it.name.toLowerCase(), 1) } + .toMap() + foundNodes + } + + val notaryDiscoveryFuture = CompletableFuture.supplyAsync { + val copiedNotaries = notaryFinder.findNotaries() + .map { foundNode: FoundNode -> + onNodeBuildStartCallback.invoke(foundNode) + notaryCopier.copyNotary(foundNode) + } + volume.notariesForNetworkParams(copiedNotaries) + copiedNotaries + } + + val notariesFuture = notaryDiscoveryFuture.thenCompose { copiedNotaries -> + copiedNotaries + .map { copiedNotary -> + nodeBuilder.buildNode(copiedNotary).also(onNodeBuiltCallback) + }.map { builtNotary -> + onNodePushStartCallback(builtNotary) + nodePusher.pushNode(builtNotary).thenApply { it.also(onNodePushedCallback) } + }.map { pushedNotary -> + pushedNotary.thenApplyAsync { nodeInstantiator.createInstanceRequest(it).also { onNodeInstanceRequestedCallback.invoke(listOf(it)) } } + }.map { instanceRequest -> + instanceRequest.thenComposeAsync { request -> + nodeInstantiator.instantiateNotaryInstance(request).thenApply { it.also(onNodeInstanceCallback) } + } + }.toSingleFuture() + } + + val nodesFuture = notaryDiscoveryFuture.thenCombineAsync(nodeDiscoveryFuture) { _, nodeCount -> + nodeCount.keys + .map { foundNode -> + nodeCopier.copyNode(foundNode).let { + onNodeCopiedCallback.invoke(it) + it + } + }.map { copiedNode: CopiedNode -> + onNodeBuildStartCallback.invoke(copiedNode) + nodeBuilder.buildNode(copiedNode).let { + onNodeBuiltCallback.invoke(it) + it + } + }.map { builtNode -> + nodePusher.pushNode(builtNode).thenApplyAsync { + onNodePushedCallback.invoke(it) + it + } + }.map { pushedNode -> + pushedNode.thenApplyAsync { + nodeInstantiator.createInstanceRequests(it, nodeCount).also(onNodeInstanceRequestedCallback) + + } + }.map { instanceRequests -> + instanceRequests.thenComposeAsync { requests -> + requests.map { request -> + nodeInstantiator.instantiateNodeInstance(request) + .thenApplyAsync { nodeInstance -> + context.registerNode(nodeInstance) + onNodeInstanceCallback.invoke(nodeInstance) + nodeInstance + } + }.toSingleFuture() + } + }.toSingleFuture() + }.thenCompose { it }.thenApplyAsync { it.flatten() } + + return notariesFuture.thenCombineAsync(nodesFuture, { _, nodeInstances -> + context.networkInitiated = true + nodeInstances to context + }) + } +} + +fun List>.toSingleFuture(): CompletableFuture> { + return CompletableFuture.allOf(*this.toTypedArray()).thenApplyAsync { + this.map { it.getNow(null) } + } +} diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/backends/AzureBackend.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/backends/AzureBackend.kt new file mode 100644 index 0000000000..93a9890d46 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/backends/AzureBackend.kt @@ -0,0 +1,63 @@ +package net.corda.bootstrapper.backends + +import com.microsoft.azure.CloudException +import com.microsoft.azure.credentials.AzureCliCredentials +import com.microsoft.azure.management.Azure +import com.microsoft.rest.LogLevel +import net.corda.bootstrapper.Constants +import net.corda.bootstrapper.containers.instance.azure.AzureInstantiator +import net.corda.bootstrapper.containers.push.azure.AzureContainerPusher +import net.corda.bootstrapper.containers.push.azure.RegistryLocator +import net.corda.bootstrapper.context.Context +import net.corda.bootstrapper.volumes.azure.AzureSmbVolume +import org.slf4j.LoggerFactory +import java.util.concurrent.CompletableFuture + +data class AzureBackend(override val containerPusher: AzureContainerPusher, + override val instantiator: AzureInstantiator, + override val volume: AzureSmbVolume) : Backend { + + companion object { + + val LOG = LoggerFactory.getLogger(AzureBackend::class.java) + + private val azure: Azure = kotlin.run { + Azure.configure() + .withLogLevel(LogLevel.NONE) + .authenticate(AzureCliCredentials.create()) + .withDefaultSubscription() + } + + fun fromContext(context: Context): AzureBackend { + val resourceGroupName = context.networkName.replace(Constants.ALPHA_NUMERIC_DOT_AND_UNDERSCORE_ONLY_REGEX, "") + val resourceGroup = try { + LOG.info("Attempting to find existing resourceGroup with name: $resourceGroupName") + val foundResourceGroup = azure.resourceGroups().getByName(resourceGroupName) + + if (foundResourceGroup == null) { + LOG.info("No existing resourceGroup found creating new resourceGroup with name: $resourceGroupName") + azure.resourceGroups().define(resourceGroupName).withRegion(context.extraParams[Constants.REGION_ARG_NAME]).create() + } else { + LOG.info("Found existing resourceGroup, reusing") + foundResourceGroup + } + } catch (e: CloudException) { + throw RuntimeException(e) + } + + val registryLocatorFuture = CompletableFuture.supplyAsync { + RegistryLocator(azure, resourceGroup) + } + val containerPusherFuture = registryLocatorFuture.thenApplyAsync { + AzureContainerPusher(azure, it.registry) + } + val azureNetworkStore = CompletableFuture.supplyAsync { AzureSmbVolume(azure, resourceGroup) } + val azureInstantiatorFuture = azureNetworkStore.thenCombine(registryLocatorFuture, + { azureVolume, registryLocator -> + AzureInstantiator(azure, registryLocator.registry, azureVolume, resourceGroup) + } + ) + return AzureBackend(containerPusherFuture.get(), azureInstantiatorFuture.get(), azureNetworkStore.get()) + } + } +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/backends/Backend.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/backends/Backend.kt new file mode 100644 index 0000000000..2737411593 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/backends/Backend.kt @@ -0,0 +1,48 @@ +package net.corda.bootstrapper.backends + +import net.corda.bootstrapper.backends.Backend.BackendType.AZURE +import net.corda.bootstrapper.backends.Backend.BackendType.LOCAL_DOCKER +import net.corda.bootstrapper.containers.instance.Instantiator +import net.corda.bootstrapper.containers.push.ContainerPusher +import net.corda.bootstrapper.context.Context +import net.corda.bootstrapper.volumes.Volume +import java.io.File + +interface Backend { + companion object { + fun fromContext(context: Context, baseDir: File): Backend { + return when (context.backendType) { + AZURE -> AzureBackend.fromContext(context) + LOCAL_DOCKER -> DockerBackend.fromContext(context, baseDir) + } + } + } + + val containerPusher: ContainerPusher + val instantiator: Instantiator + val volume: Volume + + enum class BackendType(val displayName: String) { + + AZURE("Azure Containers"), LOCAL_DOCKER("Local Docker"); + + + override fun toString(): String { + return this.displayName + } + + + } + + operator fun component1(): ContainerPusher { + return containerPusher + } + + operator fun component2(): Instantiator { + return instantiator + } + + operator fun component3(): Volume { + return volume + } +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/backends/DockerBackend.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/backends/DockerBackend.kt new file mode 100644 index 0000000000..fab1c3a24f --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/backends/DockerBackend.kt @@ -0,0 +1,25 @@ +package net.corda.bootstrapper.backends + +import net.corda.bootstrapper.containers.instance.docker.DockerInstantiator +import net.corda.bootstrapper.containers.push.docker.DockerContainerPusher +import net.corda.bootstrapper.context.Context +import net.corda.bootstrapper.volumes.docker.LocalVolume +import java.io.File + +class DockerBackend(override val containerPusher: DockerContainerPusher, + override val instantiator: DockerInstantiator, + override val volume: LocalVolume) : Backend { + + + companion object { + fun fromContext(context: Context, baseDir: File): DockerBackend { + val dockerContainerPusher = DockerContainerPusher() + val localVolume = LocalVolume(baseDir, context) + val dockerInstantiator = DockerInstantiator(localVolume, context) + return DockerBackend(dockerContainerPusher, dockerInstantiator, localVolume) + } + } + +} + + diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/cli/CommandLineInterface.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/cli/CommandLineInterface.kt new file mode 100644 index 0000000000..2f0f6e2080 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/cli/CommandLineInterface.kt @@ -0,0 +1,70 @@ +package net.corda.bootstrapper.cli + +import com.fasterxml.jackson.databind.ObjectMapper +import net.corda.bootstrapper.Constants +import net.corda.bootstrapper.NetworkBuilder +import net.corda.bootstrapper.backends.Backend +import net.corda.bootstrapper.context.Context +import net.corda.bootstrapper.nodes.NodeAdder +import net.corda.bootstrapper.nodes.NodeInstantiator +import net.corda.bootstrapper.toSingleFuture +import net.corda.bootstrapper.useAndClose +import net.corda.core.utilities.getOrThrow +import java.io.File + +class CommandLineInterface { + + + fun run(parsedArgs: CliParser) { + val baseDir = parsedArgs.baseDirectory + val cacheDir = File(baseDir, Constants.BOOTSTRAPPER_DIR_NAME) + val networkName = parsedArgs.name ?: "corda-network" + val objectMapper = Constants.getContextMapper() + val contextFile = File(cacheDir, "$networkName.yaml") + if (parsedArgs.isNew()) { + val (_, context) = NetworkBuilder.instance() + .withBasedir(baseDir) + .withNetworkName(networkName) + .withNodeCounts(parsedArgs.nodes) + .onNodeBuild { builtNode -> println("Built node: ${builtNode.name} to image: ${builtNode.localImageId}") } + .onNodePushed { pushedNode -> println("Pushed node: ${pushedNode.name} to: ${pushedNode.remoteImageName}") } + .onNodeInstance { instance -> + println("Instance of ${instance.name} with id: ${instance.nodeInstanceName} on address: " + + "${instance.reachableAddress} {ssh:${instance.portMapping[Constants.NODE_SSHD_PORT]}, " + + "p2p:${instance.portMapping[Constants.NODE_P2P_PORT]}}") + } + .withBackend(parsedArgs.backendType) + .withBackendOptions(parsedArgs.backendOptions()) + .build().getOrThrow() + persistContext(contextFile, objectMapper, context) + } else { + val context = setupContextFromExisting(contextFile, objectMapper, networkName) + val (_, instantiator, _) = Backend.fromContext(context, cacheDir) + val nodeAdder = NodeAdder(context, NodeInstantiator(instantiator, context)) + parsedArgs.nodesToAdd.map { + nodeAdder.addNode(context, Constants.ALPHA_NUMERIC_ONLY_REGEX.replace(it.toLowerCase(), "")) + }.toSingleFuture().getOrThrow() + persistContext(contextFile, objectMapper, context) + } + + } + + private fun setupContextFromExisting(contextFile: File, objectMapper: ObjectMapper, networkName: String): Context { + return contextFile.let { + if (it.exists()) { + it.inputStream().useAndClose { + objectMapper.readValue(it, Context::class.java) + } + } else { + throw IllegalStateException("No existing network context found") + } + } + } + + + private fun persistContext(contextFile: File, objectMapper: ObjectMapper, context: Context?) { + contextFile.outputStream().useAndClose { + objectMapper.writeValue(it, context) + } + } +} diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/cli/CommandParsers.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/cli/CommandParsers.kt new file mode 100644 index 0000000000..982e8146ae --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/cli/CommandParsers.kt @@ -0,0 +1,55 @@ +package net.corda.bootstrapper.cli + +import com.microsoft.azure.management.resources.fluentcore.arm.Region +import net.corda.bootstrapper.Constants +import net.corda.bootstrapper.backends.Backend +import picocli.CommandLine +import picocli.CommandLine.Option +import java.io.File + +open class CliParser { + @Option(names = arrayOf("-n", "--network-name"), description = arrayOf("The resource grouping to use")) + var name: String? = null + + @Option(names = arrayOf("-g", "--gui"), description = arrayOf("Run the graphical user interface")) + var gui = false + + @Option(names = arrayOf("-d", "--nodes-directory"), description = arrayOf("The directory to search for nodes in")) + var baseDirectory = File(System.getProperty("user.dir")) + + @Option(names = arrayOf("-b", "--backend"), description = arrayOf("The backend to use when instantiating nodes")) + var backendType: Backend.BackendType = Backend.BackendType.LOCAL_DOCKER + + @Option(names = arrayOf("--nodes"), split = ":", description = arrayOf("The number of each node to create. NodeX:2 will create two instances of NodeX")) + var nodes: MutableMap = hashMapOf() + + @Option(names = arrayOf("--add", "-a")) + var nodesToAdd: MutableList = arrayListOf() + + fun isNew(): Boolean { + return nodesToAdd.isEmpty() + } + + open fun backendOptions(): Map { + return emptyMap() + } +} + +class AzureParser : CliParser() { + companion object { + val regions = Region.values().map { it.name() to it }.toMap() + } + + @Option(names = arrayOf("-r", "--region"), description = arrayOf("The azure region to use"), converter = arrayOf(RegionConverter::class)) + var region: Region = Region.EUROPE_WEST + + class RegionConverter : CommandLine.ITypeConverter { + override fun convert(value: String): Region { + return regions[value] ?: throw Error("Unknown azure region: $value") + } + } + + override fun backendOptions(): Map { + return mapOf(Constants.REGION_ARG_NAME to region.name()) + } +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/InstanceInfo.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/InstanceInfo.kt new file mode 100644 index 0000000000..0a9c863e0c --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/InstanceInfo.kt @@ -0,0 +1,7 @@ +package net.corda.bootstrapper.containers.instance + +data class InstanceInfo(val groupId: String, + val instanceName: String, + val instanceAddress: String, + val reachableAddress: String, + val portMapping: Map = emptyMap()) \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/Instantiator.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/Instantiator.kt new file mode 100644 index 0000000000..1f7c963115 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/Instantiator.kt @@ -0,0 +1,18 @@ +package net.corda.bootstrapper.containers.instance + +import java.util.concurrent.CompletableFuture + + +interface Instantiator { + fun instantiateContainer(imageId: String, + portsToOpen: List, + instanceName: String, + env: Map? = null): CompletableFuture>> + + + companion object { + val ADDITIONAL_NODE_INFOS_PATH = "/opt/corda/additional-node-infos" + } + + fun getExpectedFQDN(instanceName: String): String +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/azure/AzureInstantiator.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/azure/AzureInstantiator.kt new file mode 100644 index 0000000000..f537a5420a --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/azure/AzureInstantiator.kt @@ -0,0 +1,85 @@ +package net.corda.bootstrapper.containers.instance.azure + +import com.microsoft.azure.management.Azure +import com.microsoft.azure.management.containerinstance.ContainerGroup +import com.microsoft.azure.management.containerinstance.ContainerGroupRestartPolicy +import com.microsoft.azure.management.containerregistry.Registry +import com.microsoft.azure.management.resources.ResourceGroup +import com.microsoft.rest.ServiceCallback +import net.corda.bootstrapper.Constants.Companion.restFriendlyName +import net.corda.bootstrapper.containers.instance.Instantiator +import net.corda.bootstrapper.containers.instance.Instantiator.Companion.ADDITIONAL_NODE_INFOS_PATH +import net.corda.bootstrapper.containers.push.azure.RegistryLocator.Companion.parseCredentials +import net.corda.bootstrapper.volumes.azure.AzureSmbVolume +import org.slf4j.LoggerFactory +import java.util.concurrent.CompletableFuture + +class AzureInstantiator(private val azure: Azure, + private val registry: Registry, + private val azureSmbVolume: AzureSmbVolume, + private val resourceGroup: ResourceGroup +) : Instantiator { + override fun instantiateContainer(imageId: String, + portsToOpen: List, + instanceName: String, + env: Map?): CompletableFuture>> { + + findAndKillExistingContainerGroup(resourceGroup, buildIdent(instanceName)) + + LOG.info("Starting instantiation of container: $instanceName using $imageId") + val registryAddress = registry.loginServerUrl() + val (username, password) = registry.parseCredentials(); + val mountName = "node-setup" + val future = CompletableFuture>>().also { + azure.containerGroups().define(buildIdent(instanceName)) + .withRegion(resourceGroup.region()) + .withExistingResourceGroup(resourceGroup) + .withLinux() + .withPrivateImageRegistry(registryAddress, username, password) + .defineVolume(mountName) + .withExistingReadWriteAzureFileShare(azureSmbVolume.shareName) + .withStorageAccountName(azureSmbVolume.storageAccountName) + .withStorageAccountKey(azureSmbVolume.storageAccountKey) + .attach() + .defineContainerInstance(instanceName) + .withImage(imageId) + .withExternalTcpPorts(*portsToOpen.toIntArray()) + .withVolumeMountSetting(mountName, ADDITIONAL_NODE_INFOS_PATH) + .withEnvironmentVariables(env ?: emptyMap()) + .attach().withRestartPolicy(ContainerGroupRestartPolicy.ON_FAILURE) + .withDnsPrefix(buildIdent(instanceName)) + .createAsync(object : ServiceCallback { + override fun failure(t: Throwable?) { + it.completeExceptionally(t) + } + + override fun success(result: ContainerGroup) { + val fqdn = result.fqdn() + LOG.info("Completed instantiation: $instanceName is running at $fqdn with port(s) $portsToOpen exposed") + it.complete(result.fqdn() to portsToOpen.map { it to it }.toMap()) + } + }) + } + return future + } + + private fun buildIdent(instanceName: String) = "$instanceName-${resourceGroup.restFriendlyName()}" + + override fun getExpectedFQDN(instanceName: String): String { + return "${buildIdent(instanceName)}.${resourceGroup.region().name()}.azurecontainer.io" + } + + fun findAndKillExistingContainerGroup(resourceGroup: ResourceGroup, containerName: String): ContainerGroup? { + val existingContainer = azure.containerGroups().getByResourceGroup(resourceGroup.name(), containerName) + if (existingContainer != null) { + LOG.info("Found an existing instance of: $containerName destroying ContainerGroup") + azure.containerGroups().deleteByResourceGroup(resourceGroup.name(), containerName) + } + return existingContainer; + } + + companion object { + val LOG = LoggerFactory.getLogger(AzureInstantiator::class.java) + } + +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/docker/DockerInstantiator.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/docker/DockerInstantiator.kt new file mode 100644 index 0000000000..0f29838407 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/instance/docker/DockerInstantiator.kt @@ -0,0 +1,99 @@ +package net.corda.bootstrapper.containers.instance.docker + +import com.github.dockerjava.api.model.* +import net.corda.bootstrapper.Constants +import net.corda.bootstrapper.containers.instance.Instantiator +import net.corda.bootstrapper.context.Context +import net.corda.bootstrapper.docker.DockerUtils +import net.corda.bootstrapper.volumes.docker.LocalVolume +import org.slf4j.LoggerFactory +import java.util.concurrent.CompletableFuture + + +class DockerInstantiator(private val volume: LocalVolume, + private val context: Context) : Instantiator { + + val networkId = setupNetwork(); + + override fun instantiateContainer(imageId: String, + portsToOpen: List, + instanceName: String, + env: Map?): CompletableFuture>> { + + val localClient = DockerUtils.createLocalDockerClient() + val convertedEnv = buildDockerEnv(env) + val nodeInfosVolume = Volume(Instantiator.ADDITIONAL_NODE_INFOS_PATH) + val existingContainers = localClient.listContainersCmd().withShowAll(true).exec() + .map { it.names.first() to it } + .filter { it.first.endsWith(instanceName) } + existingContainers.forEach { (_, container) -> + try { + localClient.killContainerCmd(container.id).exec() + LOG.info("Found running container: $instanceName killed") + } catch (e: Throwable) { + //container not running + } + try { + localClient.removeContainerCmd(container.id).exec() + LOG.info("Found existing container: $instanceName removed") + } catch (e: Throwable) { + //this *only* occurs of the container had been previously scheduled for removal + //but did not complete before this attempt was begun. + } + + } + LOG.info("starting local docker instance of: $imageId with name $instanceName and env: $env") + val ports = (portsToOpen + Constants.NODE_RPC_ADMIN_PORT).map { ExposedPort.tcp(it) }.map { PortBinding(null, it) }.let { Ports(*it.toTypedArray()) } + val createCmd = localClient.createContainerCmd(imageId) + .withName(instanceName) + .withVolumes(nodeInfosVolume) + .withBinds(Bind(volume.getPath(), nodeInfosVolume)) + .withPortBindings(ports) + .withExposedPorts(ports.bindings.map { it.key }) + .withPublishAllPorts(true) + .withNetworkMode(networkId) + .withEnv(convertedEnv).exec() + + localClient.startContainerCmd(createCmd.id).exec() + val foundContainer = localClient.listContainersCmd().exec() + .filter { it.id == (createCmd.id) } + .firstOrNull() + + val portMappings = foundContainer?.ports?.map { + (it.privatePort ?: 0) to (it.publicPort ?: 0) + }?.toMap()?.toMap() + ?: portsToOpen.map { it to it }.toMap() + + + + return CompletableFuture.completedFuture(("localhost") to portMappings) + } + + private fun buildDockerEnv(env: Map?) = + (env ?: emptyMap()).entries.map { (key, value) -> "$key=$value" }.toList() + + override fun getExpectedFQDN(instanceName: String): String { + return instanceName + } + + private fun setupNetwork(): String { + val createLocalDockerClient = DockerUtils.createLocalDockerClient() + val existingNetworks = createLocalDockerClient.listNetworksCmd().withNameFilter(context.safeNetworkName).exec() + return if (existingNetworks.isNotEmpty()) { + if (existingNetworks.size > 1) { + throw IllegalStateException("Multiple local docker networks found with name ${context.safeNetworkName}") + } else { + LOG.info("Found existing network with name: ${context.safeNetworkName} reusing") + existingNetworks.first().id + } + } else { + val result = createLocalDockerClient.createNetworkCmd().withName(context.safeNetworkName).exec() + LOG.info("Created local docker network: ${result.id} with name: ${context.safeNetworkName}") + result.id + } + } + + companion object { + val LOG = LoggerFactory.getLogger(DockerInstantiator::class.java) + } +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/ContainerPusher.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/ContainerPusher.kt new file mode 100644 index 0000000000..13714586ac --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/ContainerPusher.kt @@ -0,0 +1,9 @@ +package net.corda.bootstrapper.containers.push + +import java.util.concurrent.CompletableFuture + +interface ContainerPusher { + fun pushContainerToImageRepository(localImageId: String, + remoteImageName: String, + networkName: String): CompletableFuture +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/azure/AzureContainerPusher.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/azure/AzureContainerPusher.kt new file mode 100644 index 0000000000..2c57bb1a2e --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/azure/AzureContainerPusher.kt @@ -0,0 +1,62 @@ +package net.corda.bootstrapper.containers.push.azure + +import com.github.dockerjava.api.async.ResultCallback +import com.github.dockerjava.api.model.PushResponseItem +import com.microsoft.azure.management.Azure +import com.microsoft.azure.management.containerregistry.Registry +import net.corda.bootstrapper.containers.push.ContainerPusher +import net.corda.bootstrapper.containers.push.azure.RegistryLocator.Companion.parseCredentials +import net.corda.bootstrapper.docker.DockerUtils +import org.slf4j.LoggerFactory +import java.io.Closeable +import java.util.concurrent.CompletableFuture + + +class AzureContainerPusher(private val azure: Azure, private val azureRegistry: Registry) : ContainerPusher { + + + override fun pushContainerToImageRepository(localImageId: String, + remoteImageName: String, + networkName: String): CompletableFuture { + + + val (registryUser, registryPassword) = azureRegistry.parseCredentials() + val dockerClient = DockerUtils.createDockerClient( + azureRegistry.loginServerUrl(), + registryUser, + registryPassword) + + val privateRepoUrl = "${azureRegistry.loginServerUrl()}/$remoteImageName".toLowerCase() + dockerClient.tagImageCmd(localImageId, privateRepoUrl, networkName).exec() + val result = CompletableFuture() + dockerClient.pushImageCmd("$privateRepoUrl:$networkName") + .withAuthConfig(dockerClient.authConfig()) + .exec(object : ResultCallback { + override fun onComplete() { + LOG.info("completed PUSH image: $localImageId to registryURL: $privateRepoUrl:$networkName") + result.complete("$privateRepoUrl:$networkName") + } + + override fun close() { + } + + override fun onNext(`object`: PushResponseItem) { + } + + override fun onError(throwable: Throwable?) { + result.completeExceptionally(throwable) + } + + override fun onStart(closeable: Closeable?) { + LOG.info("starting PUSH image: $localImageId to registryURL: $privateRepoUrl:$networkName") + } + }) + return result + } + + companion object { + val LOG = LoggerFactory.getLogger(AzureContainerPusher::class.java) + } + +} + diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/azure/AzureRegistryLocator.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/azure/AzureRegistryLocator.kt new file mode 100644 index 0000000000..f9ee36c259 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/azure/AzureRegistryLocator.kt @@ -0,0 +1,55 @@ +package net.corda.bootstrapper.containers.push.azure + +import com.microsoft.azure.management.Azure +import com.microsoft.azure.management.containerregistry.AccessKeyType +import com.microsoft.azure.management.containerregistry.Registry +import com.microsoft.azure.management.resources.ResourceGroup +import net.corda.bootstrapper.Constants.Companion.restFriendlyName +import net.corda.bootstrapper.containers.instance.azure.AzureInstantiator +import org.slf4j.LoggerFactory + +class RegistryLocator(private val azure: Azure, + private val resourceGroup: ResourceGroup) { + + + val registry: Registry = locateRegistry() + + + private fun locateRegistry(): Registry { + LOG.info("Attempting to find existing registry with name: ${resourceGroup.restFriendlyName()}") + val found = azure.containerRegistries().getByResourceGroup(resourceGroup.name(), resourceGroup.restFriendlyName()) + + if (found == null) { + LOG.info("Did not find existing container registry - creating new registry with name ${resourceGroup.restFriendlyName()}") + return azure.containerRegistries() + .define(resourceGroup.restFriendlyName()) + .withRegion(resourceGroup.region().name()) + .withExistingResourceGroup(resourceGroup) + .withBasicSku() + .withRegistryNameAsAdminUser() + .create() + + } else { + LOG.info("found existing registry with name: ${resourceGroup.restFriendlyName()} reusing") + return found + } + } + + companion object { + fun Registry.parseCredentials(): Pair { + val credentials = this.credentials + return credentials.username() to + (credentials.accessKeys()[AccessKeyType.PRIMARY] + ?: throw IllegalStateException("no registry password found")) + } + + val LOG = LoggerFactory.getLogger(AzureInstantiator::class.java) + + } + + +} + + + + diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/docker/DockerContainerPusher.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/docker/DockerContainerPusher.kt new file mode 100644 index 0000000000..8c53e6edae --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/containers/push/docker/DockerContainerPusher.kt @@ -0,0 +1,15 @@ +package net.corda.bootstrapper.containers.push.docker + +import net.corda.bootstrapper.containers.push.ContainerPusher +import net.corda.bootstrapper.docker.DockerUtils +import java.util.concurrent.CompletableFuture + +class DockerContainerPusher : ContainerPusher { + + + override fun pushContainerToImageRepository(localImageId: String, remoteImageName: String, networkName: String): CompletableFuture { + val dockerClient = DockerUtils.createLocalDockerClient() + dockerClient.tagImageCmd(localImageId, remoteImageName, networkName).withForce().exec() + return CompletableFuture.completedFuture("$remoteImageName:$networkName") + } +} diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/context/Context.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/context/Context.kt new file mode 100644 index 0000000000..8b72714c8d --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/context/Context.kt @@ -0,0 +1,69 @@ +package net.corda.bootstrapper.context + +import net.corda.bootstrapper.Constants +import net.corda.bootstrapper.backends.Backend +import net.corda.bootstrapper.nodes.NodeInstanceRequest +import net.corda.core.identity.CordaX500Name +import org.apache.activemq.artemis.utils.collections.ConcurrentHashSet +import java.util.concurrent.ConcurrentHashMap + +class Context(val networkName: String, val backendType: Backend.BackendType, backendOptions: Map = emptyMap()) { + + + @Volatile + var safeNetworkName: String = networkName.replace(Constants.ALPHA_NUMERIC_ONLY_REGEX, "").toLowerCase() + + @Volatile + var nodes: MutableMap> = ConcurrentHashMap() + + @Volatile + var networkInitiated: Boolean = false + + @Volatile + var extraParams = ConcurrentHashMap(backendOptions) + + private fun registerNode(name: String, nodeInstanceRequest: NodeInstanceRequest) { + nodes.computeIfAbsent(name, { _ -> ConcurrentHashSet() }).add(nodeInstanceRequest.toPersistable()) + } + + fun registerNode(request: NodeInstanceRequest) { + registerNode(request.name, request) + } + + + data class PersistableNodeInstance( + val groupName: String, + val groupX500: CordaX500Name?, + val instanceName: String, + val instanceX500: String, + val localImageId: String?, + val remoteImageName: String, + val rpcPort: Int?, + val fqdn: String, + val rpcUser: String, + val rpcPassword: String) + + + companion object { + fun fromInstanceRequest(nodeInstanceRequest: NodeInstanceRequest): PersistableNodeInstance { + return PersistableNodeInstance( + nodeInstanceRequest.name, + nodeInstanceRequest.nodeConfig.myLegalName, + nodeInstanceRequest.nodeInstanceName, + nodeInstanceRequest.actualX500, + nodeInstanceRequest.localImageId, + nodeInstanceRequest.remoteImageName, + nodeInstanceRequest.nodeConfig.rpcOptions.address!!.port, + nodeInstanceRequest.expectedFqName, + "", + "" + ) + + } + } + + fun NodeInstanceRequest.toPersistable(): PersistableNodeInstance { + return fromInstanceRequest(this) + } +} + diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/docker/DockerUtils.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/docker/DockerUtils.kt new file mode 100644 index 0000000000..f4c5680b75 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/docker/DockerUtils.kt @@ -0,0 +1,35 @@ +package net.corda.bootstrapper.docker + +import com.github.dockerjava.api.DockerClient +import com.github.dockerjava.core.DefaultDockerClientConfig +import com.github.dockerjava.core.DockerClientBuilder +import com.github.dockerjava.core.DockerClientConfig +import org.apache.commons.lang3.SystemUtils + +object DockerUtils { + + @Throws(Exception::class) + fun createDockerClient(registryServerUrl: String, username: String, password: String): DockerClient { + return DockerClientBuilder.getInstance(createDockerClientConfig(registryServerUrl, username, password)) + .build() + } + + fun createLocalDockerClient(): DockerClient { + return if (SystemUtils.IS_OS_WINDOWS) { + DockerClientBuilder.getInstance("tcp://127.0.0.1:2375").build() + } else { + DockerClientBuilder.getInstance().build() + } + } + + private fun createDockerClientConfig(registryServerUrl: String, username: String, password: String): DockerClientConfig { + return DefaultDockerClientConfig.createDefaultConfigBuilder() + .withDockerTlsVerify(false) + .withRegistryUrl(registryServerUrl) + .withRegistryUsername(username) + .withRegistryPassword(password) + .build() + } + + +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/gui/BootstrapperView.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/gui/BootstrapperView.kt new file mode 100644 index 0000000000..c353047c66 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/gui/BootstrapperView.kt @@ -0,0 +1,361 @@ +package net.corda.bootstrapper.gui + +import com.microsoft.azure.management.resources.fluentcore.arm.Region +import javafx.beans.property.SimpleObjectProperty +import javafx.beans.property.SimpleStringProperty +import javafx.collections.ObservableListBase +import javafx.collections.transformation.SortedList +import javafx.event.EventHandler +import javafx.fxml.FXML +import javafx.scene.control.* +import javafx.scene.input.MouseEvent +import javafx.scene.layout.HBox +import javafx.scene.layout.Priority +import javafx.scene.layout.VBox +import javafx.stage.DirectoryChooser +import net.corda.bootstrapper.Constants +import net.corda.bootstrapper.GuiUtils +import net.corda.bootstrapper.NetworkBuilder +import net.corda.bootstrapper.backends.Backend +import net.corda.bootstrapper.context.Context +import net.corda.bootstrapper.nodes.* +import net.corda.bootstrapper.notaries.NotaryFinder +import org.apache.commons.lang3.RandomStringUtils +import org.controlsfx.control.SegmentedButton +import tornadofx.* +import java.io.File +import java.util.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicInteger +import kotlin.Comparator +import kotlin.collections.ArrayList + +class BootstrapperView : View("Corda Network Builder") { + val YAML_MAPPER = Constants.getContextMapper() + override val root: VBox by fxml("/views/mainPane.fxml") + + val controller: State by inject() + + val localDockerBtn: ToggleButton by fxid() + val azureBtn: ToggleButton by fxid() + val nodeTableView: TableView by fxid() + val templateChoiceBox: ChoiceBox by fxid() + val buildButton: Button by fxid() + val addInstanceButton: Button by fxid() + val infoTextArea: TextArea by fxid() + + init { + visuallyTweakBackendSelector() + + buildButton.run { + enableWhen { controller.baseDir.isNotNull } + action { + var networkName = "corda-network" + + val selectedBackEnd = when { + azureBtn.isSelected -> Backend.BackendType.AZURE + localDockerBtn.isSelected -> Backend.BackendType.LOCAL_DOCKER + else -> kotlin.error("Unknown backend selected") + } + + val backendParams = when (selectedBackEnd) { + Backend.BackendType.LOCAL_DOCKER -> { + emptyMap() + } + Backend.BackendType.AZURE -> { + val pair = setupAzureRegionOptions() + networkName = pair.second + pair.first + } + } + + val nodeCount = controller.foundNodes.map { it.id to it.count }.toMap() + val result = NetworkBuilder.instance() + .withBasedir(controller.baseDir.get()) + .withNetworkName(networkName) + .onNodeStartBuild(controller::onBuild) + .onNodeBuild(controller::addBuiltNode) + .onNodePushStart(controller::addBuiltNode) + .onNodePushed(controller::addPushedNode) + .onNodeInstancesRequested(controller::addInstanceRequests) + .onNodeInstance(controller::addInstance) + .withBackend(selectedBackEnd) + .withNodeCounts(nodeCount) + .withBackendOptions(backendParams) + .build() + + result.handle { v, t -> + runLater { + if (t != null) { + GuiUtils.showException("Failed to build network", "Failure due to", t) + } else { + controller.networkContext.set(v.second) + } + } + } + } + } + + templateChoiceBox.run { + enableWhen { controller.networkContext.isNotNull } + controller.networkContext.addListener { _, _, newValue -> + if (newValue != null) { + items = object : ObservableListBase() { + override fun get(index: Int): String { + return controller.foundNodes[index].id + } + + override val size: Int + get() = controller.foundNodes.size + } + selectionModel.select(controller.foundNodes[0].id) + } + } + } + + addInstanceButton.run { + enableWhen { controller.networkContext.isNotNull } + action { + templateChoiceBox.selectionModel.selectedItem?.let { nodeToAdd -> + val context = controller.networkContext.value + runLater { + val (_, instantiator, _) = Backend.fromContext( + context, + File(controller.baseDir.get(), Constants.BOOTSTRAPPER_DIR_NAME)) + val nodeAdder = NodeAdder(context, NodeInstantiator(instantiator, context)) + controller.addInstanceRequest(nodeToAdd) + nodeAdder.addNode(context, nodeToAdd).handleAsync { instanceInfo, t -> + t?.let { + GuiUtils.showException("Failed", "Failed to add node", it) + } + instanceInfo?.let { + runLater { + controller.addInstance(NodeInstanceEntry( + it.groupId, + it.instanceName, + it.instanceAddress, + it.reachableAddress, + it.portMapping[Constants.NODE_P2P_PORT] ?: Constants.NODE_P2P_PORT, + it.portMapping[Constants.NODE_SSHD_PORT] + ?: Constants.NODE_SSHD_PORT)) + } + } + } + } + } + } + } + + nodeTableView.run { + items = controller.sortedNodes + column("ID", NodeTemplateInfo::templateId) + column("Type", NodeTemplateInfo::nodeType) + column("Local Docker Image", NodeTemplateInfo::localDockerImageId) + column("Repository Image", NodeTemplateInfo::repositoryImageId) + column("Status", NodeTemplateInfo::status) + columnResizePolicy = TableView.CONSTRAINED_RESIZE_POLICY + hgrow = Priority.ALWAYS + + onMouseClicked = EventHandler { _ -> + val selectedItem: NodeTemplateInfo = selectionModel.selectedItem ?: return@EventHandler + infoTextArea.text = YAML_MAPPER.writeValueAsString(translateForPrinting(selectedItem)) + } + } + } + + private fun visuallyTweakBackendSelector() { + // The SegmentedButton will jam together the two toggle buttons in a way + // that looks more modern. + val hBox = localDockerBtn.parent as HBox + val idx = hBox.children.indexOf(localDockerBtn) + // Adding this to the hbox will re-parent the two toggle buttons into the + // SegmentedButton control, so we have to put it in the same position as + // the original buttons. Unfortunately it's not so Scene Builder friendly. + hBox.children.add(idx, SegmentedButton(localDockerBtn, azureBtn).apply { + styleClass.add(SegmentedButton.STYLE_CLASS_DARK) + }) + } + + private fun setupAzureRegionOptions(): Pair, String> { + var networkName1 = RandomStringUtils.randomAlphabetic(4) + "-network" + val textInputDialog = TextInputDialog(networkName1) + textInputDialog.title = "Azure Resource Group" + networkName1 = textInputDialog.showAndWait().orElseGet { networkName1 } + return Pair(mapOf(Constants.REGION_ARG_NAME to ChoiceDialog(Region.EUROPE_WEST, Region.values().toList().sortedBy { it.name() }).showAndWait().get().name()), networkName1) + } + + private fun translateForPrinting(selectedItem: NodeTemplateInfo): Any { + return object { + val templateId = selectedItem.templateId.get() + val nodeType = selectedItem.nodeType.get() + val localDockerImageId = selectedItem.localDockerImageId.get() + val repositoryImageId = selectedItem.repositoryImageId.get() + val status = selectedItem.status.get() + val instances = selectedItem.instances.map { it } + } + } + + @FXML + fun onOpenClicked() { + val chooser = DirectoryChooser() + chooser.initialDirectory = File(System.getProperty("user.home")) + val file: File = chooser.showDialog(null) ?: return // Null means user cancelled. + processSelectedDirectory(file) + } + + private fun processSelectedDirectory(dir: File) { + controller.clearAll() + controller.baseDir.set(dir) + val foundNodes = CompletableFuture.supplyAsync { + val nodeFinder = NodeFinder(dir) + nodeFinder.findNodes() + } + val foundNotaries = CompletableFuture.supplyAsync { + val notaryFinder = NotaryFinder(dir) + notaryFinder.findNotaries() + } + foundNodes.thenCombine(foundNotaries) { nodes, notaries -> + notaries to nodes + }.thenAcceptAsync({ (notaries: List, nodes: List) -> + runLater { + controller.foundNodes(nodes) + controller.notaries(notaries) + } + }) + } + + class NodeTemplateInfo(templateId: String, type: NodeType) { + val templateId: SimpleStringProperty = object : SimpleStringProperty(templateId) { + override fun toString(): String { + return this.get()?.toString() ?: "null" + } + } + val nodeType: SimpleObjectProperty = SimpleObjectProperty(type) + val localDockerImageId: SimpleStringProperty = SimpleStringProperty() + val repositoryImageId: SimpleStringProperty = SimpleStringProperty() + val status: SimpleObjectProperty = SimpleObjectProperty(NodeBuildStatus.DISCOVERED) + val instances: MutableList = ArrayList() + val numberOfInstancesWaiting: AtomicInteger = AtomicInteger(-1) + } + + enum class NodeBuildStatus { + DISCOVERED, LOCALLY_BUILDING, LOCALLY_BUILT, REMOTE_PUSHING, REMOTE_PUSHED, INSTANTIATING, INSTANTIATED, + } + + enum class NodeType { + NODE, NOTARY + } + + class State : Controller() { + val foundNodes = Collections.synchronizedList(ArrayList()).observable() + val foundNotaries = Collections.synchronizedList(ArrayList()).observable() + val networkContext = SimpleObjectProperty(null) + + val unsortedNodes = Collections.synchronizedList(ArrayList()).observable() + val sortedNodes = SortedList(unsortedNodes, Comparator { o1, o2 -> + compareValues(o1.nodeType.toString() + o1.templateId, o2.nodeType.toString() + o2.templateId) * -1 + }) + + fun clear() { + networkContext.set(null) + } + + fun clearAll() { + networkContext.set(null) + foundNodes.clear() + foundNotaries.clear() + unsortedNodes.clear() + } + + fun foundNodes(nodesToAdd: List) { + foundNodes.clear() + nodesToAdd.forEach { + runLater { + foundNodes.add(FoundNodeTableEntry(it.name)) + unsortedNodes.add(NodeTemplateInfo(it.name, NodeType.NODE)) + } + } + } + + fun notaries(notaries: List) { + foundNotaries.clear() + notaries.forEach { + runLater { + foundNotaries.add(it) + unsortedNodes.add(NodeTemplateInfo(it.name, NodeType.NOTARY)) + } + } + + } + + var baseDir = SimpleObjectProperty(null) + + fun addBuiltNode(builtNode: BuiltNode) { + runLater { + val foundNode = unsortedNodes.find { it.templateId.get() == builtNode.name } + foundNode?.status?.set(NodeBuildStatus.LOCALLY_BUILT) + foundNode?.localDockerImageId?.set(builtNode.localImageId) + } + } + + fun addPushedNode(pushedNode: PushedNode) { + runLater { + val foundNode = unsortedNodes.find { it.templateId.get() == pushedNode.name } + foundNode?.status?.set(NodeBuildStatus.REMOTE_PUSHED) + foundNode?.repositoryImageId?.set(pushedNode.remoteImageName) + + } + } + + fun onBuild(nodeBuilding: FoundNode) { + val foundNode = unsortedNodes.find { it.templateId.get() == nodeBuilding.name } + foundNode?.status?.set(NodeBuildStatus.LOCALLY_BUILDING) + } + + fun addInstance(nodeInstance: NodeInstance) { + addInstance(NodeInstanceEntry( + nodeInstance.name, + nodeInstance.nodeInstanceName, + nodeInstance.expectedFqName, + nodeInstance.reachableAddress, + nodeInstance.portMapping[Constants.NODE_P2P_PORT] ?: Constants.NODE_P2P_PORT, + nodeInstance.portMapping[Constants.NODE_SSHD_PORT] ?: Constants.NODE_SSHD_PORT) + ) + } + + fun addInstanceRequests(requests: List) { + requests.firstOrNull()?.let { request -> + unsortedNodes.find { it.templateId.get() == request.name }?.let { + it.numberOfInstancesWaiting.set(requests.size) + it.status.set(NodeBuildStatus.INSTANTIATING) + } + } + } + + fun addInstance(nodeInstance: NodeInstanceEntry) { + runLater { + val foundNode = unsortedNodes.find { it.templateId.get() == nodeInstance.id } + foundNode?.instances?.add(nodeInstance) + if (foundNode != null && foundNode.instances.size == foundNode.numberOfInstancesWaiting.get()) { + foundNode.status.set(NodeBuildStatus.INSTANTIATED) + } + } + } + + fun addInstanceRequest(nodeToAdd: String) { + val foundNode = unsortedNodes.find { it.templateId.get() == nodeToAdd } + foundNode?.numberOfInstancesWaiting?.incrementAndGet() + foundNode?.status?.set(NodeBuildStatus.INSTANTIATING) + } + } + + data class NodeInstanceEntry(val id: String, + val nodeInstanceName: String, + val address: String, + val locallyReachableAddress: String, + val rpcPort: Int, + val sshPort: Int) + +} + +data class FoundNodeTableEntry(val id: String, @Volatile var count: Int = 1) \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/gui/Gui.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/gui/Gui.kt new file mode 100644 index 0000000000..841022f502 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/gui/Gui.kt @@ -0,0 +1,11 @@ +package net.corda.bootstrapper.gui + +import javafx.stage.Stage +import tornadofx.* + +class Gui : App(BootstrapperView::class) { + override fun start(stage: Stage) { + super.start(stage) + stage.scene.stylesheets.add("/views/bootstrapper.css") + } +} diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/BuiltNode.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/BuiltNode.kt new file mode 100644 index 0000000000..47515f9dce --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/BuiltNode.kt @@ -0,0 +1,22 @@ +package net.corda.bootstrapper.nodes + +import net.corda.node.services.config.NodeConfiguration +import java.io.File + +open class BuiltNode(configFile: File, baseDirectory: File, + copiedNodeConfig: File, copiedNodeDir: File, + val nodeConfig: NodeConfiguration, val localImageId: String) : CopiedNode(configFile, baseDirectory, copiedNodeConfig, copiedNodeDir) { + + + override fun toString(): String { + return "BuiltNode(" + + "nodeConfig=$nodeConfig," + + "localImageId='$localImageId'" + + ")" + + " ${super.toString()}" + } + + fun toPushedNode(remoteImageName: String): PushedNode { + return PushedNode(configFile, baseDirectory, copiedNodeConfig, copiedNodeDir, nodeConfig, localImageId, remoteImageName) + } +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/CopiedNode.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/CopiedNode.kt new file mode 100644 index 0000000000..6e6c04c67f --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/CopiedNode.kt @@ -0,0 +1,40 @@ +package net.corda.bootstrapper.nodes + +import net.corda.node.services.config.NodeConfiguration +import java.io.File + +open class CopiedNode(configFile: File, baseDirectory: File, + open val copiedNodeConfig: File, open val copiedNodeDir: File) : + FoundNode(configFile, baseDirectory) { + + constructor(foundNode: FoundNode, copiedNodeConfig: File, copiedNodeDir: File) : this( + foundNode.configFile, foundNode.baseDirectory, copiedNodeConfig, copiedNodeDir + ) + + operator fun component4(): File { + return copiedNodeDir; + } + + operator fun component5(): File { + return copiedNodeConfig; + } + + + fun builtNode(nodeConfig: NodeConfiguration, imageId: String): BuiltNode { + return BuiltNode(configFile, baseDirectory, copiedNodeConfig, copiedNodeDir, nodeConfig, imageId) + } + + override fun toString(): String { + return "CopiedNode(" + + "copiedNodeConfig=$copiedNodeConfig," + + "copiedNodeDir=$copiedNodeDir" + + ")" + + " ${super.toString()}" + } + + fun toBuiltNode(nodeConfig: NodeConfiguration, localImageId: String): BuiltNode { + return BuiltNode(this.configFile, this.baseDirectory, this.copiedNodeConfig, this.copiedNodeDir, nodeConfig, localImageId) + } + + +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/FoundNode.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/FoundNode.kt new file mode 100644 index 0000000000..477cbd1b75 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/FoundNode.kt @@ -0,0 +1,53 @@ +package net.corda.bootstrapper.nodes + +import java.io.File + +open class FoundNode(open val configFile: File, + open val baseDirectory: File = configFile.parentFile, + val name: String = configFile.parentFile.name.toLowerCase().replace(net.corda.bootstrapper.Constants.ALPHA_NUMERIC_ONLY_REGEX, "")) { + + + operator fun component1(): File { + return baseDirectory; + } + + operator fun component2(): File { + return configFile; + } + + operator fun component3(): String { + return name; + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FoundNode + + if (configFile != other.configFile) return false + if (baseDirectory != other.baseDirectory) return false + if (name != other.name) return false + + return true + } + + override fun hashCode(): Int { + var result = configFile.hashCode() + result = 31 * result + baseDirectory.hashCode() + result = 31 * result + name.hashCode() + return result + } + + override fun toString(): String { + return "FoundNode(name='$name', configFile=$configFile, baseDirectory=$baseDirectory)" + } + + fun toCopiedNode(copiedNodeConfig: File, copiedNodeDir: File): CopiedNode { + return CopiedNode(this.configFile, this.baseDirectory, copiedNodeConfig, copiedNodeDir) + } + + +} + + diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeAdder.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeAdder.kt new file mode 100644 index 0000000000..4de0d53ebe --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeAdder.kt @@ -0,0 +1,26 @@ +package net.corda.bootstrapper.nodes + +import net.corda.bootstrapper.containers.instance.InstanceInfo +import net.corda.bootstrapper.context.Context +import java.util.concurrent.CompletableFuture + +class NodeAdder(val context: Context, + val nodeInstantiator: NodeInstantiator) { + + fun addNode(context: Context, nodeGroupName: String): CompletableFuture { + return synchronized(context) { + val nodeGroup = context.nodes[nodeGroupName]!! + val nodeInfo = nodeGroup.iterator().next() + val currentNodeSize = nodeGroup.size + val newInstanceX500 = nodeInfo.groupX500!!.copy(commonName = nodeInfo.groupX500.commonName + (currentNodeSize)).toString() + val newInstanceName = nodeGroupName + (currentNodeSize) + val nextNodeInfo = nodeInfo.copy( + instanceX500 = newInstanceX500, + instanceName = newInstanceName, + fqdn = nodeInstantiator.expectedFqdn(newInstanceName) + ) + nodeGroup.add(nextNodeInfo) + nodeInstantiator.instantiateNodeInstance(nextNodeInfo) + } + } +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeBuilder.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeBuilder.kt new file mode 100644 index 0000000000..9b55321cdd --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeBuilder.kt @@ -0,0 +1,48 @@ +package net.corda.bootstrapper.nodes + +import com.github.dockerjava.core.command.BuildImageResultCallback +import com.typesafe.config.Config +import com.typesafe.config.ConfigFactory +import com.typesafe.config.ConfigValueFactory +import net.corda.bootstrapper.docker.DockerUtils +import net.corda.node.services.config.NodeConfiguration +import net.corda.node.services.config.parseAsNodeConfiguration +import org.slf4j.LoggerFactory +import java.io.File + +open class NodeBuilder { + + companion object { + val LOG = LoggerFactory.getLogger(NodeBuilder::class.java) + } + + fun buildNode(copiedNode: CopiedNode): BuiltNode { + val localDockerClient = DockerUtils.createLocalDockerClient() + val copiedNodeConfig = copiedNode.copiedNodeConfig + val nodeDir = copiedNodeConfig.parentFile + if (!copiedNodeConfig.exists()) { + throw IllegalStateException("There is no nodeConfig for dir: " + copiedNodeConfig) + } + val nodeConfig = ConfigFactory.parseFile(copiedNodeConfig) + LOG.info("starting to build docker image for: " + nodeDir) + val nodeImageId = localDockerClient.buildImageCmd() + .withDockerfile(File(nodeDir, "Dockerfile")) + .withBaseDirectory(nodeDir) + .exec(BuildImageResultCallback()).awaitImageId() + LOG.info("finished building docker image for: $nodeDir with id: $nodeImageId") + val config = nodeConfig.parseAsNodeConfigWithFallback(ConfigFactory.parseFile(copiedNode.configFile)) + return copiedNode.builtNode(config, nodeImageId) + } + +} + + +fun Config.parseAsNodeConfigWithFallback(preCopyConfig: Config): NodeConfiguration { + val nodeConfig = this + .withValue("baseDirectory", ConfigValueFactory.fromAnyRef("")) + .withFallback(ConfigFactory.parseResources("reference.conf")) + .withFallback(preCopyConfig) + .resolve() + return nodeConfig.parseAsNodeConfiguration() +} + diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeCopier.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeCopier.kt new file mode 100644 index 0000000000..9d9bca13b3 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeCopier.kt @@ -0,0 +1,102 @@ +package net.corda.bootstrapper.nodes + +import com.typesafe.config.ConfigFactory +import com.typesafe.config.ConfigRenderOptions +import com.typesafe.config.ConfigValue +import net.corda.bootstrapper.useAndClose +import org.slf4j.LoggerFactory +import java.io.File + +open class NodeCopier(private val cacheDir: File) { + + + fun copyNode(foundNode: FoundNode): CopiedNode { + val nodeCacheDir = File(cacheDir, foundNode.baseDirectory.name) + nodeCacheDir.deleteRecursively() + LOG.info("copying: ${foundNode.baseDirectory} to $nodeCacheDir") + foundNode.baseDirectory.copyRecursively(nodeCacheDir, overwrite = true) + copyBootstrapperFiles(nodeCacheDir) + val configInCacheDir = File(nodeCacheDir, "node.conf") + LOG.info("Applying precanned config " + configInCacheDir) + val rpcSettings = getDefaultRpcSettings() + val sshSettings = getDefaultSshSettings(); + mergeConfigs(configInCacheDir, rpcSettings, sshSettings) + return CopiedNode(foundNode, configInCacheDir, nodeCacheDir) + } + + + fun copyBootstrapperFiles(nodeCacheDir: File) { + this.javaClass.classLoader.getResourceAsStream("node-Dockerfile").useAndClose { nodeDockerFileInStream -> + val nodeDockerFile = File(nodeCacheDir, "Dockerfile") + nodeDockerFile.outputStream().useAndClose { nodeDockerFileOutStream -> + nodeDockerFileInStream.copyTo(nodeDockerFileOutStream) + } + } + + this.javaClass.classLoader.getResourceAsStream("run-corda-node.sh").useAndClose { nodeRunScriptInStream -> + val nodeRunScriptFile = File(nodeCacheDir, "run-corda.sh") + nodeRunScriptFile.outputStream().useAndClose { nodeDockerFileOutStream -> + nodeRunScriptInStream.copyTo(nodeDockerFileOutStream) + } + } + + this.javaClass.classLoader.getResourceAsStream("node_info_watcher.sh").useAndClose { nodeRunScriptInStream -> + val nodeInfoWatcherFile = File(nodeCacheDir, "node_info_watcher.sh") + nodeInfoWatcherFile.outputStream().useAndClose { nodeDockerFileOutStream -> + nodeRunScriptInStream.copyTo(nodeDockerFileOutStream) + } + } + } + + internal fun getDefaultRpcSettings(): ConfigValue { + return javaClass + .classLoader + .getResourceAsStream("rpc-settings.conf") + .reader().useAndClose { + ConfigFactory.parseReader(it) + }.getValue("rpcSettings") + } + + internal fun getDefaultSshSettings(): ConfigValue { + return javaClass + .classLoader + .getResourceAsStream("ssh.conf") + .reader().useAndClose { + ConfigFactory.parseReader(it) + }.getValue("sshd") + } + + internal fun mergeConfigs(configInCacheDir: File, + rpcSettings: ConfigValue, + sshSettings: ConfigValue, + mergeMode: Mode = Mode.NODE) { + var trimmedConfig = ConfigFactory.parseFile(configInCacheDir) + .withoutPath("compatibilityZoneURL") + .withValue("rpcSettings", rpcSettings) + .withValue("sshd", sshSettings) + + if (mergeMode == Mode.NODE) { + trimmedConfig = trimmedConfig.withoutPath("p2pAddress") + } + + configInCacheDir.outputStream().useAndClose { + trimmedConfig.root().render(ConfigRenderOptions + .defaults() + .setOriginComments(false) + .setComments(false) + .setFormatted(true) + .setJson(false)).byteInputStream().copyTo(it) + } + } + + + internal enum class Mode { + NOTARY, NODE + } + + + companion object { + val LOG = LoggerFactory.getLogger(NodeCopier::class.java) + } +} + diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeFinder.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeFinder.kt new file mode 100644 index 0000000000..9c2b75d94b --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeFinder.kt @@ -0,0 +1,32 @@ +package net.corda.bootstrapper.nodes + +import com.typesafe.config.ConfigFactory +import net.corda.bootstrapper.Constants +import org.slf4j.LoggerFactory +import java.io.File + +class NodeFinder(private val scratchDir: File) { + + + fun findNodes(): List { + return scratchDir.walkBottomUp().filter { it.name == "node.conf" && !it.absolutePath.contains(Constants.BOOTSTRAPPER_DIR_NAME) }.map { + try { + ConfigFactory.parseFile(it) to it + } catch (t: Throwable) { + null + } + }.filterNotNull() + .filter { !it.first.hasPath("notary") } + .map { (nodeConfig, nodeConfigFile) -> + LOG.info("We've found a node with name: ${nodeConfigFile.parentFile.name}") + FoundNode(nodeConfigFile, nodeConfigFile.parentFile) + }.toList() + + } + + companion object { + val LOG = LoggerFactory.getLogger(NodeFinder::class.java) + } + +} + diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeInstance.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeInstance.kt new file mode 100644 index 0000000000..e6d6a440d3 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeInstance.kt @@ -0,0 +1,31 @@ +package net.corda.bootstrapper.nodes + +import net.corda.node.services.config.NodeConfiguration +import java.io.File + +class NodeInstance(configFile: File, + baseDirectory: File, + copiedNodeConfig: File, + copiedNodeDir: File, + nodeConfig: NodeConfiguration, + localImageId: String, + remoteImageName: String, + nodeInstanceName: String, + actualX500: String, + expectedFqName: String, + val reachableAddress: String, + val portMapping: Map) : + NodeInstanceRequest( + configFile, + baseDirectory, + copiedNodeConfig, + copiedNodeDir, + nodeConfig, + localImageId, + remoteImageName, + nodeInstanceName, + actualX500, + expectedFqName + ) { +} + diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeInstanceRequest.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeInstanceRequest.kt new file mode 100644 index 0000000000..0ecb570352 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeInstanceRequest.kt @@ -0,0 +1,24 @@ +package net.corda.bootstrapper.nodes + +import net.corda.node.services.config.NodeConfiguration +import java.io.File + +open class NodeInstanceRequest(configFile: File, baseDirectory: File, + copiedNodeConfig: File, copiedNodeDir: File, + nodeConfig: NodeConfiguration, localImageId: String, remoteImageName: String, + internal val nodeInstanceName: String, + internal val actualX500: String, + internal val expectedFqName: String) : PushedNode( + configFile, baseDirectory, copiedNodeConfig, copiedNodeDir, nodeConfig, localImageId, remoteImageName +) { + + override fun toString(): String { + return "NodeInstanceRequest(nodeInstanceName='$nodeInstanceName', actualX500='$actualX500', expectedFqName='$expectedFqName') ${super.toString()}" + } + + fun toNodeInstance(reachableAddress: String, portMapping: Map): NodeInstance { + return NodeInstance(configFile, baseDirectory, copiedNodeConfig, copiedNodeDir, nodeConfig, localImageId, remoteImageName, nodeInstanceName, actualX500, expectedFqName, reachableAddress, portMapping) + } + + +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeInstantiator.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeInstantiator.kt new file mode 100644 index 0000000000..1c7f0ac6f6 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodeInstantiator.kt @@ -0,0 +1,95 @@ +package net.corda.bootstrapper.nodes + +import net.corda.bootstrapper.Constants +import net.corda.bootstrapper.containers.instance.InstanceInfo +import net.corda.bootstrapper.containers.instance.Instantiator +import net.corda.bootstrapper.context.Context +import net.corda.core.identity.CordaX500Name +import java.util.concurrent.CompletableFuture + +class NodeInstantiator(val instantiator: Instantiator, + val context: Context) { + + + fun createInstanceRequests(pushedNode: PushedNode, nodeCount: Map): List { + + val namedMap = nodeCount.map { it.key.name.toLowerCase() to it.value }.toMap() + + return (0 until (namedMap[pushedNode.name.toLowerCase()] ?: 1)).map { i -> + createInstanceRequest(pushedNode, i) + } + } + + private fun createInstanceRequest(node: PushedNode, i: Int): NodeInstanceRequest { + val nodeInstanceName = node.name + i + val expectedName = instantiator.getExpectedFQDN(nodeInstanceName) + return node.toNodeInstanceRequest(nodeInstanceName, buildX500(node.nodeConfig.myLegalName, i), expectedName) + } + + fun createInstanceRequest(node: PushedNode): NodeInstanceRequest { + return createInstanceRequest(node, 0) + } + + + private fun buildX500(baseX500: CordaX500Name, i: Int): String { + if (i == 0) { + return baseX500.toString() + } + return baseX500.copy(commonName = (baseX500.commonName ?: "") + i).toString() + } + + fun instantiateNodeInstance(request: Context.PersistableNodeInstance): CompletableFuture { + return instantiateNodeInstance(request.remoteImageName, request.rpcPort!!, request.instanceName, request.fqdn, request.instanceX500).thenApplyAsync { + InstanceInfo(request.groupName, request.instanceName, request.fqdn, it.first, it.second) + } + } + + fun instantiateNodeInstance(request: NodeInstanceRequest): CompletableFuture { + return instantiateNodeInstance(request.remoteImageName, request.nodeConfig.rpcOptions.address!!.port, request.nodeInstanceName, request.expectedFqName, request.actualX500) + .thenApplyAsync { (reachableName, portMapping) -> + request.toNodeInstance(reachableName, portMapping) + } + } + + fun instantiateNotaryInstance(request: NodeInstanceRequest): CompletableFuture { + return instantiateNotaryInstance(request.remoteImageName, request.nodeConfig.rpcOptions.address!!.port, request.nodeInstanceName, request.expectedFqName) + .thenApplyAsync { (reachableName, portMapping) -> + request.toNodeInstance(reachableName, portMapping) + } + } + + private fun instantiateNotaryInstance(remoteImageName: String, + rpcPort: Int, + nodeInstanceName: String, + expectedFqName: String): CompletableFuture>> { + return instantiator.instantiateContainer( + remoteImageName, + listOf(Constants.NODE_P2P_PORT, Constants.NODE_RPC_PORT, Constants.NODE_SSHD_PORT), + nodeInstanceName, + mapOf("OUR_NAME" to expectedFqName, + "OUR_PORT" to Constants.NODE_P2P_PORT.toString()) + ) + } + + private fun instantiateNodeInstance(remoteImageName: String, + rpcPort: Int, + nodeInstanceName: String, + expectedFqName: String, + actualX500: String): CompletableFuture>> { + + return instantiator.instantiateContainer( + remoteImageName, + listOf(Constants.NODE_P2P_PORT, Constants.NODE_RPC_PORT, Constants.NODE_SSHD_PORT), + nodeInstanceName, + mapOf("OUR_NAME" to expectedFqName, + "OUR_PORT" to Constants.NODE_P2P_PORT.toString(), + "X500" to actualX500) + ) + } + + fun expectedFqdn(newInstanceName: String): String { + return instantiator.getExpectedFQDN(newInstanceName) + } + + +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodePusher.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodePusher.kt new file mode 100644 index 0000000000..ecb12b363d --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/NodePusher.kt @@ -0,0 +1,19 @@ +package net.corda.bootstrapper.nodes + +import net.corda.bootstrapper.containers.push.ContainerPusher +import net.corda.bootstrapper.context.Context +import java.util.concurrent.CompletableFuture + +class NodePusher(private val containerPusher: ContainerPusher, + private val context: Context) { + + + fun pushNode(builtNode: BuiltNode): CompletableFuture { + + val localImageId = builtNode.localImageId + val nodeImageIdentifier = "node-${builtNode.name}" + val nodeImageNameFuture = containerPusher.pushContainerToImageRepository(localImageId, + nodeImageIdentifier, context.networkName) + return nodeImageNameFuture.thenApplyAsync { imageName -> builtNode.toPushedNode(imageName) } + } +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/PushedNode.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/PushedNode.kt new file mode 100644 index 0000000000..d75c4390f5 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/nodes/PushedNode.kt @@ -0,0 +1,25 @@ +package net.corda.bootstrapper.nodes + +import net.corda.node.services.config.NodeConfiguration +import java.io.File + +open class PushedNode(configFile: File, baseDirectory: File, + copiedNodeConfig: File, copiedNodeDir: File, + nodeConfig: NodeConfiguration, localImageId: String, val remoteImageName: String) : BuiltNode( + configFile, + baseDirectory, + copiedNodeConfig, + copiedNodeDir, + nodeConfig, + localImageId +) { + fun toNodeInstanceRequest(nodeInstanceName: String, actualX500: String, expectedFqName: String): NodeInstanceRequest { + return NodeInstanceRequest(configFile, baseDirectory, copiedNodeConfig, copiedNodeDir, nodeConfig, localImageId, remoteImageName, nodeInstanceName, actualX500, expectedFqName) + } + + override fun toString(): String { + return "PushedNode(remoteImageName='$remoteImageName') ${super.toString()}" + } + + +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/notaries/CopiedNotary.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/notaries/CopiedNotary.kt new file mode 100644 index 0000000000..f16ef7a70a --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/notaries/CopiedNotary.kt @@ -0,0 +1,14 @@ +package net.corda.bootstrapper.notaries + +import net.corda.bootstrapper.nodes.CopiedNode +import java.io.File + +class CopiedNotary(configFile: File, baseDirectory: File, + copiedNodeConfig: File, copiedNodeDir: File, val nodeInfoFile: File) : + CopiedNode(configFile, baseDirectory, copiedNodeConfig, copiedNodeDir) { +} + + +fun CopiedNode.toNotary(nodeInfoFile: File): CopiedNotary { + return CopiedNotary(this.configFile, this.baseDirectory, this.copiedNodeConfig, this.copiedNodeDir, nodeInfoFile) +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/notaries/NotaryCopier.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/notaries/NotaryCopier.kt new file mode 100644 index 0000000000..ffc949b584 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/notaries/NotaryCopier.kt @@ -0,0 +1,70 @@ +package net.corda.bootstrapper.notaries + +import net.corda.bootstrapper.nodes.CopiedNode +import net.corda.bootstrapper.nodes.FoundNode +import net.corda.bootstrapper.nodes.NodeCopier +import net.corda.bootstrapper.useAndClose +import org.slf4j.LoggerFactory +import java.io.File + +class NotaryCopier(val cacheDir: File) : NodeCopier(cacheDir) { + + fun copyNotary(foundNotary: FoundNode): CopiedNotary { + val nodeCacheDir = File(cacheDir, foundNotary.baseDirectory.name) + nodeCacheDir.deleteRecursively() + LOG.info("copying: ${foundNotary.baseDirectory} to $nodeCacheDir") + foundNotary.baseDirectory.copyRecursively(nodeCacheDir, overwrite = true) + copyNotaryBootstrapperFiles(nodeCacheDir) + val configInCacheDir = File(nodeCacheDir, "node.conf") + LOG.info("Applying precanned config " + configInCacheDir) + val rpcSettings = getDefaultRpcSettings() + val sshSettings = getDefaultSshSettings(); + mergeConfigs(configInCacheDir, rpcSettings, sshSettings, Mode.NOTARY) + val generatedNodeInfo = generateNodeInfo(nodeCacheDir) + return CopiedNode(foundNotary, configInCacheDir, nodeCacheDir).toNotary(generatedNodeInfo) + } + + fun generateNodeInfo(dirToGenerateFrom: File): File { + val nodeInfoGeneratorProcess = ProcessBuilder() + .command(listOf("java", "-jar", "corda.jar", "--just-generate-node-info")) + .directory(dirToGenerateFrom) + .inheritIO() + .start() + + val exitCode = nodeInfoGeneratorProcess.waitFor() + if (exitCode != 0) { + throw IllegalStateException("Failed to generate nodeInfo for notary: " + dirToGenerateFrom) + } + val nodeInfoFile = dirToGenerateFrom.listFiles().filter { it.name.startsWith("nodeInfo") }.single() + return nodeInfoFile; + } + + private fun copyNotaryBootstrapperFiles(nodeCacheDir: File) { + this.javaClass.classLoader.getResourceAsStream("notary-Dockerfile").useAndClose { nodeDockerFileInStream -> + val nodeDockerFile = File(nodeCacheDir, "Dockerfile") + nodeDockerFile.outputStream().useAndClose { nodeDockerFileOutStream -> + nodeDockerFileInStream.copyTo(nodeDockerFileOutStream) + } + } + + this.javaClass.classLoader.getResourceAsStream("run-corda-notary.sh").useAndClose { nodeRunScriptInStream -> + val nodeRunScriptFile = File(nodeCacheDir, "run-corda.sh") + nodeRunScriptFile.outputStream().useAndClose { nodeDockerFileOutStream -> + nodeRunScriptInStream.copyTo(nodeDockerFileOutStream) + } + } + + this.javaClass.classLoader.getResourceAsStream("node_info_watcher.sh").useAndClose { nodeRunScriptInStream -> + val nodeInfoWatcherFile = File(nodeCacheDir, "node_info_watcher.sh") + nodeInfoWatcherFile.outputStream().useAndClose { nodeDockerFileOutStream -> + nodeRunScriptInStream.copyTo(nodeDockerFileOutStream) + } + } + } + + companion object { + val LOG = LoggerFactory.getLogger(NotaryCopier::class.java) + } + + +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/notaries/NotaryFinder.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/notaries/NotaryFinder.kt new file mode 100644 index 0000000000..d4ae23d072 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/notaries/NotaryFinder.kt @@ -0,0 +1,25 @@ +package net.corda.bootstrapper.notaries + +import com.typesafe.config.ConfigFactory +import net.corda.bootstrapper.Constants +import net.corda.bootstrapper.nodes.FoundNode +import java.io.File + +class NotaryFinder(private val dirToSearch: File) { + + fun findNotaries(): List { + return dirToSearch.walkBottomUp().filter { it.name == "node.conf" && !it.absolutePath.contains(Constants.BOOTSTRAPPER_DIR_NAME) } + .map { + try { + ConfigFactory.parseFile(it) to it + } catch (t: Throwable) { + null + } + }.filterNotNull() + .filter { it.first.hasPath("notary") } + .map { (_, nodeConfigFile) -> + FoundNode(nodeConfigFile) + }.toList() + } +} + diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/serialization/SerializationHelper.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/serialization/SerializationHelper.kt new file mode 100644 index 0000000000..f004ee9e24 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/serialization/SerializationHelper.kt @@ -0,0 +1,30 @@ +package net.corda.bootstrapper.serialization + +import net.corda.core.serialization.internal.SerializationEnvironmentImpl +import net.corda.core.serialization.internal.nodeSerializationEnv +import net.corda.nodeapi.internal.serialization.AMQP_P2P_CONTEXT +import net.corda.nodeapi.internal.serialization.AMQP_STORAGE_CONTEXT +import net.corda.nodeapi.internal.serialization.SerializationFactoryImpl +import net.corda.nodeapi.internal.serialization.amqp.AMQPServerSerializationScheme + + +class SerializationEngine { + companion object { + fun init() { + synchronized(this) { + if (nodeSerializationEnv == null) { + val classloader = this.javaClass.classLoader + nodeSerializationEnv = SerializationEnvironmentImpl( + SerializationFactoryImpl().apply { + registerScheme(AMQPServerSerializationScheme(emptyList())) + }, + p2pContext = AMQP_P2P_CONTEXT.withClassLoader(classloader), + rpcServerContext = AMQP_P2P_CONTEXT.withClassLoader(classloader), + storageContext = AMQP_STORAGE_CONTEXT.withClassLoader(classloader), + checkpointContext = AMQP_P2P_CONTEXT.withClassLoader(classloader) + ) + } + } + } + } +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/volumes/Volume.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/volumes/Volume.kt new file mode 100644 index 0000000000..fa514dd168 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/volumes/Volume.kt @@ -0,0 +1,56 @@ +package net.corda.bootstrapper.volumes + +import com.microsoft.azure.storage.file.CloudFile +import com.typesafe.config.ConfigFactory +import net.corda.bootstrapper.notaries.CopiedNotary +import net.corda.bootstrapper.serialization.SerializationEngine +import net.corda.core.node.NetworkParameters +import net.corda.core.node.NotaryInfo +import net.corda.core.serialization.deserialize +import net.corda.nodeapi.internal.DEV_ROOT_CA +import net.corda.nodeapi.internal.SignedNodeInfo +import net.corda.nodeapi.internal.createDevNetworkMapCa +import java.io.File +import java.security.cert.X509Certificate +import java.time.Instant + +interface Volume { + fun notariesForNetworkParams(notaries: List) + + companion object { + init { + SerializationEngine.init() + } + + internal val networkMapCa = createDevNetworkMapCa(DEV_ROOT_CA) + internal val networkMapCert: X509Certificate = networkMapCa.certificate + internal val keyPair = networkMapCa.keyPair + + } + + + fun CloudFile.uploadFromByteArray(array: ByteArray) { + this.uploadFromByteArray(array, 0, array.size) + } + + + fun convertNodeIntoToNetworkParams(notaryFiles: List>): NetworkParameters { + val notaryInfos = notaryFiles.map { (configFile, nodeInfoFile) -> + val validating = ConfigFactory.parseFile(configFile).getConfig("notary").getBoolean("validating") + nodeInfoFile.readBytes().deserialize().verified().let { NotaryInfo(it.legalIdentities.first(), validating) } + } + + return notaryInfos.let { + NetworkParameters( + minimumPlatformVersion = 1, + notaries = it, + maxMessageSize = 10485760, + maxTransactionSize = Int.MAX_VALUE, + modifiedTime = Instant.now(), + epoch = 10, + whitelistedContractImplementations = emptyMap()) + } + } + + +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/volumes/azure/AzureSmbVolume.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/volumes/azure/AzureSmbVolume.kt new file mode 100644 index 0000000000..185868bbf1 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/volumes/azure/AzureSmbVolume.kt @@ -0,0 +1,81 @@ +package net.corda.bootstrapper.volumes.azure + +import com.microsoft.azure.management.Azure +import com.microsoft.azure.management.resources.ResourceGroup +import com.microsoft.azure.management.storage.StorageAccount +import com.microsoft.azure.storage.CloudStorageAccount +import net.corda.bootstrapper.Constants.Companion.restFriendlyName +import net.corda.bootstrapper.notaries.CopiedNotary +import net.corda.bootstrapper.volumes.Volume +import net.corda.bootstrapper.volumes.Volume.Companion.keyPair +import net.corda.bootstrapper.volumes.Volume.Companion.networkMapCert +import net.corda.core.internal.signWithCert +import net.corda.core.serialization.serialize +import net.corda.nodeapi.internal.network.NETWORK_PARAMS_FILE_NAME +import org.slf4j.LoggerFactory + + +class AzureSmbVolume(private val azure: Azure, private val resourceGroup: ResourceGroup) : Volume { + + private val storageAccount = getStorageAccount() + + private val accKeys = storageAccount.keys[0] + + + private val cloudFileShare = CloudStorageAccount.parse( + "DefaultEndpointsProtocol=https;" + + "AccountName=${storageAccount.name()};" + + "AccountKey=${accKeys.value()};" + + "EndpointSuffix=core.windows.net" + ) + .createCloudFileClient() + .getShareReference("nodeinfos") + + val networkParamsFolder = cloudFileShare.rootDirectoryReference.getDirectoryReference("network-params") + val shareName: String = cloudFileShare.name + val storageAccountName: String + get() = resourceGroup.restFriendlyName() + val storageAccountKey: String + get() = accKeys.value() + + + init { + while (true) { + try { + cloudFileShare.createIfNotExists() + networkParamsFolder.createIfNotExists() + break + } catch (e: Throwable) { + LOG.debug("storage account not ready, waiting") + Thread.sleep(5000) + } + } + } + + private fun getStorageAccount(): StorageAccount { + return azure.storageAccounts().getByResourceGroup(resourceGroup.name(), resourceGroup.restFriendlyName()) + ?: azure.storageAccounts().define(resourceGroup.restFriendlyName()) + .withRegion(resourceGroup.region()) + .withExistingResourceGroup(resourceGroup) + .withAccessFromAllNetworks() + .create() + } + + override fun notariesForNetworkParams(notaries: List) { + val networkParamsFile = networkParamsFolder.getFileReference(NETWORK_PARAMS_FILE_NAME) + networkParamsFile.deleteIfExists() + LOG.info("Storing network-params in AzureFile location: " + networkParamsFile.uri) + val networkParameters = convertNodeIntoToNetworkParams(notaries.map { it.configFile to it.nodeInfoFile }) + networkParamsFile.uploadFromByteArray(networkParameters.signWithCert(keyPair.private, networkMapCert).serialize().bytes) + } + + + companion object { + val LOG = LoggerFactory.getLogger(AzureSmbVolume::class.java) + } + +} + + + + diff --git a/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/volumes/docker/LocalVolume.kt b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/volumes/docker/LocalVolume.kt new file mode 100644 index 0000000000..130835b179 --- /dev/null +++ b/tools/network-bootstrapper/src/main/kotlin/net/corda/bootstrapper/volumes/docker/LocalVolume.kt @@ -0,0 +1,39 @@ +package net.corda.bootstrapper.volumes.docker + +import net.corda.bootstrapper.context.Context +import net.corda.bootstrapper.notaries.CopiedNotary +import net.corda.bootstrapper.useAndClose +import net.corda.bootstrapper.volumes.Volume +import net.corda.core.internal.signWithCert +import net.corda.core.serialization.serialize +import net.corda.nodeapi.internal.network.NETWORK_PARAMS_FILE_NAME +import org.slf4j.LoggerFactory +import java.io.File + +class LocalVolume(scratchDir: File, context: Context) : Volume { + + private val networkDir = File(scratchDir, context.safeNetworkName) + private val volumeDir = File(networkDir, "nodeinfos") + private val networkParamsDir = File(volumeDir, "network-params") + + override fun notariesForNetworkParams(notaries: List) { + volumeDir.deleteRecursively() + networkParamsDir.mkdirs() + val networkParameters = convertNodeIntoToNetworkParams(notaries.map { it.configFile to it.nodeInfoFile }) + val networkParamsFile = File(networkParamsDir, NETWORK_PARAMS_FILE_NAME) + networkParamsFile.outputStream().useAndClose { fileOutputStream -> + val serializedNetworkParams = networkParameters.signWithCert(Volume.keyPair.private, Volume.networkMapCert).serialize() + fileOutputStream.write(serializedNetworkParams.bytes) + } + LOG.info("wrote network params to local file: ${networkParamsFile.absolutePath}") + } + + + fun getPath(): String { + return volumeDir.absolutePath + } + + companion object { + val LOG = LoggerFactory.getLogger(LocalVolume::class.java) + } +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/resources/node-Dockerfile b/tools/network-bootstrapper/src/main/resources/node-Dockerfile new file mode 100644 index 0000000000..3ef62097b2 --- /dev/null +++ b/tools/network-bootstrapper/src/main/resources/node-Dockerfile @@ -0,0 +1,37 @@ +# Base image from (http://phusion.github.io/baseimage-docker) +FROM openjdk:8u151-jre-alpine + +RUN apk upgrade --update && \ + apk add --update --no-cache bash iputils && \ + rm -rf /var/cache/apk/* && \ + # Add user to run the app && \ + addgroup corda && \ + adduser -G corda -D -s /bin/bash corda && \ + # Create /opt/corda directory && \ + mkdir -p /opt/corda/plugins && \ + mkdir -p /opt/corda/logs && \ + mkdir -p /opt/corda/additional-node-infos && \ + mkdir -p /opt/node-setup + +# Copy corda files +ADD --chown=corda:corda corda.jar /opt/corda/corda.jar +ADD --chown=corda:corda node.conf /opt/corda/node.conf +ADD --chown=corda:corda cordapps/ /opt/corda/cordapps + +# Copy node info watcher script +ADD --chown=corda:corda node_info_watcher.sh /opt/corda/ + +COPY run-corda.sh /run-corda.sh + +RUN chmod +x /run-corda.sh && \ + chmod +x /opt/corda/node_info_watcher.sh && \ + sync && \ + chown -R corda:corda /opt/corda + +# Working directory for Corda +WORKDIR /opt/corda +ENV HOME=/opt/corda +USER corda + +# Start it +CMD ["/run-corda.sh"] \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/resources/node_info_watcher.sh b/tools/network-bootstrapper/src/main/resources/node_info_watcher.sh new file mode 100755 index 0000000000..a3b6e19387 --- /dev/null +++ b/tools/network-bootstrapper/src/main/resources/node_info_watcher.sh @@ -0,0 +1,12 @@ +#!/bin/bash +while [ 1 -lt 2 ]; do + NODE_INFO=$(ls | grep nodeInfo) + if [ ${#NODE_INFO} -ge 5 ]; then + echo "found nodeInfo copying to additional node node info folder" + cp ${NODE_INFO} additional-node-infos/ + exit 0 + else + echo "no node info found waiting" + fi + sleep 5 +done \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/resources/node_killer.sh b/tools/network-bootstrapper/src/main/resources/node_killer.sh new file mode 100755 index 0000000000..e553d287c5 --- /dev/null +++ b/tools/network-bootstrapper/src/main/resources/node_killer.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +sleep $1 \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/resources/notary-Dockerfile b/tools/network-bootstrapper/src/main/resources/notary-Dockerfile new file mode 100644 index 0000000000..d8a7b8a0a7 --- /dev/null +++ b/tools/network-bootstrapper/src/main/resources/notary-Dockerfile @@ -0,0 +1,39 @@ +# Base image from (http://phusion.github.io/baseimage-docker) +FROM openjdk:8u151-jre-alpine + +RUN apk upgrade --update && \ + apk add --update --no-cache bash iputils && \ + rm -rf /var/cache/apk/* && \ + # Add user to run the app && \ + addgroup corda && \ + adduser -G corda -D -s /bin/bash corda && \ + # Create /opt/corda directory && \ + mkdir -p /opt/corda/plugins && \ + mkdir -p /opt/corda/logs && \ + mkdir -p /opt/corda/additional-node-infos && \ + mkdir -p /opt/node-setup + +# Copy corda files +ADD --chown=corda:corda corda.jar /opt/corda/corda.jar +ADD --chown=corda:corda node.conf /opt/corda/node.conf +ADD --chown=corda:corda cordapps/ /opt/corda/cordapps +ADD --chown=corda:corda certificates/ /opt/corda/certificates +#ADD --chown=corda:corda nodeInfo-* /opt/corda/ + +# Copy node info watcher script +ADD --chown=corda:corda node_info_watcher.sh /opt/corda/ + +COPY run-corda.sh /run-corda.sh + +RUN chmod +x /run-corda.sh && \ + chmod +x /opt/corda/node_info_watcher.sh && \ + sync && \ + chown -R corda:corda /opt/corda + +# Working directory for Corda +WORKDIR /opt/corda +ENV HOME=/opt/corda +USER corda + +# Start it +CMD ["/run-corda.sh"] \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/resources/rpc-settings.conf b/tools/network-bootstrapper/src/main/resources/rpc-settings.conf new file mode 100644 index 0000000000..95c820218f --- /dev/null +++ b/tools/network-bootstrapper/src/main/resources/rpc-settings.conf @@ -0,0 +1,4 @@ +rpcSettings { + address="0.0.0.0:10003" + adminAddress="127.0.0.1:10005" +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/resources/run-corda-node.sh b/tools/network-bootstrapper/src/main/resources/run-corda-node.sh new file mode 100755 index 0000000000..c5d379d260 --- /dev/null +++ b/tools/network-bootstrapper/src/main/resources/run-corda-node.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +: ${CORDA_HOME:=/opt/corda} +: ${JAVA_OPTIONS:=-Xmx512m} +: ${X500? Need a value for the x500 name of this node} +: ${OUR_NAME? Need a value for the expected FQDN of this node} +: ${OUR_PORT? Need a value for the port to bind to} + +export CORDA_HOME JAVA_OPTIONS + +sed -i "/myLegalName/d" node.conf +echo "myLegalName=\"${X500}\"" >> node.conf +echo "p2pAddress=\"${OUR_NAME}:${OUR_PORT}\"" >> node.conf + +cp additional-node-infos/network-params/network-parameters . + +bash node_info_watcher.sh & + +cd ${CORDA_HOME} + +if java ${JAVA_OPTIONS} -jar ${CORDA_HOME}/corda.jar 2>&1 ; then + echo "Corda exited with zero exit code" +else + echo "Corda exited with nonzero exit code, sleeping to allow log collection" + sleep 10000 +fi \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/resources/run-corda-notary.sh b/tools/network-bootstrapper/src/main/resources/run-corda-notary.sh new file mode 100755 index 0000000000..ec8e21d4e7 --- /dev/null +++ b/tools/network-bootstrapper/src/main/resources/run-corda-notary.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +: ${CORDA_HOME:=/opt/corda} +: ${JAVA_OPTIONS:=-Xmx512m} +: ${OUR_NAME? Need a value for the expected FQDN of this node} +: ${OUR_PORT? Need a value for the port to bind to} + +export CORDA_HOME JAVA_OPTIONS +echo "p2pAddress=\"${OUR_NAME}:${OUR_PORT}\"" >> node.conf +cp additional-node-infos/network-params/network-parameters . + +bash node_info_watcher.sh & + +cd ${CORDA_HOME} + + +if java ${JAVA_OPTIONS} -jar ${CORDA_HOME}/corda.jar 2>&1 ; then + echo "Corda exited with zero exit code" +else + echo "Corda exited with nonzero exit code, sleeping to allow log collection" + sleep 10000 +fi + + + diff --git a/tools/network-bootstrapper/src/main/resources/ssh.conf b/tools/network-bootstrapper/src/main/resources/ssh.conf new file mode 100644 index 0000000000..32f59a0321 --- /dev/null +++ b/tools/network-bootstrapper/src/main/resources/ssh.conf @@ -0,0 +1,3 @@ +sshd { + port = 12222 +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/resources/views/bootstrapper.css b/tools/network-bootstrapper/src/main/resources/views/bootstrapper.css new file mode 100644 index 0000000000..b02c3130b5 --- /dev/null +++ b/tools/network-bootstrapper/src/main/resources/views/bootstrapper.css @@ -0,0 +1,13 @@ +.top-pane { + -fx-background-color: white; +} + +.top-pane > .button, .top-pane .toggle-button { + -fx-padding: 15px; + -fx-base: white; +} + +.top-pane > .choice-box { + -fx-padding: 10px; + -fx-base: white; +} \ No newline at end of file diff --git a/tools/network-bootstrapper/src/main/resources/views/cordalogo.png b/tools/network-bootstrapper/src/main/resources/views/cordalogo.png new file mode 100644 index 0000000000000000000000000000000000000000..5c97ca01e922226c238c5d7f0ef8234d7be1a198 GIT binary patch literal 33254 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +