From 3e5aa56fe46cecdc39df090427800e8e2da43d0a Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Mon, 30 Oct 2017 14:29:03 +1000 Subject: [PATCH 01/17] Add initial cluster support --- build.gradle | 5 + client/build.gradle | 18 + .../proxyhook/client/IntegrationTest.kt | 30 +- .../proxyhook/server/ProxyHookServer.kt | 392 +++++++++++------- 4 files changed, 282 insertions(+), 163 deletions(-) diff --git a/build.gradle b/build.gradle index 5efec6b..22392a9 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,11 @@ subprojects { } } + configurations { + all*.exclude group: 'xerces', module: 'xerces' + all*.exclude group: 'xerces', module: 'xercesImpl' + } + } task('setVersionFromBuild') << { diff --git a/client/build.gradle b/client/build.gradle index edf74e7..f5ff7ce 100644 --- a/client/build.gradle +++ b/client/build.gradle @@ -3,6 +3,11 @@ plugins { id 'com.github.johnrengelman.shadow' version '1.2.3' } +sourceSets { + // Just for testSourcesCompile (see below) + testSources +} + dependencies { compile project(':common') testCompile project(':server') @@ -10,6 +15,19 @@ dependencies { testCompile 'org.asynchttpclient:async-http-client:2.1.0-alpha20' testRuntime 'org.slf4j:slf4j-simple:1.7.25' testCompile 'org.mock-server:mockserver-netty:3.11' + // for FakeClusterManager: https://github.com/eclipse/vert.x/issues/2191 + testCompile 'io.vertx:vertx-core:3.5.0:tests' + // Enabling this should cause the IDE to download the source for + // FakeClusterManager. (You'll still have to select Choose Sources on + // FakeClusterManager.class, then browse to the test-sources jar in a + // nearby directory.) +// testSourcesCompile 'io.vertx:vertx-core:3.5.0:test-sources' + +// compile 'io.vertx:vertx-hazelcast:3.5.0' +// compile 'io.vertx:vertx-infinispan:3.5.0' +// // http://vertx.io/docs/vertx-infinispan/java/#_configuring_for_kubernetes_or_openshift_3 +// compile 'org.infinispan:infinispan-cloud:9.1.2.Final' +// compile 'org.jgroups.kubernetes:jgroups-kubernetes:1.0.3.Final' } mainClassName = 'io.vertx.core.Launcher' diff --git a/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt b/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt index 34f45df..591ab24 100644 --- a/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt +++ b/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt @@ -2,6 +2,7 @@ package org.zanata.proxyhook.client import io.vertx.core.Future import io.vertx.core.Vertx +import io.vertx.core.VertxOptions import io.vertx.core.http.HttpServer import io.vertx.core.logging.LoggerFactory import io.vertx.kotlin.coroutines.await @@ -43,20 +44,30 @@ class IntegrationTest { private lateinit var proxyClient: ProxyClient @Before - fun before() { - server = Vertx.vertx() + fun before() = runBlocking { + val serverOpts = VertxOptions().apply { + clusterManager = io.vertx.test.fakecluster.FakeClusterManager() +// clusterManager = io.vertx.ext.cluster.infinispan.InfinispanClusterManager() + clusterHost = "localhost" + clusterPort = 0 + isClustered = true + } + server = awaitResult { + Vertx.clusteredVertx(serverOpts, it) + } webhook = Vertx.vertx() client = Vertx.vertx() } @After - fun after() { + fun after() = runBlocking { // to minimise shutdown errors: // 1. we want the client to stop delivering before the webhook receiver stops // 2. we want the client to disconnect from server before server stops - client.close() - webhook.close() - server.close() + awaitResult { client.close(it) } + awaitResult { webhook.close(it) } + awaitResult { server.close(it) } + Unit } @Test @@ -78,13 +89,6 @@ class IntegrationTest { proxyClient.verifyZeroInteractions() } - @Test - fun subPathDeploymentWithProxy() { - deliverProxiedWebhook(prefix = "/proxyhook", internalHttpProxy = proxyRule.httpPort) -// proxyClient.dumpToLogAsJSON(request()) - proxyClient.verify(request(), once()) - } - private fun ProxyClient.verifyZeroInteractions() { verify(request(), exactly(0)) } diff --git a/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt b/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt index 11e23b6..4956700 100644 --- a/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt +++ b/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt @@ -33,12 +33,13 @@ import io.vertx.core.http.HttpServerRequest import io.vertx.core.http.ServerWebSocket import io.vertx.core.json.JsonObject import io.vertx.core.logging.LoggerFactory +import io.vertx.core.shareddata.AsyncMap +import io.vertx.core.shareddata.Counter import io.vertx.core.shareddata.LocalMap import io.vertx.ext.web.Router import io.vertx.ext.web.RoutingContext import io.vertx.ext.web.handler.BodyHandler import io.vertx.ext.web.handler.ErrorHandler -import org.zanata.proxyhook.common.* import org.zanata.proxyhook.common.Constants.EVENT_ID_HEADERS import org.zanata.proxyhook.common.Constants.MAX_BODY_SIZE import org.zanata.proxyhook.common.Constants.MAX_FRAME_SIZE @@ -82,9 +83,9 @@ import java.net.UnknownHostException */ class ProxyHookServer(val port: Int? = null, val prefix: String = getenv("PROXYHOOK_PREFIX") ?: "", var actualPort: Future? = null) : AbstractVerticle() { - // TODO clustering: should use getClusterWideMap and getCounter - val connections: LocalMap by lazy { - vertx.sharedData().getLocalMap("connections") + // map of websockets which are connected directly to this verticle (not via clustering) + val localConnections: LocalMap by lazy { + vertx.sharedData().getLocalMap("localConnections") } val eventBus: EventBus get() = vertx.eventBus() @@ -96,138 +97,247 @@ class ProxyHookServer(val port: Int? = null, val prefix: String = getenv("PROXYH } else { log.warn("{0} is not set; authentication is disabled", PROXYHOOK_PASSHASH) } - val host = System.getProperty("http.address", "127.0.0.1") + val listenHost = System.getProperty("http.address", "127.0.0.1") val listenPort: Int = port ?: Integer.getInteger("http.port", 8080) - log.info("Starting webhook/websocket server on $host:$listenPort") - - val options = HttpServerOptions() - // 60s timeout based on pings every 50s - .setIdleTimeout(60) - .setMaxWebsocketFrameSize(MAX_FRAME_SIZE) - .setPort(listenPort) - .setHost(host) - val server = vertx.createHttpServer(options) - // a set of textHandlerIds for connected websockets - - vertx.setPeriodic(50_000) { - // TODO clustering: should iterate through websockets of this verticle only (eg a local HashMap?) - connections.keys.forEach { connection -> - - // this is probably the correct way (ping frame triggers pong, closes websocket if no data received before idleTimeout in TCPSSLOptions): - // WebSocketFrameImpl frame = new WebSocketFrameImpl(FrameType.PING, io.netty.buffer.Unpooled.copyLong(System.currentTimeMillis())); - // webSocket.writeFrame(frame); - - val obj = JsonObject() - obj.put(TYPE, PING) - obj.put(PING_ID, System.currentTimeMillis().toString()) - eventBus.send(connection, obj.encode()) - } - } + log.info("Starting webhook/websocket server on $listenHost:$listenPort") - val router = Router.router(vertx) - router.exceptionHandler { t -> log.error("Unhandled exception", t) } - router.route() - .handler(BodyHandler.create().setBodyLimit(MAX_BODY_SIZE.toLong())) - // .handler(LoggerHandler.create()) - .failureHandler(ErrorHandler.create()) - // we need to respond to GET / so that health checks will work: - router.get("$prefix/").handler { routingContext -> routingContext.response().setStatusCode(HTTP_OK).end(APP_NAME + " (" + describe(connections.size) + ")") } - // see https://github.com/vert-x3/vertx-health-check if we need more features - router.get("$prefix/ready").handler(this::readyHandler) - router.post("$prefix/$PATH_WEBHOOK").handler(this::webhookHandler) - server.requestHandler({ router.accept(it) }) - server.websocketHandler { webSocket: ServerWebSocket -> - if (webSocket.path() != "$prefix/$PATH_WEBSOCKET") { - log.warn("wrong path for websocket connection: {0}", webSocket.path()) - webSocket.reject() - return@websocketHandler - } - handleListen(webSocket) - } - server.listen { startupResult -> - if (startupResult.failed()) { - actualPort?.fail(startupResult.cause()) - throw StartupException(startupResult.cause()) - } else { - log.info("Started server on port ${server.actualPort()}") - logEndPoints(server.actualPort()) - actualPort?.complete(server.actualPort()) - } - } - } + val sharedData = vertx.sharedData() - private fun readyHandler(context: RoutingContext) { - context.response() - // if there are no connections, webhooks won't be delivered, thus HTTP_SERVICE_UNAVAILABLE - .setStatusCode(if (connections.isEmpty()) HTTP_SERVICE_UNAVAILABLE else HTTP_OK) - .end(APP_NAME + " (" + describe(connections.size) + ")") - } + sharedData.getCounter("connectionCount", { countRes -> + if (countRes.succeeded()) { + // a counter of websocket connections across the vert.x cluster + val connectionCount: Counter = countRes.result() + sharedData.getClusterWideMap("connections", { connsRes -> + if (connsRes.succeeded()) { + // a map of websocket connections across the vert.x cluster + val connections: AsyncMap = connsRes.result() - private fun webhookHandler(context: RoutingContext) { - log.info("handling POST request") - val req = context.request() - val headers = req.headers() - EVENT_ID_HEADERS - .filter { headers.contains(it) } - .forEach { log.info("{0}: {1}", it, headers.getAll(it)) } - val statusCode: Int - val listeners = connections.keys - log.info("handling POST for {0} listeners", listeners.size) - if (!listeners.isEmpty()) { - val body = context.body - val msgString = encodeWebhook(req, body) - for (connection in listeners) { - eventBus.send(connection, msgString) - } - log.info("Webhook " + req.path() + " received " + body.length() + " bytes. Forwarded to " + describe(listeners.size) + ".") - statusCode = HTTP_OK - } else { - // nothing to do - log.warn("Webhook " + req.path() + " received, but there are no listeners connected.") + fun readyHandler(context: RoutingContext) { + connectionCount.get { res -> + if (res.succeeded()) { + val count: Long = res.result() + context.response() + // if there are no connections, webhooks won't be delivered, thus HTTP_SERVICE_UNAVAILABLE + .setStatusCode(if (count == 0L) HTTP_SERVICE_UNAVAILABLE else HTTP_OK) + .end(APP_NAME + " (" + describe(count) + ")") + } else { + context.response().setStatusCode(HTTP_INTERNAL_SERVER_ERROR).end() + } + } + } - // returning an error should make it easier for client to redeliver later (when there is a listener) - statusCode = HTTP_SERVICE_UNAVAILABLE - } - context.response() - .setStatusCode(statusCode) - .end("Received by " + APP_NAME + " (" + describe(listeners.size) + ")") - } + fun rootHandler(context: RoutingContext) { + connectionCount.get { res -> + if (res.succeeded()) { + val count: Long = res.result() + context.response().setStatusCode(HTTP_OK).end(APP_NAME + " (" + describe(count) + ")") + } else { + context.response().setStatusCode(HTTP_INTERNAL_SERVER_ERROR).end() + } + } + } + + fun webhookHandler(context: RoutingContext) { + log.info("handling POST request") + val req = context.request() + val headers = req.headers() + EVENT_ID_HEADERS + .filter { headers.contains(it) } + .forEach { log.info("{0}: {1}", it, headers.getAll(it)) } + connections.keys { res -> + if (res.succeeded()) { + val listeners = res.result() + log.info("handling POST for {0} listeners", listeners.size) + val statusCode: Int + if (!listeners.isEmpty()) { + val body = context.body + val msgString = encodeWebhook(req, body) + for (connection in listeners) { + eventBus.send(connection, msgString) + } + log.info("Webhook " + req.path() + " received " + body.length() + " bytes. Forwarded to " + describe(listeners.size) + ".") + statusCode = HTTP_OK + } else { + // nothing to do + log.warn("Webhook " + req.path() + " received, but there are no listeners connected.") + + // returning an error should make it easier for client to redeliver later (when there is a listener) + statusCode = HTTP_SERVICE_UNAVAILABLE + } + context.response() + .setStatusCode(statusCode) + .end("Received by " + APP_NAME + " (" + describe(listeners.size) + ")") + } else { + context.response().setStatusCode(HTTP_INTERNAL_SERVER_ERROR).end() + } + } + + } + + fun registerWebsocket(webSocket: ServerWebSocket) { + // TODO enhancement: register specific webhook path using webSocket.path() or webSocket.query() + val id = webSocket.textHandlerID() + val clientIP = getClientIP(webSocket) + log.info("Adding connection. ID: $id IP: $clientIP") + localConnections.put(id, true) + connections.put(id, true, { res -> + if (res.succeeded()) { + log.info("Connection registered with cluster") + } else { + log.error("error", res.cause()) + } + }) + connectionCount.incrementAndGet { res -> + if (res.succeeded()) { + log.info("New connection counted: cluster has {0} connections", res.result()) + } else { + log.error("error", res.cause()) + } + } + log.info("Total local connections: {0}", localConnections.size) + webSocket.closeHandler { + log.info("Connection closed. ID: {0} IP: {1}", id, clientIP) + localConnections.remove(id) + connections.remove(id, { res -> + if (res.succeeded()) { + log.info("Connection removed from cluster") + } else { + log.error("error", res.cause()) + } + }) + connectionCount.decrementAndGet() { res -> + if (res.succeeded()) { + log.info("Closed connection counted: cluster has {0} connections", res.result()) + } else { + log.error("error", res.cause()) + } + } + log.info("Total local connections: {0}", localConnections.size) + } + webSocket.exceptionHandler { e -> + log.warn("Connection error. ID: {0} IP: {1}", e, id, clientIP) + localConnections.remove(id) + connections.remove(id, { res -> + if (res.succeeded()) { + log.info("Broken connection removed from cluster") + } else { + log.error("error", res.cause()) + } + }) + connectionCount.decrementAndGet() { res -> + if (res.succeeded()) { + log.info("Broken connection counted: cluster has {0} connections", res.result()) + } else { + log.error("error", res.cause()) + } + } + log.info("Total local connections: {0}", localConnections.size) + } + } - private fun handleListen(webSocket: ServerWebSocket) { - webSocket.handler { buffer: Buffer -> - val msg = buffer.toJsonObject() - val messageType = MessageType.valueOf(msg.getString(TYPE)) - when (messageType) { - LOGIN -> handleLogin(msg, webSocket) - PING -> handlePing(msg, webSocket) - PONG -> handlePong(msg) - else -> handleUnknownMessage(msg, webSocket) + fun handleLogin(msg: JsonObject, webSocket: ServerWebSocket) { + val password = msg.getString(PASSWORD) + if (passhash == null) { + log.info("unverified websocket connection") + val obj = JsonObject() + obj.put(TYPE, SUCCESS) + webSocket.writeTextMessage(obj.encode()) + registerWebsocket(webSocket) + } else if (BCrypt.checkpw(password, passhash)) { + log.info("password accepted") + val obj = JsonObject() + obj.put(TYPE, SUCCESS) + webSocket.writeTextMessage(obj.encode()) + registerWebsocket(webSocket) + } else { + log.warn("password rejected") + val obj = JsonObject() + obj.put(TYPE, FAILED) + webSocket.writeTextMessage(obj.encode()) + webSocket.close() + } + } + + fun handleListen(webSocket: ServerWebSocket) { + webSocket.handler { buffer: Buffer -> + val msg = buffer.toJsonObject() + val messageType = MessageType.valueOf(msg.getString(TYPE)) + when (messageType) { + LOGIN -> handleLogin(msg, webSocket) + PING -> handlePing(msg, webSocket) + PONG -> handlePong(msg) + else -> handleUnknownMessage(msg, webSocket) + } + } + } + + val options = HttpServerOptions().apply { + // 60s timeout based on pings every 50s + idleTimeout = 60 + maxWebsocketFrameSize = MAX_FRAME_SIZE + host = listenHost + port = listenPort + } + val server = vertx.createHttpServer(options) + // a set of textHandlerIds for connected websockets + + vertx.setPeriodic(50_000) { + // in a cluster, we should only ping websockets connected to this verticle directly + localConnections.keys.forEach { connection -> + + // this is probably the correct way (ping frame triggers pong, closes websocket if no data received before idleTimeout in TCPSSLOptions): + // WebSocketFrameImpl frame = new WebSocketFrameImpl(FrameType.PING, io.netty.buffer.Unpooled.copyLong(System.currentTimeMillis())); + // webSocket.writeFrame(frame); + + val obj = JsonObject() + obj.put(TYPE, PING) + obj.put(PING_ID, System.currentTimeMillis().toString()) + eventBus.send(connection, obj.encode()) + } + } + + val router = Router.router(vertx) + router.exceptionHandler { t -> log.error("Unhandled exception", t) } + router.route() + .handler(BodyHandler.create().setBodyLimit(MAX_BODY_SIZE.toLong())) + // .handler(LoggerHandler.create()) + .failureHandler(ErrorHandler.create()) + // we need to respond to GET / so that health checks will work: + router.get("$prefix/").handler(::rootHandler) + // see https://github.com/vert-x3/vertx-health-check if we need more features + router.get("$prefix/ready").handler(::readyHandler) + router.post("$prefix/$PATH_WEBHOOK").handler(::webhookHandler) + server.requestHandler({ router.accept(it) }) + server.websocketHandler { webSocket: ServerWebSocket -> + if (webSocket.path() != "$prefix/$PATH_WEBSOCKET") { + log.warn("wrong path for websocket connection: {0}", webSocket.path()) + webSocket.reject() + return@websocketHandler + } + handleListen(webSocket) + } + server.listen { startupResult -> + if (startupResult.failed()) { + actualPort?.fail(startupResult.cause()) + throw StartupException(startupResult.cause()) + } else { + log.info("Started server on port ${server.actualPort()}") + logEndPoints(server.actualPort()) + actualPort?.complete(server.actualPort()) + } + } + } else { + // Something went wrong! + actualPort?.fail(connsRes.cause()) + } + }) + } else { + // Something went wrong! + actualPort?.fail(countRes.cause()) } - } + }) } - private fun handleLogin(msg: JsonObject, webSocket: ServerWebSocket) { - val password = msg.getString(PASSWORD) - if (passhash == null) { - log.info("unverified websocket connection") - val obj = JsonObject() - obj.put(TYPE, SUCCESS) - webSocket.writeTextMessage(obj.encode()) - registerWebsocket(connections, webSocket) - } else if (BCrypt.checkpw(password, passhash)) { - log.info("password accepted") - val obj = JsonObject() - obj.put(TYPE, SUCCESS) - webSocket.writeTextMessage(obj.encode()) - registerWebsocket(connections, webSocket) - } else { - log.warn("password rejected") - val obj = JsonObject() - obj.put(TYPE, FAILED) - webSocket.writeTextMessage(obj.encode()) - webSocket.close() - } - } private fun handlePing(msg: JsonObject, webSocket: ServerWebSocket) { val pingId = msg.getString(PING_ID) @@ -275,26 +385,6 @@ class ProxyHookServer(val port: Int? = null, val prefix: String = getenv("PROXYH } } - private fun registerWebsocket(connections: LocalMap, - webSocket: ServerWebSocket) { - // TODO enhancement: register specific webhook path using webSocket.path() or webSocket.query() - val id = webSocket.textHandlerID() - val clientIP = getClientIP(webSocket) - log.info("Adding connection. ID: $id IP: $clientIP") - connections.put(id, true) - log.info("Total connections: {0}", connections.size) - webSocket.closeHandler { - log.info("Connection closed. ID: {0} IP: {1}", id, clientIP) - connections.remove(id) - log.info("Total connections: {0}", connections.size) - } - webSocket.exceptionHandler { e -> - log.warn("Connection error. ID: {0} IP: {1}", e, id, clientIP) - connections.remove(id) - log.info("Total connections: {0}", connections.size) - } - } - private fun getClientIP(webSocket: ServerWebSocket): String { var clientIP: String? = webSocket.headers().get("X-Client-Ip") if (clientIP == null) clientIP = webSocket.headers().get("X-Forwarded-For") @@ -351,14 +441,16 @@ class ProxyHookServer(val port: Int? = null, val prefix: String = getenv("PROXYH // HTTP status codes private val HTTP_OK = 200 // private static final int HTTP_NO_CONTENT = 204; - // private static final int HTTP_INTERNAL_SERVER_ERROR = 500; + private val HTTP_INTERNAL_SERVER_ERROR = 500; // private static final int HTTP_NOT_IMPLEMENTED = 501; // private static final int HTTP_BAD_GATEWAY = 502; private val HTTP_SERVICE_UNAVAILABLE = 503 // private static final int HTTP_GATEWAY_TIMEOUT = 504; - internal fun describe(size: Int): String { - if (size == 1) { + internal fun describe(size: Int): String = describe(size.toLong()) + + internal fun describe(size: Long): String { + if (size == 1L) { return "1 listener" } else { return "" + size + " listeners" From fe9a380dcfc601ffe1e5f55eaba7bccfee860d1b Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Tue, 31 Oct 2017 16:41:45 +1000 Subject: [PATCH 02/17] Disable coroutine compiler warnings --- build.gradle | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/build.gradle b/build.gradle index 22392a9..c028f0a 100644 --- a/build.gradle +++ b/build.gradle @@ -20,6 +20,12 @@ subprojects { sourceCompatibility = '1.8' + kotlin { + experimental { + coroutines "enable" + } + } + dependencies { compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version" compile 'io.vertx:vertx-core:3.5.0' From 304adfbdf7144e5f5c11820a2ae828e47f951af5 Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Mon, 30 Oct 2017 17:47:54 +1000 Subject: [PATCH 03/17] Convert ProxyHookServer to coroutines --- build.gradle | 2 +- .../proxyhook/server/ProxyHookServer.kt | 653 +++++++++--------- .../proxyhook/server/ProxyHookServerTest.kt | 16 +- 3 files changed, 336 insertions(+), 335 deletions(-) diff --git a/build.gradle b/build.gradle index c028f0a..eed9025 100644 --- a/build.gradle +++ b/build.gradle @@ -31,7 +31,7 @@ subprojects { compile 'io.vertx:vertx-core:3.5.0' compile 'io.vertx:vertx-web:3.5.0' // testCompile 'io.vertx:vertx-unit:3.5.0' - testCompile 'io.vertx:vertx-lang-kotlin-coroutines:3.5.0' + compile 'io.vertx:vertx-lang-kotlin-coroutines:3.5.0' testCompile 'junit:junit:4.12' testCompile 'net.wuerl.kotlin:assertj-core-kotlin:0.2.1' } diff --git a/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt b/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt index 4956700..123028c 100644 --- a/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt +++ b/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt @@ -20,7 +20,6 @@ */ package org.zanata.proxyhook.server -import io.vertx.core.AbstractVerticle import io.vertx.core.Future import io.vertx.core.Vertx import org.mindrot.jbcrypt.BCrypt @@ -31,15 +30,21 @@ import io.vertx.core.http.HttpHeaders import io.vertx.core.http.HttpServerOptions import io.vertx.core.http.HttpServerRequest import io.vertx.core.http.ServerWebSocket +import io.vertx.core.http.WebSocketBase import io.vertx.core.json.JsonObject import io.vertx.core.logging.LoggerFactory import io.vertx.core.shareddata.AsyncMap import io.vertx.core.shareddata.Counter import io.vertx.core.shareddata.LocalMap +import io.vertx.ext.web.Route import io.vertx.ext.web.Router import io.vertx.ext.web.RoutingContext import io.vertx.ext.web.handler.BodyHandler import io.vertx.ext.web.handler.ErrorHandler +import io.vertx.kotlin.coroutines.CoroutineVerticle +import io.vertx.kotlin.coroutines.awaitResult +import io.vertx.kotlin.coroutines.dispatcher +import kotlinx.coroutines.experimental.launch import org.zanata.proxyhook.common.Constants.EVENT_ID_HEADERS import org.zanata.proxyhook.common.Constants.MAX_BODY_SIZE import org.zanata.proxyhook.common.Constants.MAX_FRAME_SIZE @@ -81,17 +86,42 @@ import java.net.UnknownHostException * when deployment is complete. * @author Sean Flanigan [sflaniga@redhat.com](mailto:sflaniga@redhat.com) */ -class ProxyHookServer(val port: Int? = null, val prefix: String = getenv("PROXYHOOK_PREFIX") ?: "", var actualPort: Future? = null) : AbstractVerticle() { +class ProxyHookServer( + private val port: Int? = null, + private val prefix: String = getenv("PROXYHOOK_PREFIX") ?: "", + var actualPort: Future? = null) : CoroutineVerticle() { + companion object { + @JvmStatic fun main(args: Array) { + Vertx.vertx().deployVerticle(ProxyHookServer(port = null), { result -> + result.otherwise { e -> + exit(e) + } + }) + } + } + private inner class ConnectionManager(val connections: AsyncMap, val connectionCount: Counter) + + private val eventBus: EventBus get() = vertx.eventBus() + private val passhash: String? = getenv(PROXYHOOK_PASSHASH) // map of websockets which are connected directly to this verticle (not via clustering) - val localConnections: LocalMap by lazy { + // TODO a plain local HashMap might be more appropriate + private val localConnections: LocalMap by lazy { vertx.sharedData().getLocalMap("localConnections") } - val eventBus: EventBus get() = vertx.eventBus() + private lateinit var manager: ConnectionManager - val passhash: String? = getenv(PROXYHOOK_PASSHASH) + suspend override fun start() { + // a counter of websocket connections across the vert.x cluster + val connectionCount: Counter = awaitResult { + vertx.sharedData().getCounter("connectionCount", it) + } + // a map of websocket connections across the vert.x cluster + val connections: AsyncMap = awaitResult { + vertx.sharedData().getClusterWideMap("connections", it) + } + this.manager = ConnectionManager(connections, connectionCount) - override fun start() { if (passhash != null) { log.info("password is set") } else { @@ -101,243 +131,147 @@ class ProxyHookServer(val port: Int? = null, val prefix: String = getenv("PROXYH val listenPort: Int = port ?: Integer.getInteger("http.port", 8080) log.info("Starting webhook/websocket server on $listenHost:$listenPort") - val sharedData = vertx.sharedData() - - sharedData.getCounter("connectionCount", { countRes -> - if (countRes.succeeded()) { - // a counter of websocket connections across the vert.x cluster - val connectionCount: Counter = countRes.result() - sharedData.getClusterWideMap("connections", { connsRes -> - if (connsRes.succeeded()) { - // a map of websocket connections across the vert.x cluster - val connections: AsyncMap = connsRes.result() - - fun readyHandler(context: RoutingContext) { - connectionCount.get { res -> - if (res.succeeded()) { - val count: Long = res.result() - context.response() - // if there are no connections, webhooks won't be delivered, thus HTTP_SERVICE_UNAVAILABLE - .setStatusCode(if (count == 0L) HTTP_SERVICE_UNAVAILABLE else HTTP_OK) - .end(APP_NAME + " (" + describe(count) + ")") - } else { - context.response().setStatusCode(HTTP_INTERNAL_SERVER_ERROR).end() - } - } - } - - fun rootHandler(context: RoutingContext) { - connectionCount.get { res -> - if (res.succeeded()) { - val count: Long = res.result() - context.response().setStatusCode(HTTP_OK).end(APP_NAME + " (" + describe(count) + ")") - } else { - context.response().setStatusCode(HTTP_INTERNAL_SERVER_ERROR).end() - } - } - } - - fun webhookHandler(context: RoutingContext) { - log.info("handling POST request") - val req = context.request() - val headers = req.headers() - EVENT_ID_HEADERS - .filter { headers.contains(it) } - .forEach { log.info("{0}: {1}", it, headers.getAll(it)) } - connections.keys { res -> - if (res.succeeded()) { - val listeners = res.result() - log.info("handling POST for {0} listeners", listeners.size) - val statusCode: Int - if (!listeners.isEmpty()) { - val body = context.body - val msgString = encodeWebhook(req, body) - for (connection in listeners) { - eventBus.send(connection, msgString) - } - log.info("Webhook " + req.path() + " received " + body.length() + " bytes. Forwarded to " + describe(listeners.size) + ".") - statusCode = HTTP_OK - } else { - // nothing to do - log.warn("Webhook " + req.path() + " received, but there are no listeners connected.") - - // returning an error should make it easier for client to redeliver later (when there is a listener) - statusCode = HTTP_SERVICE_UNAVAILABLE - } - context.response() - .setStatusCode(statusCode) - .end("Received by " + APP_NAME + " (" + describe(listeners.size) + ")") - } else { - context.response().setStatusCode(HTTP_INTERNAL_SERVER_ERROR).end() - } - } - - } - - fun registerWebsocket(webSocket: ServerWebSocket) { - // TODO enhancement: register specific webhook path using webSocket.path() or webSocket.query() - val id = webSocket.textHandlerID() - val clientIP = getClientIP(webSocket) - log.info("Adding connection. ID: $id IP: $clientIP") - localConnections.put(id, true) - connections.put(id, true, { res -> - if (res.succeeded()) { - log.info("Connection registered with cluster") - } else { - log.error("error", res.cause()) - } - }) - connectionCount.incrementAndGet { res -> - if (res.succeeded()) { - log.info("New connection counted: cluster has {0} connections", res.result()) - } else { - log.error("error", res.cause()) - } - } - log.info("Total local connections: {0}", localConnections.size) - webSocket.closeHandler { - log.info("Connection closed. ID: {0} IP: {1}", id, clientIP) - localConnections.remove(id) - connections.remove(id, { res -> - if (res.succeeded()) { - log.info("Connection removed from cluster") - } else { - log.error("error", res.cause()) - } - }) - connectionCount.decrementAndGet() { res -> - if (res.succeeded()) { - log.info("Closed connection counted: cluster has {0} connections", res.result()) - } else { - log.error("error", res.cause()) - } - } - log.info("Total local connections: {0}", localConnections.size) - } - webSocket.exceptionHandler { e -> - log.warn("Connection error. ID: {0} IP: {1}", e, id, clientIP) - localConnections.remove(id) - connections.remove(id, { res -> - if (res.succeeded()) { - log.info("Broken connection removed from cluster") - } else { - log.error("error", res.cause()) - } - }) - connectionCount.decrementAndGet() { res -> - if (res.succeeded()) { - log.info("Broken connection counted: cluster has {0} connections", res.result()) - } else { - log.error("error", res.cause()) - } - } - log.info("Total local connections: {0}", localConnections.size) - } - } - - fun handleLogin(msg: JsonObject, webSocket: ServerWebSocket) { - val password = msg.getString(PASSWORD) - if (passhash == null) { - log.info("unverified websocket connection") - val obj = JsonObject() - obj.put(TYPE, SUCCESS) - webSocket.writeTextMessage(obj.encode()) - registerWebsocket(webSocket) - } else if (BCrypt.checkpw(password, passhash)) { - log.info("password accepted") - val obj = JsonObject() - obj.put(TYPE, SUCCESS) - webSocket.writeTextMessage(obj.encode()) - registerWebsocket(webSocket) - } else { - log.warn("password rejected") - val obj = JsonObject() - obj.put(TYPE, FAILED) - webSocket.writeTextMessage(obj.encode()) - webSocket.close() - } - } - - fun handleListen(webSocket: ServerWebSocket) { - webSocket.handler { buffer: Buffer -> - val msg = buffer.toJsonObject() - val messageType = MessageType.valueOf(msg.getString(TYPE)) - when (messageType) { - LOGIN -> handleLogin(msg, webSocket) - PING -> handlePing(msg, webSocket) - PONG -> handlePong(msg) - else -> handleUnknownMessage(msg, webSocket) - } - } - } - - val options = HttpServerOptions().apply { - // 60s timeout based on pings every 50s - idleTimeout = 60 - maxWebsocketFrameSize = MAX_FRAME_SIZE - host = listenHost - port = listenPort - } - val server = vertx.createHttpServer(options) - // a set of textHandlerIds for connected websockets - - vertx.setPeriodic(50_000) { - // in a cluster, we should only ping websockets connected to this verticle directly - localConnections.keys.forEach { connection -> - - // this is probably the correct way (ping frame triggers pong, closes websocket if no data received before idleTimeout in TCPSSLOptions): - // WebSocketFrameImpl frame = new WebSocketFrameImpl(FrameType.PING, io.netty.buffer.Unpooled.copyLong(System.currentTimeMillis())); - // webSocket.writeFrame(frame); - - val obj = JsonObject() - obj.put(TYPE, PING) - obj.put(PING_ID, System.currentTimeMillis().toString()) - eventBus.send(connection, obj.encode()) - } - } - - val router = Router.router(vertx) - router.exceptionHandler { t -> log.error("Unhandled exception", t) } - router.route() - .handler(BodyHandler.create().setBodyLimit(MAX_BODY_SIZE.toLong())) - // .handler(LoggerHandler.create()) - .failureHandler(ErrorHandler.create()) - // we need to respond to GET / so that health checks will work: - router.get("$prefix/").handler(::rootHandler) - // see https://github.com/vert-x3/vertx-health-check if we need more features - router.get("$prefix/ready").handler(::readyHandler) - router.post("$prefix/$PATH_WEBHOOK").handler(::webhookHandler) - server.requestHandler({ router.accept(it) }) - server.websocketHandler { webSocket: ServerWebSocket -> - if (webSocket.path() != "$prefix/$PATH_WEBSOCKET") { - log.warn("wrong path for websocket connection: {0}", webSocket.path()) - webSocket.reject() - return@websocketHandler - } - handleListen(webSocket) - } - server.listen { startupResult -> - if (startupResult.failed()) { - actualPort?.fail(startupResult.cause()) - throw StartupException(startupResult.cause()) - } else { - log.info("Started server on port ${server.actualPort()}") - logEndPoints(server.actualPort()) - actualPort?.complete(server.actualPort()) - } - } - } else { - // Something went wrong! - actualPort?.fail(connsRes.cause()) - } - }) + val options = HttpServerOptions().apply { + // 60s timeout based on pings every 50s + idleTimeout = 60 + maxWebsocketFrameSize = MAX_FRAME_SIZE + host = listenHost + port = listenPort + } + val server = vertx.createHttpServer(options) + // a set of textHandlerIds for connected websockets + + vertx.setPeriodic(50_000) { + // in a cluster, we should only ping websockets connected to this verticle directly + localConnections.keys.forEach(this::pingConnection) + } + + val router = Router.router(vertx) + router.exceptionHandler { t -> log.error("Unhandled exception", t) } + router.route() + .handler(BodyHandler.create().setBodyLimit(MAX_BODY_SIZE.toLong())) +// .handler(LoggerHandler.create()) + .failureHandler(ErrorHandler.create()) + // we need to respond to GET / so that health checks will work: + router.get("$prefix/").coroutineHandler { rootHandler(it) } + // see https://github.com/vert-x3/vertx-health-check if we need more features + router.get("$prefix/ready").coroutineHandler { readyHandler(it) } + router.post("$prefix/$PATH_WEBHOOK").coroutineHandler { webhookHandler(it) } + server.requestHandler { router.accept(it) } + + server.websocketHandler { ws: ServerWebSocket -> + if (ws.path() != "$prefix/$PATH_WEBSOCKET") { + log.warn("wrong path for websocket connection: {0}", ws.path()) + ws.reject() + return@websocketHandler + } + handleListen(ws) + } + + server.listen { res -> + if (res.failed()) { + actualPort?.fail(res.cause()) + throw StartupException(res.cause()) } else { - // Something went wrong! - actualPort?.fail(countRes.cause()) + log.info("Started server on port ${server.actualPort()}") + logEndPoints(server.actualPort()) + actualPort?.complete(server.actualPort()) + } + } + } + + private fun logEndPoints(actualPort: Int) { + val hostname = System.getenv("OPENSHIFT_APP_DNS") + if (hostname != null) { + log.info("Running on OpenShift") + // TODO handle proxyhookContext (or remove OpenShift support) + log.info("Webhooks should be POSTed to https://{0}/webhook (secure) or http://{0}/webhook (insecure)", hostname) + log.info("ProxyHook client should connect to wss://{0}:8433/listen (secure) or ws://{0}:8000/listen (insecure)", hostname) + } else { + val port = actualPort.toString() // we don't want commas for thousands + log.info("Webhooks should be POSTed to http://{0}:{1}{2}/webhook (insecure)", localHostName, port, prefix) + log.info("ProxyHook client should connect to ws://{0}:{1}{2}/listen (insecure)", localHostName, port, prefix) + } + } + + private suspend fun rootHandler(context: RoutingContext) { + val count = awaitResult { manager.connectionCount.get(it) } + context.response().setStatusCode(HTTP_OK).end(APP_NAME + " (" + describe(count) + ")") + } + + private suspend fun readyHandler(context: RoutingContext) { + val count = awaitResult { manager.connectionCount.get(it) } + context.response() + // if there are no connections, webhooks won't be delivered, thus HTTP_SERVICE_UNAVAILABLE + .setStatusCode(if (count == 0L) HTTP_SERVICE_UNAVAILABLE else HTTP_OK) + .end(APP_NAME + " (" + describe(count) + ")") + } + + private suspend fun webhookHandler(context: RoutingContext) { + log.info("handling POST request") + val req = context.request() + val headers = req.headers() + EVENT_ID_HEADERS + .filter { headers.contains(it) } + .forEach { log.info("{0}: {1}", it, headers.getAll(it)) } + val listeners = awaitResult> { manager.connections.keys(it) } + log.info("handling POST for {0} listeners", listeners.size) + val statusCode: Int + if (!listeners.isEmpty()) { + val body = context.body + val msgString = encodeWebhook(req, body) + for (connection in listeners) { + eventBus.send(connection, msgString) + } + log.info("Webhook " + req.path() + " received " + body.length() + " bytes. Forwarded to " + describe(listeners.size) + ".") + statusCode = HTTP_OK + } else { + // nothing to do + log.warn("Webhook " + req.path() + " received, but there are no listeners connected.") + + // returning an error should make it easier for client to redeliver later (when there is a listener) + statusCode = HTTP_SERVICE_UNAVAILABLE + } + context.response() + .setStatusCode(statusCode) + .end("Received by " + APP_NAME + " (" + describe(listeners.size) + ")") + } + + private fun handleListen(webSocket: ServerWebSocket) { + webSocket.coroutineHandler { buffer: Buffer -> + val msg = buffer.toJsonObject() + val messageType = MessageType.valueOf(msg.getString(TYPE)) + when (messageType) { + LOGIN -> handleLogin(msg, webSocket) + PING -> handlePing(msg, webSocket) + PONG -> handlePong(msg) + else -> handleUnknownMessage(msg, webSocket) } - }) + } } + private suspend fun handleLogin(msg: JsonObject, webSocket: ServerWebSocket) { + val password = msg.getString(PASSWORD) + if (passhash == null) { + log.info("unverified websocket connection") + val obj = JsonObject() + obj.put(TYPE, SUCCESS) + webSocket.writeTextMessage(obj.encode()) + registerWebsocket(webSocket) + } else if (BCrypt.checkpw(password, passhash)) { + log.info("password accepted") + val obj = JsonObject() + obj.put(TYPE, SUCCESS) + webSocket.writeTextMessage(obj.encode()) + registerWebsocket(webSocket) + } else { + log.warn("password rejected") + val obj = JsonObject() + obj.put(TYPE, FAILED) + webSocket.writeTextMessage(obj.encode()) + webSocket.close() + } + } private fun handlePing(msg: JsonObject, webSocket: ServerWebSocket) { val pingId = msg.getString(PING_ID) @@ -354,6 +288,7 @@ class ProxyHookServer(val port: Int? = null, val prefix: String = getenv("PROXYH log.debug("received PONG with id {}", pongId) } + private fun handleUnknownMessage(msg: JsonObject, webSocket: ServerWebSocket) { log.warn("unexpected message: {0}", msg) val obj = JsonObject() @@ -362,108 +297,174 @@ class ProxyHookServer(val port: Int? = null, val prefix: String = getenv("PROXYH webSocket.close() } - private fun logEndPoints(actualPort: Int) { - val hostname = System.getenv("OPENSHIFT_APP_DNS") - if (hostname != null) { - log.info("Running on OpenShift") - // TODO handle proxyhookContext (or remove OpenShift support) - log.info("Webhooks should be POSTed to https://{0}/webhook (secure) or http://{0}/webhook (insecure)", hostname) - log.info("ProxyHook client should connect to wss://{0}:8433/listen (secure) or ws://{0}:8000/listen (insecure)", hostname) - } else { - val port = actualPort.toString() // we don't want commas for thousands - log.info("Webhooks should be POSTed to http://{0}:{1}{2}/webhook (insecure)", localHostName, port, prefix) - log.info("ProxyHook client should connect to ws://{0}:{1}{2}/listen (insecure)", localHostName, port, prefix) + private suspend fun registerWebsocket(webSocket: ServerWebSocket) { + // TODO enhancement: register specific webhook path using webSocket.path() or webSocket.query() + val id = webSocket.textHandlerID() + val clientIP = getClientIP(webSocket) + log.info("Adding connection. ID: $id IP: $clientIP") + localConnections.put(id, true) + val connections = manager.connections + awaitResult { connections.put(id, true, it) } + log.info("Connection registered with cluster") + val connectionCount = manager.connectionCount + val incCount = awaitResult { connectionCount.incrementAndGet(it) } + log.info("New connection counted: cluster has {0} connections", incCount) + log.info("Total local connections: {0}", localConnections.size) + webSocket.closeCoroutineHandler { + log.info("Connection closed. ID: {0} IP: {1}", id, clientIP) + localConnections.remove(id) + awaitResult { connections.remove(id, it) } + log.info("Connection removed from cluster") + val decCount = awaitResult { connectionCount.decrementAndGet(it) } + log.info("Closed connection counted: cluster has {0} connections", decCount) + log.info("Total local connections: {0}", localConnections.size) } - } - - private val localHostName: String by lazy { - try { - InetAddress.getLocalHost().hostName - } catch (e: UnknownHostException) { - log.warn("Unable to find hostname", e) - "localhost" + webSocket.exceptionCoroutineHandler { e -> + log.warn("Connection error. ID: {0} IP: {1}", e, id, clientIP) + localConnections.remove(id) + awaitResult { connections.remove(id, it) } + log.info("Broken connection removed from cluster") + val decCount = awaitResult { connectionCount.decrementAndGet(it) } + log.info("Broken connection counted: cluster has {0} connections", decCount) + log.info("Total local connections: {0}", localConnections.size) } } - private fun getClientIP(webSocket: ServerWebSocket): String { - var clientIP: String? = webSocket.headers().get("X-Client-Ip") - if (clientIP == null) clientIP = webSocket.headers().get("X-Forwarded-For") - if (clientIP == null) clientIP = webSocket.remoteAddress().host()!! - return clientIP + private fun pingConnection(connection: String?) { + // this is probably the correct way (ping frame triggers pong, closes websocket if no data received before idleTimeout in TCPSSLOptions): + // WebSocketFrameImpl frame = new WebSocketFrameImpl(FrameType.PING, io.netty.buffer.Unpooled.copyLong(System.currentTimeMillis())); + // webSocket.writeFrame(frame); + + val obj = JsonObject() + obj.put(TYPE, PING) + obj.put(PING_ID, System.currentTimeMillis().toString()) + eventBus.send(connection, obj.encode()) } - private fun encodeWebhook(req: HttpServerRequest, buffer: Buffer): String { - val msg = JsonObject() - msg.put(TYPE, WEBHOOK) - msg.put(PATH, req.path()) - msg.put(QUERY, req.query()) - val headers = CaseInsensitiveHeaders().addAll(req.headers()) - msg.put(HOST, headers.get("Host")) - headers.remove("Host") - // headers.remove("Content-Length"); - // serialise MultiMap - msg.put(HEADERS, multiMapToJson(headers)) - - if (treatAsUTF8(headers.get(HttpHeaders.CONTENT_TYPE))) { - // toString will blow up if not valid UTF-8 - msg.put(BUFFER_TEXT, buffer.toString()) - } else { - msg.put(BUFFER, buffer.bytes) + /** + * Extension methods to simplify coroutine usage for various WebSocket handlers + */ + private fun WebSocketBase.coroutineHandler(fn : suspend (Buffer) -> Unit) { + handler { buffer -> + launch(vertx.dispatcher()) { + fn(buffer) + } } - return msg.encode() } - internal fun treatAsUTF8(contentType: String?): Boolean { - if (contentType == null) return false // equiv. to application/octet-stream - val contentTypeSplit = contentType.toLowerCase().split("; *".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - // use the explicit charset if available: - contentTypeSplit - .filter { it.matches("charset=(utf-?8|ascii)".toRegex()) } - .forEach { return true } - // otherwise we infer charset based on the content type: - when (contentType) { - // JSON only allows Unicode. - "application/json", - // XML defaults to Unicode. - // An XML doc could specify another (non-Unicode) charset internally, but we don't support this. - "application/xml", - // Defaults to ASCII: - "text/xml" -> return true - // If in doubt, treat as non-Unicode (or binary) - else -> return false + private fun WebSocketBase.closeCoroutineHandler(fn : suspend () -> Unit) { + closeHandler { + launch(vertx.dispatcher()) { + fn() + } } } - companion object { - private val APP_NAME = ProxyHookServer::class.java.name - private val log = LoggerFactory.getLogger(ProxyHookServer::class.java) - - // HTTP status codes - private val HTTP_OK = 200 - // private static final int HTTP_NO_CONTENT = 204; - private val HTTP_INTERNAL_SERVER_ERROR = 500; - // private static final int HTTP_NOT_IMPLEMENTED = 501; - // private static final int HTTP_BAD_GATEWAY = 502; - private val HTTP_SERVICE_UNAVAILABLE = 503 - // private static final int HTTP_GATEWAY_TIMEOUT = 504; - - internal fun describe(size: Int): String = describe(size.toLong()) - - internal fun describe(size: Long): String { - if (size == 1L) { - return "1 listener" - } else { - return "" + size + " listeners" + private fun WebSocketBase.exceptionCoroutineHandler(fn : suspend (Throwable) -> Unit) { + exceptionHandler { throwable -> + launch(vertx.dispatcher()) { + fn(throwable) } } + } - @JvmStatic fun main(args: Array) { - Vertx.vertx().deployVerticle(ProxyHookServer(port = null), { result -> - result.otherwise { e -> - exit(e) - } - }) - } +} + +private val APP_NAME = ProxyHookServer::class.java.name + +// HTTP status codes +private val HTTP_OK = 200 +//private val HTTP_NO_CONTENT = 204 +//private val HTTP_INTERNAL_SERVER_ERROR = 500 +//private val HTTP_NOT_IMPLEMENTED = 501 +//private val HTTP_BAD_GATEWAY = 502 +private val HTTP_SERVICE_UNAVAILABLE = 503 +//private val HTTP_GATEWAY_TIMEOUT = 504 + +private val log = LoggerFactory.getLogger(ProxyHookServer::class.java) + +private val localHostName: String by lazy { + try { + InetAddress.getLocalHost().hostName + } catch (e: UnknownHostException) { + log.warn("Unable to find hostname", e) + "localhost" } +} + +// visible for testing +internal fun describe(size: Int): String = describe(size.toLong()) + +// visible for testing +internal fun describe(size: Long): String { + if (size == 1L) { + return "1 listener" + } else { + return "" + size + " listeners" + } +} +private fun getClientIP(webSocket: ServerWebSocket): String { + var clientIP: String? = webSocket.headers().get("X-Client-Ip") + if (clientIP == null) clientIP = webSocket.headers().get("X-Forwarded-For") + if (clientIP == null) clientIP = webSocket.remoteAddress().host()!! + return clientIP +} + +private fun encodeWebhook(req: HttpServerRequest, buffer: Buffer): String { + val msg = JsonObject() + msg.put(TYPE, WEBHOOK) + msg.put(PATH, req.path()) + msg.put(QUERY, req.query()) + val headers = CaseInsensitiveHeaders().addAll(req.headers()) + msg.put(HOST, headers.get("Host")) + headers.remove("Host") + // headers.remove("Content-Length"); + // serialise MultiMap + msg.put(HEADERS, multiMapToJson(headers)) + + if (treatAsUTF8(headers.get(HttpHeaders.CONTENT_TYPE))) { + // toString will blow up if not valid UTF-8 + msg.put(BUFFER_TEXT, buffer.toString()) + } else { + msg.put(BUFFER, buffer.bytes) + } + return msg.encode() +} + +// visible for testing +internal fun treatAsUTF8(contentType: String?): Boolean { + if (contentType == null) return false // equiv. to application/octet-stream + val contentTypeSplit = contentType.toLowerCase().split("; *".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + // use the explicit charset if available: + contentTypeSplit + .filter { it.matches("charset=(utf-?8|ascii)".toRegex()) } + .forEach { return true } + // otherwise we infer charset based on the content type: + when (contentType) { + // JSON only allows Unicode. + "application/json", + // XML defaults to Unicode. + // An XML doc could specify another (non-Unicode) charset internally, but we don't support this. + "application/xml", + // Defaults to ASCII: + "text/xml" -> return true + // If in doubt, treat as non-Unicode (or binary) + else -> return false + } +} + +/** + * An extension method for simplifying coroutines usage with Vert.x Web routers + */ +private fun Route.coroutineHandler(fn : suspend (RoutingContext) -> Unit) { + handler { ctx -> + launch(ctx.vertx().dispatcher()) { + try { + fn(ctx) + } catch(e: Exception) { + ctx.fail(e) + } + } + } } diff --git a/server/src/test/java/org/zanata/proxyhook/server/ProxyHookServerTest.kt b/server/src/test/java/org/zanata/proxyhook/server/ProxyHookServerTest.kt index 7b2b2b1..21b473f 100644 --- a/server/src/test/java/org/zanata/proxyhook/server/ProxyHookServerTest.kt +++ b/server/src/test/java/org/zanata/proxyhook/server/ProxyHookServerTest.kt @@ -17,29 +17,29 @@ class ProxyHookServerTest { @Test fun describe0() { - val desc = ProxyHookServer.describe(0) + val desc = describe(0) assertThat(desc).isEqualTo("0 listeners") } @Test fun describe1() { - val desc = ProxyHookServer.describe(1) + val desc = describe(1) assertThat(desc).isEqualTo("1 listener") } @Test fun describe2() { - val desc = ProxyHookServer.describe(2) + val desc = describe(2) assertThat(desc).isEqualTo("2 listeners") } @Test fun testTreatAsUtf8() { - assertThat(proxyHookServer.treatAsUTF8("application/json")).isEqualTo(true) - assertThat(proxyHookServer.treatAsUTF8("application/xml; charset=utf8")).isEqualTo(true) - assertThat(proxyHookServer.treatAsUTF8("application/xml; charset=utf-8")).isEqualTo(true) - assertThat(proxyHookServer.treatAsUTF8("application/xml; charset=ASCII")).isEqualTo(true) - assertThat(proxyHookServer.treatAsUTF8("application/xml; charset=iso8859-1")).isEqualTo(false) + assertThat(treatAsUTF8("application/json")).isEqualTo(true) + assertThat(treatAsUTF8("application/xml; charset=utf8")).isEqualTo(true) + assertThat(treatAsUTF8("application/xml; charset=utf-8")).isEqualTo(true) + assertThat(treatAsUTF8("application/xml; charset=ASCII")).isEqualTo(true) + assertThat(treatAsUTF8("application/xml; charset=iso8859-1")).isEqualTo(false) } } From c19c42ddc3e2ecc4bcb22202eee72fc2a0a1dbf3 Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Tue, 31 Oct 2017 15:43:03 +1000 Subject: [PATCH 04/17] Update main method to use cluster mode --- server/build.gradle | 5 +++++ .../proxyhook/server/ProxyHookServer.kt | 21 +++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/server/build.gradle b/server/build.gradle index 05ab697..e39d081 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -6,6 +6,9 @@ plugins { dependencies { compile project(':common') compile 'de.svenkubiak:jBCrypt:0.4.1' + runtime 'io.vertx:vertx-infinispan:3.5.0' +// // http://vertx.io/docs/vertx-infinispan/java/#_configuring_for_kubernetes_or_openshift_3 + runtime 'org.infinispan:infinispan-cloud:9.1.2.Final' } mainClassName = 'io.vertx.core.Launcher' @@ -37,11 +40,13 @@ shadowJar { classifier = 'fat' manifest { + attributes "Main-Class": "io.vertx.core.Launcher" attributes "Main-Verticle": mainVerticleName } mergeServiceFiles { include 'META-INF/services/io.vertx.core.spi.VerticleFactory' + include 'META-INF/services/io.vertx.core.spi.cluster.ClusterManager' } } diff --git a/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt b/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt index 123028c..1500592 100644 --- a/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt +++ b/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt @@ -22,6 +22,7 @@ package org.zanata.proxyhook.server import io.vertx.core.Future import io.vertx.core.Vertx +import io.vertx.core.VertxOptions import org.mindrot.jbcrypt.BCrypt import io.vertx.core.buffer.Buffer import io.vertx.core.eventbus.EventBus @@ -92,12 +93,24 @@ class ProxyHookServer( var actualPort: Future? = null) : CoroutineVerticle() { companion object { + // Try these JVM arguments: -Djava.net.preferIPv4Stack=true -Djgroups.bind_addr=127.0.0.1 @JvmStatic fun main(args: Array) { - Vertx.vertx().deployVerticle(ProxyHookServer(port = null), { result -> - result.otherwise { e -> - exit(e) + Vertx.clusteredVertx(VertxOptions().apply { +// clusterHost = "localhost" +// clusterPort = 0 +// isClustered = true + }) { res -> + if (res.succeeded()) { + res.result().deployVerticle(ProxyHookServer(port = null), { result -> + result.otherwise { e -> + exit(e) + } + } + ) + } else { + exit(res.cause()) } - }) + } } } private inner class ConnectionManager(val connections: AsyncMap, val connectionCount: Counter) From f33d505cb3e4528aa776e41c3eb5b74e6111a834 Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Tue, 31 Oct 2017 15:44:40 +1000 Subject: [PATCH 05/17] Use openjdk18-openshift for docker base --- server/.dockerignore | 6 ++++-- server/Dockerfile | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/server/.dockerignore b/server/.dockerignore index c2d50e6..2c50eeb 100644 --- a/server/.dockerignore +++ b/server/.dockerignore @@ -1,3 +1,5 @@ -# don't send any files to Docker build daemon, other than the fat jar -* +# don't send any build artifacts to Docker build daemon, other than the fat jar +build +out +src !build/libs/*-fat.jar diff --git a/server/Dockerfile b/server/Dockerfile index 04d812f..71a56e5 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,13 +1,20 @@ -FROM openjdk:8-jre-alpine +# https://access.redhat.com/containers/?tab=overview&platform=docker#/registry.access.redhat.com/redhat-openjdk-18/openjdk18-openshift +FROM registry.access.redhat.com/redhat-openjdk-18/openjdk18-openshift:1.1-13 -ADD build/libs/server*-fat.jar /app/server.jar -RUN chgrp -R 0 /app/ && chmod -R 775 /app/ +ADD build/libs/server*-fat.jar /deployments/ +EXPOSE 8080 -WORKDIR /app/ +# http://vertx.io/docs/vertx-infinispan/java/#_configuring_for_kubernetes_or_openshift_3 +# NB run-java.sh will scale heap size to 50% of container memory size +ENV JAVA_OPTIONS "-Dhttp.address=0.0.0.0 \ + -Djava.net.preferIPv4Stack=true \ + -Dvertx.jgroups.config=default-configs/default-jgroups-kubernetes.xml" -EXPOSE 8080 +# You should run this docker container with the env var KUBERNETES_NAMESPACE set to your project name +# eg docker run --memory 128m -e KUBERNETES_NAMESPACE=proxyhook-server --rm -it # NB must use the exec form of ENTRYPOINT if you want to add arguments with CMD # https://docs.docker.com/engine/reference/builder/#exec-form-entrypoint-example -ENTRYPOINT ["java", "-Dhttp.address=0.0.0.0", "-Xmx64M", "-jar", "server.jar"] +# see https://github.com/fabric8io-images/run-java-sh +ENTRYPOINT ["/opt/run-java/run-java.sh", "-cluster"] From 86921cf5e6d83cbee3fdd010e6f20fcc3936805f Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Fri, 3 Nov 2017 12:58:16 +1000 Subject: [PATCH 06/17] Extract buildAndTest() --- Jenkinsfile | 79 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 54 insertions(+), 25 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 893eedd..c02dc2f 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,5 +1,31 @@ #!/usr/bin/env groovy + +/** + * Jenkinsfile for proxyhook server/client + * + * Environment variables: + * + * PROXYHOOK_DOCKER_REGISTRY - Docker registry host to use with OpenShift platform + * PROXYHOOK_OPENSHIFT - OpenShift platform URL + * PROXYHOOK_SERVER_PROJECT - OpenShift project name for DEV/QA/STAGE/PROD deployments + * + * For pull requests: + * - build and test + * - optional: wait for input + * - deploy to DEV + * - while (input = redeploy) deploy to DEV + * - wait for input + * - deploy to QA + * - while (input = redeploy) deploy to QA + * + * For master: + * - build and test + * - deploy to STAGE + * - wait for input + * - deploy to PROD + */ + @Field public static final String PROJ_URL = 'https://github.com/zanata/proxyhook' @@ -96,31 +122,7 @@ timestamps { stage('Build') { notify.startBuilding() tag = makeTag() - - // TODO run detekt - sh """./gradlew clean build shadowJar jacocoTestReport - """ - - // archive build artifacts - archive "**/build/libs/*.jar" - - // gather surefire results; mark build as unstable in case of failures - junit(testResults: '**/build/test-results/**/*.xml') - notify.testResults("UNIT", currentBuild.result) - - if (isBuildResultSuccess()) { - // parse Jacoco test coverage - step([$class: 'JacocoPublisher']) - - if (env.BRANCH_NAME == 'master') { - step([$class: 'MasterCoverageAction']) - } else if (env.BRANCH_NAME.startsWith('PR-')) { - step([$class: 'CompareCoverageAction']) - } - - // send test coverage data to codecov.io - codecov(env, steps, mainScmGit) - } + buildAndTest() } stage('Deploy') { if (tag && isBuildResultSuccess()) { @@ -175,6 +177,33 @@ timestamps { } } +private void buildAndTest() { +// TODO run detekt + sh """./gradlew clean build shadowJar jacocoTestReport + """ + + // archive build artifacts + archive "**/build/libs/*.jar" + + // gather surefire results; mark build as unstable in case of failures + junit(testResults: '**/build/test-results/**/*.xml') + notify.testResults("UNIT", currentBuild.result) + + if (isBuildResultSuccess()) { + // parse Jacoco test coverage + step([$class: 'JacocoPublisher']) + + if (env.BRANCH_NAME == 'master') { + step([$class: 'MasterCoverageAction']) + } else if (env.BRANCH_NAME.startsWith('PR-')) { + step([$class: 'CompareCoverageAction']) + } + + // send test coverage data to codecov.io + codecov(env, steps, mainScmGit) + } +} + private boolean isBuildResultSuccess() { currentBuild.result in ['SUCCESS', null] } From d25e169c2ca2e2c03a039ba5888c1113a4811964 Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Fri, 15 Dec 2017 15:26:00 +1000 Subject: [PATCH 07/17] Update Kotlin --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index eed9025..4cc24a5 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.1.51' + ext.kotlin_version = '1.2.10' repositories { mavenCentral() } From 5dd17b01a4d06be4080d746921184ea4ef922071 Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Fri, 15 Dec 2017 15:34:26 +1000 Subject: [PATCH 08/17] Upgrade Gradle --- gradle/wrapper/gradle-wrapper.jar | Bin 53324 -> 54712 bytes gradle/wrapper/gradle-wrapper.properties | 3 +- gradlew | 26 ++-- gradlew.bat | 174 +++++++++++------------ 4 files changed, 102 insertions(+), 101 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 3baa851b28c65f87dd36a6748e1a85cf360c1301..ed88a042a287c140a32e1639edfc91b2a233da8c 100644 GIT binary patch delta 25496 zcmZ6yV{|1>^evcz!Iv;AS zv-YX8_pYfzh>jWvWK{(SNK7y=SXeMHFfp(sWD<=3+1+ts?u-3*uAL;RhL?SccZPTG ze&_xJ?EjZjgAe~dIZ3l*|NHKp4(ESjH-7pVH;`apf8oHum{Qo3u~Voxh=4we+Nj@{ zlj#(a*fu|4WD&`RXUsL^?c{9XJK!L5MJ-^tiZDm|ydj%YC)g>saJpVlZr3yV(s6Rq zHT>S!f%78b0y@p;F0R?R9vN%>SppRQzCK@Uz>Eh}*u{MfM$M)abbq;ujgm4?=nhli zWhCuSCL@xhTNw^$_4Qy3!viZye|Avsb%oL4ZRw`DN{n{@k?rV&`O-~sWr%v|=-dbZ z6tMV&s#?`jFp%Lu9*p(GNzlr!qN4Ja;AIQZ{Kko|gGWuVPJlaAwu3fc##zboQZibJ zMwidh(=yRwir;aNoZp_42REl%q^E>W&tBBq4=HnYW@m8Q=DbQ)$^cSnaV&uB*QT-Ulvn^?)7G?DOMD~j#` zMwltrRyvjGKO;TInlf#*C6=aj;SC%=aR-Zok>iow*BSrI+O-Yez+#?ZqU8-$gW@2? z_%Qy(|3h9#X257OrI3Mk@Ruq5>M#sEV;|v$;;9U4<;wLbH7NP!G$C)$9&s}Bwwc#zS6AKg!>y4j zjIsi?+A_t4$c~XQNKOSca&W@@i^E%3E zpY)f=;Q4aCMd;cTRKN1vSsE+OWp+nO@c!C+xGmj?Ji6^gMT)#)dku+SBO%^&eR84? zKS``X3w}@2xaDAp%|we< z`g*WYw6Z*6c52xe1`!&*=;zO2O)R2GmA%O*py+9af(P$G``UExbQ@kySo&SgWo13F@r4sz8^3n*^A6rP*hme zr4DEF1`(4D@`GW2WK+wIodv{@x>b4{a)%Pn!&kq>Pzi`6<8+4ao>3px4~o(~6-dgJd4_2m}T~ z34H1>DEsyY*)C@_j)av`Cle;nG6&n-Ix8$GESMZY=~Gdx%jP{;h9u>6%*by%u35DT z#^_Lnq471ANy?wl|5Hl;cP0J0Lz_WC1_Pre1Op@ce@h1z$ZX+g=HZd2hhU1i;w7ZA zzJ(VrO`ib9l~j`kEVJGR$K4Y6Su9jO&Zl zZ@#N@2Uz>eE`9S_`-}+LxXX^%S-LfV&c5A%9(*N&=#Sk=ne1oD5t+M#f&fN|Bu|q} z9>+rWg^94;R=jeoeFBiA0`%tN1&7KeDI6w?x3lV5z@+K^iQ^s1hnwa%l(hgO?tgSx?<_?BoOU68jav9g>lW7#$1*$5 zk7#!ea4R{k4A*6MD1m_=7apt{11IDwu3ANXmpoXQ9<4w1Htte1(r^60pGqvoP!f@b z%~767@JRTmNui<6-;HRl3=%}l%GkZzT1!NMPCKtf5)c+BqtGfSnhcsbpU zgzmjr*Lbt?rI**S`CK9{{#z3EZVkbscTrq68sSAA%TP4}R(u>=Y•ERK-f@c0_ zr5CWAGyd0cHWiB!5K2&dzcp`@mf*`Eq*XuULhrnu5XNb4Lc|s-ZP-jp%6k z;4UTGwv1B0V^*G~VxqULq}W5dnsLig5o^q|k#nz*rku_TP z_(JNn*k?g$?J!4C-b$XC#TuS=#T%IIv3cbk5}S7Wa6Kc#Fn6tV-qGEeLGWtx72?av zsiVk0>p3M-ndQ*Bty*L-d~)`Q=PmXrd`E{7e3K%6o%_&FI+!=mU1NPHHkoYN#6T45 zU<8(*;=pjRnZ`)$H_`zUWJmIoaSWZ*kAJXm+-$%p5ps>Nu~E)xB5oZ>Rl-TaV@Dac zF3h8kGFRcO9j-+xv5_Y@m@i?{3};fCm-Et%DzTA8D7wkxZ!v~v(QSv}D4}Ic+s!+~ za5Ve$JI*xk^`T_G;(gYNs=m`Z#lVb6fM=*YCB!2p5GEv4z|{axlTv78O6bKTGi;(N z{Rf_l(DRz+>1Xg6=?7T=M~jV zYhyE0_6=FyWQ9rU7-RxWsI$}ua3`v0>LeV45NET@)$`ruDHY9P?T#_VH>8@X>V?yi zIN2;>^ad2|7|Ve8)OXF5+#`H$!il-+&rVVa?}pWUXUCosvGhv3*|J&Y%D0dMcPWwl zaI890EF}z$+**Ic!PWTut1cD{s~yf9>v`>~zP*6)8<@}GAEapnQD3Vf<0$R|3XsN+uOqs^e! zBD1>O#kSK>-0`G)oYPejjxq8c^K4>T5qZ-(yO<9jGP6jI9yBDJdD}TlI?w72*Ev@W zxSgsLL2mwC{{H;yT|&J&U+pEO4)7+Hvd!|!=EhZx{2i@5c5Pvxl$;D`E60JmIt8kF z+K~b93nn0SwZ3?A^skRsg!6k{t>CU;gBbP+c^3+&511=EhLru@rlvz;ed&s4R)q(O z0~LESKKrG`d3B(C8(tVZ7};NSB$QhoBMQsWIdYFM;n)s0oJKEQKwK2lfj(0An&GG9#tHq zgjG&wa)jotRlQg`)_3I}s{2bKlH7&G>bjtAiZx!KspiTKa8*Uue#jett4hsD=RzaG&B<=F zGM--**)RVxbV-F{*=Z>?@p@Labj0h@ z3D6p#G6rhVexYdY^0kCkcxGTFMnCO1$3H9a<7_^J4GUpi`j>}}GYE;4l6_zYygA4z zlwnNxSZ|!Kn)P(!Xx&!Y_&EHt7f!7-^otEEH*%S|pG;64P{&I6RWXQ7$%^=cfDGvP zn=jW!1ga3;gHV9V0>|u4(o}@e1n&Ieq(6Y+*5TJPIdhZIt}US$>iMkJqM)JYWE<;Z;A ztVh%$|0Gxzdns23o_nM~*YKCDjH;2l)R(MBDRb1T&1nehq1UQpj)70EAR3~eM8EFaioNE*A3XC6>a<5 zswY0z#YW_J7Ta?HEo==w4fdgmuUGA%U+C4!T;@o2{+w6X!OA>l#co@yEpc>Mv+WsN zYnu#stgehg!_7Q-h%05WRTCNdA9D*8S0x7H1%FSzF1vQMmI{!yiV-F5e9J24j*-R_ z<8i&22R087b()17e>-`EX$9ECMZ67)x?*Wx;#x5TJukH_Rl7JTTpX1R%xyo* zX+|2fth&i7t^3>aVRVHwezx)uWEh+ahl5c0Dg3-Vl(4U6ffso(kR<1OmwY<H$VqaX#D?D)_wp&1+$IfrOf%ah7{U2t7>eoOXuBtHxUzng^{Yd79LSL!qEQNE4k=${V>Ule<~JM`IS;-x#vhJBe^W4yBB zdN7*jdslN7m4{J*6dRS3(m?3XO!Cs^ANN2An^s&T3I8EvIep=7VNM=-^Mf!Dct_mt0nGq=w zlo7nJNimkSevPbdn8fvd`KCuyk-u!cR?1UA)wR2)t85$MqkYg)4X4onzP}lfk$u8o znt3sW4ZrXRf#c?`r4#|035ig=2|7Gxw4-bJq7|eAuGkbp1G1#(hv%+6T|xnZv1;gX zJ)synfACXkX(n|keyBzF(6|yE+}(!7J!r6K$mRVZJm_aT?itg(_mw1CGyTx6??bu( zR63WZ5*NF~y~d)sokVqxMYWGbbJ`VuoV`7jzVap+1C`F}Ue%t#bM+EQh8{fE`Lelq zUU-SV4qoA&&EpyiELF-uG_l8l>RHJ+>_^|2B@w!*{gHpC4!FCO3@qEgD5h zFC6Q4K#%ixc06&N)P|Zv7BxUy-yb2W?;7u3cOUBTZftk@M97I&&)LQLo}BTvt|J#0$@(id??LrZDwtHKg(`DNlHM{Qqh z+X1s^Ad(ZJS2(X!3cm1r8Mr;nmc-T?xkRA4A`#V8RBv?x`hb|_-zyxdOPnD`q+a&o$I)BwN_T>=|)tGwS4YY4f5aCwXnu9Q4v_M?}I|-;eL9S&&!a zC@#=piCQTl=fW3-AMw3+gZ_W?bx;&5nlUsO*bxF47{~v*n*c01(7*%Z1TRQfZ(;p{ ziyN62ZH#M9l6^JwQIx)$QhGp>)T7z$AWVFDA+p!Et)^Z(SV2WjVZGGJPkueZeKF^l zd@;+K8Vz+e%&?84*SVsLo!)R8s=*Qh}D&E+!be=sMFvq-Fk5uY7S8$v9u4VPvQ{? z(VlT?Et77z>5XYq^7d65LxW9Yak z4S69U;P=tZX$aIj#;S8Z#u^gW||_@zsRJ z(?r}OO&y*LJDlApl z)c;X0j2pku+AuMVqJbU1lYamgAQ#KM`9EjG^4SYO!XL-LqTR;ceNy;mL+NC|RUTj>O9`EXWn%`XaUnW;Lt-$VE`J2r>MWV|Uy1 zCLGBCc}AqI8#ZBETW;Uk-#c!lhYWc>2|c12OG?qO2*@iKgp4gGnQ>wmZ?SM)bbicV z5~u?wpd(y|eg_e%Bww{k*|{B{n8giO5~(3DNmG zfZFG>3)5k@%fC_Ul7?GA6^^u zVz*-tZX;-*}{q`d=|KH6( zgs<4sEa&rQ=W_Hi{|rx(ZWedABn9?VCI}Kz??wLFXhWIm0~TB@o4|F0b~j%%g9;e` zkL%q#L-I7+-DpGhG)Gb_c;Q!|KRp0vLmdv8bKd)b8Pxx=i(zHn`1c9J^7tEZK0O(h%}NkJ?Eo?P2rz$<)ebKd$9VE$tJfWY`lyQ%Z$$SA@G z#*O;$4-|WOjQ`^;BB!6zoJxYt;Q+cz2Y$H>T76$A#o;vZdO*63lWZ(8IiA@i_P3az z%y=>TcZ6X6$?Uv^CQmw$YpfE`BX={qS3?Fw_&kxRkb9~a5-9v)es4EbIZB))REan_ zk(iRETpX^bQBl?S$9SUqs`(vPAL74CbIZIx@&X{NFvZ!DK7RiNYoblaa`#E@!hE~d zvuWN(P~oaCH7iMnYV+@Z(*gc(@)oknZ_DsM-e0Z74=NS>e-i{QYVA|X;r|l@aKXUn z|5uYL7=TXc8b$-=>B0nHtl<3<`4xEo=J?Z*l^jtFjKwiLDQq=tsR;?!lEBfzTvitnlp z<-<7tb9N~4$0{jz)oMhymgBI(;k|likK%wG78SZ-Xa)n&H=Ly5@j%P{#U86+KqzSQ zV$1=nM_DRuxO(v7Xpca%2hBzO)*6co`IU(tbWoRkvT=Ct-Pt2C64>#d`Uk&#Hr7pf zx<#LUAOyc%V~(|5VU9OL>#I6~KYk=ESi1Wa6d)=^BwR@z3-`uFmw_^VWF}Jn=_?sd zk~r~Y0Y3w*_9Z0IzC#Pp92vWH!z%cV0WVy&t3eOimBT6+wfTIlEq%)+DbTrvdV7-s zyrv3%CR;wHl9;5&_3e-8UP?p47myM z;S)EiHyzsMh)P9fDH`}K;4d8DNswwvZm)5zZWEoIPu*2{t36*|T;orPWlV=+qop~= zQ&}u?hrB%@Nr3kudxR}ilQ)&l=g1LqtN(o8>mWNYnQWZD0F%3;8k1l?1?j!!y>4=sGqt8L7l4o3xEJ1)sZbmsLR0 z6UAlXZ4<{52-Xp*WC^yKOY4h6rl#8I=dtalCtOsu4kZ44^OHVka*fQAJsn50yCf!v zjGHL7zURf03spdXgH|qMqG+O_p-&VWPow%sjee&yyI)S~ zHu?HOGhcc7UX?TpOhaBV#^;ojsISHL6r(nw$mtx3cPZn~$628R<8i*f*K`-IjA%+m zTlxOJ|B)?1fz)1}O&Gn*L##cIa^o~s3*J`6oeZz%eq+a8!YO(?P?W3EWhP3Fz}g0` z>`Zfr@lF2c98jiW`m&tCJ(Jg&`jsTVVQ61&g_*kV1~X6vQ=QDdU`>t1wZ1sa_89iN zIM;b={4VY&Sgx9AW_X|COL{3P-hhuYC$Nv2;DRNWTI$$@i>CPDD2(4UXK!h8zc|z> z37pF?_JT8RUmK}?*BVL2jiY-$99|AM#B$4Nq;;w+9RWljTsTH#%NfhW#1D!#p?htM z*x79#=A%}1XT;z*ZBhCs184tA6x6VX@>&uqV)x4NJy@DeSDEMMS1UuZa;x*HG_-Sv zf@HT@wTGEsH85xd|6R3b^4k7e9p%MqvX3yaWmgbcU>ak*qbjixhL?thQxFH(uE6qb z1KKF3dp=#(_2+k@TvgLRzv(wT1z!S?noQ@XgF2G#@EScgbjgMV%k_i&<_sg7t;(6? zK@V7ai;~f_f5Qf&;6=1BtUB_X4)r4h+N28w3nVLx4HxVjaXilMVk+k(%PQ4}!Ic;> ze@JOM6iYLz51T73#ZY;!1MGpx-}&Pi---({RB@X{niQ2$W!W9F=NAIv99&I`j5w>y zMKmepSomzG36L0MnDG~9kGw(z{O0B2^<8A!ghdo|9B483$pW<%@v2+vo<@_koL2$? zOHumSx@6k&(PT=s$@`&Z-N`u`%5<~$^Y(6R5*rQ?j;X(5(X+47HOYV}ghi?N4e9mK zT;_Gu%hCKHlYfS;xQa@*9L3MvDDVZ)Ii$s#4*6c&X^V|9kUA2s)=GL=-HEItJX?e~ zuP}6sV>v-;XiC|-EZKSqp^ih@9gyt|=09r_4bH+mxH05<)r?W@IsoP-_oI}Pg)Z+4 zx*9vI8L{n4o=?4-9)dt#l-RyAVZVIP(g`~Y$>QQw)x6@MtUiu9)0oZ-q>On<7AAs& zliCIox3n>U>UoJx$3bu`RFJ*q)5Nz$V9VZPUY50D%@Sk<^#U|#TIr3P2@h|E>;CSE z>tzl<8@&PW*Nkm|V-A5r%Sj$5P*l)ndJh1{UM#Eb%+G|=@J4f|1I@SjTjQG1lXn%_ ze7<{ljl4fEH~(tX3eX%|b@ui!BGu?*3>Brbj~;Xp%B$NEJ608F;P#G!x;m2V818Mbr8i}^bOUZf)N`tF zhj_Fpj<}*9`zS>~$JSuz2YKTm2LFL?xC|?@-<&U$J1_~R&Pc)=T+c$e<2^Rw9-5`g z1Mytg#@0an2EbgFHru+b5pbWM^Wu0^BlOCKKHovd5;^*Ca(XF>x$P`vlsm9@#T~c^ z=!$1sJFj{V_dU5D^zs-we27E#_;(V%LfdVzEWmA^&0{@^7x@l>B%vD~zY6_3k~E^R zVCa@>6F4n__c8?VPy2uXe869$9A7cWksU%rTs(z^k-<0=mb!uOixC{5uBa1qfb+Qi zC4@ZXo!ViSWD{pZk6pynQC>Yr$;7eC9{wa|?%SqIMN}zl{fqNxRdk8+E>9c|^bIO> zG+o6}g=wap70>yhBkNiQc~c;cMTgUx4yizS1sp7FV2c%XIHRmKNGlY#J;St*YNm}H z(OZHBG?)G*dE`+cr{z&Fm|RxbFV^O&-Hi0?Zvd!laRhVZ-i7SMUh?tm(xTnFdy3Mxz4pJy5krsb}DBWvC#@Up5XZX@WnmsBO?{^@SOQ zWOMl4%1`#>bt|NLAwn(j3O;5&*Nxy%m0sCZ{ zoP2{0epA+G|K^zKnrKzoJ?8+`LOo6&)l)OjLg11*M$L&WnUZMOh@_n2Yv24Z7(x~5 z3I?|C>23aH^_?B{_+!VHZIo%!muOU1V9l!VDj-=0L8Rw-?HmA(1+q3u;NQp^79dwz zva``B6o@qvx~O3DR)~A*6^!950NSOsVZy!7T!Uu)^lk%f#sjI=PwmgHkw0)cVyxzd zZj7EAEY_s`us`Wbkgyz~+7zqO6eqY8FRA_b6oDoKukCChK5Sk-98P6z@u_peah*Sn ze{EG0%?>@lnaG_YWX^xUwyCt8jQB7KlEZ9~ITZ_P-X3PXTRS%1sxW_yN%& zIQVuCtEAxY7_G>r4hP}8gDHIwd^$JVJ2n|ASY^wxQdvU+!|}W{h%@9fVom4eYH|Ld z{GS|CN+jei=KpvF^J;Kkm;R$=xKUD)An{X>Mv#GdIv@U+I(R`U&+L=d!KnJK2DbCk ztBgnpMXGQi@NiBN8KMN@kQQFe0*=ts92b|28P!h_?I8Oa?K1mb%~+qRWGYOji@Npq z*nh$O?>euQH#{vlTaM|l$AITtzMX)Kmw+9C9X`OvPD3BGDZw`g(crcNktB6bf*qG# zaUA$dg*-MCnj;!g*`Agew`AS1PB(Hzi>K{7_eebtM4igD?j>9Cn4;43d60y>6C*GMwm@YB?pk8nt;6!Dz+jX2J8z=N`c zF7V2d&#Y($0hM}b&sEE;7K_sV67nq4@WQWIGo5%xE^)DP$&vQITeYs&svGN#Qh{g{ zmjoatzn|$AZ~j=!r5WmmnPJ{F@5U~E8nJ3G#2Qh^olw?+Rx|EX^Al64XGC1(rN~=+ zbdzjDEtWiIgd8-4}TLeOaBmQ zCk{Flk+-{C|&c zIArZ$w%}5Xn_}P~&arR10tf{*p zU-E@D$()&=5bG;8_TPk|?H22=Kjvd86oumzfU~oB7l;=`+MIDKfh4kU$IeVQ3`#|u zUAeOWd|`a1s$D#lJ|OMi*#Pl_>JM;U%MwiXnj>^PKk@tL-Jn|dt2?UAl*hPws`t7Q zc47*4LY9n$9M{WPlx69um!D=9w;X(ph6wnB5=CrZOATQfqnV={2Yjkt zslSU48hjPTHump>9fP*i8EIJP`T_mGc#1c{- z+Zbr_d~>_LaSZ?02h{y;odj`}jpGZFaGA6;M)=!Uw}EO3Vu{)OJF+1l$7b$9D^+QjUWQy}>{qz2J(Ab87-wyAq! zj&n0Y*pAY}-Kr-ok8U+j1Hqg(Y^#h5xITx6^RYq$%qrav?|(ZwCDuAGJMQ>pcoDr` zOM(pKj=OeAXDJ|Ou)fNa8*SJOExPQh8ez!enAFPO- zKksCVv3q?4!!vz#SWIO0eUIV!t98AJ#t(Gn@#-Jx(|sCtRYxXjdcIWgtIYpO)$SNd zy-3v-iAJ*qH2ER78zD1wn*?L`hS^%8tjzOm3U^(TD+5{ZY^DQpLu~2wN7mu)gd=^H zG`lr4Piyv(>9yQ!UfuILRHPcr0wJqxPp|5u`D^($g;0Br-ULwjT-<7T6fp;ZE*){L z?{$2nb3jER4xf%B0AUoAn}x^fl;Ms*Qj8JuD%syaLI=KQ3MJ($@&b3u1xkP>X=M3$ z@yQ{vG_sLd+D_@nP7}$$->Bc=W^KeUOVq~l?7q54dxh1zXkI@emmYAnSoRzJ;1Z#0 zvxeE<0bnR>%Bkr&cdP~buQYm`p+7B=0U8II`aq#*nOAKybt1}ai9;Q+t1;RvVza|` z*G$C(_ECDjn~Ez7F5`%sQQSRRDmD4h?&qYUG4Tj!lcYwT^s3^Ud-TVIzlBrXd%S3; z{U~EU-ypY1dUA$gGU0E5S|hWZuOBS`XG6?G=oF>I00Vob0|O)dPk2e;RL1}RjC^nb zSVwIlG%v?9)6=hKqIIQSCpEzK1}- zlZ%Rwf+WIQ3F?!ZC=g#NyO0yG5OzOzs8BL-(t(D6Ymrh)2YY9k zPp)JZRpG7{<$Peb5QddcTudh@aXhQ(tNqI+(ihnQz5u=WOps@|;$HBKF8!D@{3OyA zIT6JTE+e8cee$ryFdN>E{-HCb2Fn=rFdUvG8823XvmitEA8>(`CFR5&?Zp#MfI?Er z5aST*UXUYkc0Y|wIA<1auTFbUi%cKc7`G;++&CM$s8OFbm*9|VVX^WS01ssTqa^^ejI zbKx+-JS4hz12%D{!C#QsA906oNVLXpgc8Cvqa7kAwR0qPuVU6Q!`&+bq@^-52k%h5 zeka?azim!u$Xn3mBF-y2{~Aq?rVIC>+r%h#&dS*})|;b=F~;7XeK@)YboLmL33nRq zF=R!_ImL}3)i77nq(~>z8SXT8d21|=59O)p4tdjSg|;bifEapdh)4Pd{( zMS5rE(J8b~O9n_)U3hY+F>#~mwAv(cq~){H0c|mV_)j`2jK5GbOmJ>fk?>reD-qzc zm9hHED`fQ9o;9ay#@VTBYBtQ`Yrfih^2A?T>$@8=%o4P~=VI*{y6EE-MR;iw9UDwL zzeO;GV3Zi7Q(=9BZ-x#$YhoTxqx(9K%hHz_&mUZ{`(>>Tn@|X@?fsgKCZn|OTuhPn z2TrhG^_{oa9=cdAN24)L1-X+?oR!oDW$ zUgd(UUR0*p+|t!rGxT$rYj&$`cYmyh*ahC3o!ZEfz#_2Y7F=rmEDAMJuQvQB)||7p z>L)+Z7DM?lwykKmdhUAJDdkts{{;5b!$ppsKd zqoOryXb=v8wB^qtna$kHR*LA69_$UuYJPGb>i1E>h5tGclvQy82blxFY`Yf0Q5{(AnnjPJ))G%-MtzXf>M1 z&FZFo|0+7O9js;)wl$P`GGHoF0&~3dvT)dnBMwz2htxAGDK%z-egN*U9B)F+(|_2i zY#-yjqLHQM#F$u%-|^))rCvoDQaBp~;+gPzk1p6BmO|qtQ9{XBx(7qfpeA|geI=iS zrPAdDs$n-p_|jWjqPNYWO_H!#wyQD|$OlMSbXJh@7YWU8Wu*Pgtd^QhmF*tPg~pLGe)MJ|6x>KiAUZl z3!4YH4{MIiC)1BjRJrKrw2E3rFVX_Aw}OyajOk-%MM6esa&$cWa8Nw-s13DqTf19I ztfH3gQIsvB28nP-B(!NJ2YUv!Qrdu$37$J1)W2&O&E{B=r%>T8KWV&Fy*TR6?t~Rv8rP^PbF}P`&hm6xF!W)XJFFkgQxcx- z5I7#*t5q!!A52!ffpA?~K~XQ!1J5;|PTP~MUzy^i0Ga{vq7mU7NP3Z}9W^_at&;m6 z-}MtaKeak0=~|||X?{3FBTp+tAQK_StM@xJei^V$UoSXuMsV-6Z00w38Ne~NesJn`srYuv0&5g0>ga`#+qmfTxj5hs zbc-nnF_NgVoyA%`xgn$pz0Z#HBh#Ynx~vVVN>?YD3acgAn7S91R5kwEj*RcWs_HM5 z5~h;13s~GedphYH@WNZR>Q{&oa1`ECgpSH+3I9o?`|Oxdi8?@W69Jv?wdy(O`Tb{5 zPlGunN<(=_0n&?5x%|Av`%?TxXwPwQ28;PB7{B$V3pE3~iu8B8^UA0RG6r?$zDz+Y zDB=Y+%|=UjaF*^fFbzm*%7{BnwYX)H^(U!X`WH@C<1Xm-Sa{5s;I9u}xenS$o z&Zb2;^GW>CDo}nO`Dmg6wYI?*7|Yui znL=XKJW%r+D`2W0`*Mo#D$a`{rrmChj?Z8A^ERXd%3zM<3?p1b^NlAUexmKzSm~P5y#SmdPzDb_k_ncoH1;n{V1_Neo6=6f;&#PN^7xJK7SVG5vD~}E>?|}w0yQ^u$dqU8i_i6v!fvK@yZqBc9 z;xmLZ*u*GP_l@cWY1`EEgi}ze$f>ItepIQ{ZAR(@(s*f7)_5n?R6B-41d9T^0>024 zHt@Gw+2XbPzNl|>-jOF?NvZSY=hT<{NR!u7HgPM4gnJx`-nIlC#~SJd$W0j0lh7J*fEaQv zs)J4Aoe2C7rN8)&rRDCejt8Zi$KE}wCOp^SvMfhXXEwo-mza1obM)E&?ft>m-8&2} zmqbCQfsZ#MUh@zH&{mShZF>=o!B$A0I5VDg?L5TL8YVl1{^!&R&xe4mVixM;Hp4?w zqllP7{CzkUZ!GY|=QlJpTno<81za%nG@87;O!(-xGIYIkhEWBK~0& zV1xu8BzTgBq-wlI8m^CUPcuIWQA%9wW_1)POIO1y8PYr~anDz&Nk+llIsB+KcNt%q@ZkS)0r&u5?N2T+vXS}I^ zbC_3jWcCjoJ+M-b7rDHt2+cMGDAvOj0u6atWi|9C2SZ$)r2t1@SkH?~CzYr3YVPUc zW9+K+!L@0eKdVXKz-4B*fXwZ2xeRC0U2y0}3wV!6U3i+}tzpg~fq}XP((MaGbB@rD zJt^0NwVj+PebMb;fe1BUx&WT#-JbLFq4-k-v%~dJyGXho?_cHZ=_qq?);sL~N~0e= z8>!pp3R*I(wIe(}?2g%iMgOi#fc3B^@9;&3wSh7s)u$C}TyyTqzzZ-tt{4}+Mpj&8 zL4OjaOi7uckip%329!lQF2G(-Q1(TCm zK0E9gJZeiG!4GXA)3uRLWX>Pt1pyVN2>UueT-mwK--jGYtXhnKZJ!eVF(Z* zdihH1S|AX;w~+_Q`E)xYh)c%AZr~oP6R=88Q2m;{&MQJ%m1lp054IKEOI0(};1-_a z1Ir472J%{QgLON~Oq2GbjJFrgWybc?mp3QUmqk}us+SIhr4=Am6uu(u)?BA%ymH;4 zaE+zjqQ>Z=fPYwv?>u3F$iodV&n~X!q`|^iO}e~}m5P2W7>t~ss?M|_t!b%eX8ZVb{5qHH@7TPFrLrMN;| z3k$S2#q6bcZz=P#Bn-Syz{g265kIU!61UcsUI+mmK!&tAC)kS(vfDaO?qPiRTlB)O zA2hNRs(eBUGL1p_uHU&oyYOz$UhdKv2CXn_l|I;D?e}|g<6buNB~IT$EY?Z(ZvcJ+ zN)?~N9fw8#EJ)884`Y7*)LhaxZgdZxF`k_M>8b3ni&yRRgjE}Q0e`;B``XZF6^y_6 z3BrFv0@e$>DD(gK0S>Q}i&fiV0Pyw5=f+9=wX~$-@G^fcX75Z?>Q%=M`~u4p5hbA1 zXf+)e#h03DuWF-yCC3U+3!P?O8X8CsT5pB|NTClxO@G){PkJ*LKWwcafnKzdVL^^H|pK2!? z^hZ6>q3COnQM{2F7L1}@Md3#o+D_d6a?^cBHMc-@6$B^kL)ra8ZbaYlhR79)8pS!I z(*3e`6k%Cp*zJh4p05cuN8M!fi#LwUaImHMirNm&AQ{~xSyY+x`2+A@40#^In*}P0 z0KEi7vbG0rj1nST7B-MuJr3C!dfl8yGJXg74I~VC-dU8sXc%Y!#Qa61+=&Yf|NYnr zQ9Q-s6-)pVnVr`xbi!Aqz*qF5D+SbW=5Koq(#tqcE(Q>XrG`!)9VrWH?wQp-)#Yl1 zDD9@Q!x&?=VE-_IILtsgRGxSuTD?lq1r98Q4~ci&{X<85WY_HtHTA`!_D7<-^rHOK zu|>ngDM}AEVzcg*=?_~yKqF3`3)J<%pZdojb9jX3!SsK%aTQ=uJ>46mySqCSB&CrK zap~^vkPd+r!KFc11f*L)KuQp#k#400q&tNlAo;)W`F%fD{{#2go#(#qxo2k1nVp$C zbKaSVtoFvezd9_6u?ps-S$rBtgL4gOO4L}H5j&Wbt;qWj))X0@D;s|CH2^N zC#ZThPRj&$use=tf#9(L2i9YSYYW_X0zp8q#F)`Iw@t&i(9{*G+*==L^GNbQPOp8o z-z{ldO+}Hs!sz+k7xEe>pR*pDf5bDlrrVqQj2aX&XJ#voBdjgaSG6u}*&i#hVKICr z#>5=+Gpp;o|GA67; z^?<=9DRpUkRvfM%>9Q$mKvg1j-5wHq<{LMW$PT|1_fL(nh*k@6)kB_u`U)fNAM zYBpHuyG&46WvR6n8SXX4rM$3i@j-%KU3^Z1;`?31BtGJ677wb=g3rt&uh9|eA|1YN z(9RRm&%K|0hGSWc{z*S*@Ot>#Z{&bRP*M0=9;}q=D^|jw$bxFjo6B=db(FW9_^=f- zpO&@m+z;2w>*OC=Dyq5ovV&D!|XLv;|O)% zI8;U+lx%YcJ1-R#;-6l_!!Eg1Z4!mwyA!@57g6hOwkm*>9_7yMDs;SY#JhP;?5}rs zZB1W=!gm3IH4=bTJl5M?TgG3Az;ATIpA$^rU6;l^@YTcYCN1X-Pe`FL zbEe8cVgIdDPs!nVcnQ&aqIo_$DZji8qv>k8MisxVYSWQVXkyu*y>s#F*MaH2pG&mO zvyTqfPUafko}?}f^cyt(%p@^EE~mQ2UZ}vO##tKR+1!^wRp~`tOv6M|IRFnDNclLB zx?n8q@MLs~Sn|!2kwv<4hIsetMz~`!{^F#b8)$!iI{)0)hsON}O(%+v_>c(A@>pn_ zPvRQR;eH%f#?r5QRhesJ^6jUjLo{m(1ZMv6Ybpo(8@S>GoicBbIeQEKx=`snjE0mDX_)+OlziC8~0G(eonYx z^+`&|IQxq-=?!WeDSlp0+tb3(y84K>;(oUNUAtmS8*zbX?>qf8mNpi^t=Sp+GC1#$ zl{u5;BzZZE?myABOEs=MGPLQPWjUm{uP%Q?IrYO5r)*EQRqp)NHn|GltE0GeFJ#hPyDwp# zGzh6a`hi|ewaSnKLjGGc@BpPY%jCF1W}KRPm!^iz^ps^YRU@HBWjp-SF+qK>>d@9W z0{){3-4&W*jxI(8kCG`UqL>Y@e(QmtDQJsu{rI!HomJ~7dT|lecw~YC0TVJAw)OdMqLWJycZTLWS@Q|r>IB|-YzOxNcc4`c+{~MifksP68^r3 zw~S`l!=*{Ja(f4X?tyC7JH9>#QaxD_Eu9B+c<11sba`k$dcp}R2x=4KXw)AjX>qRg zZSi16Zfjs%vOmvDNwkijKrdV+{57?l8yq$4q=4q+tcddoXW3qRs6Cr^mHf`<`+3|Zh2=%*>}rZO zMU-sj;_Q@NxTk#t7Th)%(eUb;LI+9<~ z%!lT&Y_W}Tek^4B6hncIPJ12rC8d~Ay_mmrzU#pOI&W%Y|55c57v*}s+ z*hp^)*6A?-Ji8dk;OT)|xZ ztALj8LOTurxUdZb8byshiJCM`+YN9Yi67Bccryp z)}BL5G=oJ3U}x@ZAkNIsw&%%U(MP4C(SsbL!Pp z?q^xSe_g+~DYq0SYlTRROUaSj6g8aof+BB(cqW7}IwAn|1HmvBsT(|vl)`@efp17= z*CrBQlpJ`uMM+n*o;9NhJwJ+RLRW7_MV^6pgp?%;ae8=RyNe4yFbxopRBPhL;MWu1Fv>p2d5EEb%%O^nq^$t@ws4@^$635BOrotg8ES$L0(y z_CAI;emtHNJDzJKOkNiqpGw`@8NiY%pPl<|yXOSPf1dg<$5XuNqaLL)ZC_p{V$!;orVhcHWqMwY`j zSmWha@B&ZD>WHL>0UO8ah)`1DjJAwSb827{H8L2dgoVnn;|WFI2-TNH!61Rxy8`$` zS-(gqMp+3j9J2%6k<>O+&gKM%6ysU0SC&fjA!1W%qP;~ z0cQvnXJolM{+veICS%gs+frd%t4O*!F$xiF0lTBjDpOf`Fr+A!zxB8C8mm#qdeK78 zS|6RqEQRM-P#5iQMv&81^3l1Vj3}<4?8!-y`Ab#jlsDO6zoGP$DM?-mnwmo!u+xCp zwky}RAgg41o_o_bA)4jGkD>2GLxXo0*q(}T_r#4zHudL~Ubb9G$mhH&JDbl>@p&v& z(GNBgVxnc(tyHN)BMypzN-2dfy3-@kGp4?);(6<&s(Q~EPVis@quX^p3mUlKBZaQ| zX6#ILKTm6Nj1K%G5DXgx44grf8OJi!17owR z>xtdQiQsSGKZa6EuZB&ifwz`G7!o)J;IeTuvvjoL@OFcOO+@dEnV@c_On0(X*``7F zNdMgiwBXyx^%RJLvj%K_7lDHgcalMI1{lGm+6FolT7*~1JZ7fJZsB=E*+j2LL-M|0 zT2A^{=UIczhMwGCS`4?g9(K>K0T=WX&mNzG76C-ai(3fnVNQGtN@YXXyA zn)`7^z^~&uo4vP>udjA+;P!Iq7VyQ155hwt6%JQoLbPVyltZ4UT_j}gDs|8EqGd9G zMWQ?%F)auT{C}C_nmpO369_|TPj1mkn_TUS3HwlYRTdUXPtA>)$7fWfLwXUn{wTQI zK0y;QQd01q;vaiSej3(S=&mpIatk$Uh%}ZwBH1R-Q#zgjXHr&VrZk@r@vD{ChI_PD zg7R%$-pYaaoj@P8qwTdulxDVzvuW@(!2D3mC98jA)@`e-9Uy~Tc;VJK3DQhzYDaGL zHmNDb%p;1i>qcYR)e-vU$-4*r>?!(vi>f2TC)IAVjNJ>}cI8yVHH6>j4Mp(~_)q$| zPV9Z-AL*A3#yit9*i}qEg>R?AA)l};lDhJ=`-tF)Aac$c;ZZqJ>8@5BzR( zq(;5yy+d?Lk$xfu+vNRJai+L94dIHAif>-P*=91bs${%_=qT?v zl7rGH7TRc`tGpLEK!B;7h)JkgXA>%@6FwuNu)9zy$t+9G`=TalPXdHSM(jxDN%?c- zIRwM#pf}7ssBE%s75~*G`P<_s-U|5xZjHs8Ugy0${SEeM!=llmKebt=hrpIX?Wa9H zZ%&A|5Q+J>{qr8SGvtl&Ya}sT6UeAM=E^&(*cown{%Jas5rpmM^l2&nCvKESjgNMf zPhYf$J1O$V@#MU^@%&+K@nB=l0nOeYkm>T-lWl1~_f5``%QA=B`oaySj}>)YxSMYx zFf)I7syCAz*q;c2ASJ@hqn6;<(DyaGXo%w5a}kpUxc}GM7Yp8iIc%j*u&!+clG8Twwnw1Gfe} zuDw6ZyiAEWzJ!fDz9bWYjqGjghb5Ke8Sz5-+a6EbavVdloxTJ8GZi!NnGK`B69QJL z=_nkW_{F(v`aOi|HH(=K!E`8Jxglha%BZBRPw^Z)KDF%MYcgod!=-fPs;5Ln3T*R9 z>#nefI6b9G*15CGS9eqFw=$dVDvRN3wH1f${<CS!V1B=Bw*(%WID1^KDVGUxw#G>jh1Br{ceg)X%-wo$d;5gOr0JgR{FfIo@j+3- z>OMI!?}V|nK9%Ga=N9A+zIImBfjB{A>f8ojZ~I8MmXEwC^8t&bAH4#1o@2|R8@aTk zyYDJ_#s>H7H)4_=3yb2`P@oFD&ThbO98TK(v;^X;8p-7q#7c{U6?3#;2SIdh|`6c50>P$eD1dW z^I7px&DH2IUqZ}34hp4K(M~x$esiNVVFK!nZHwx43~wykS~v*g%b!xVn|J@noj73J3ZQNbFV3aLUuLCTy6xf6- z09YhD6x4$Yexv1}@=cXAPjab%WMLTKq)qv+mBL(=iYSZpTT6QBDG(L!Xw7u+v=(J**q z9`xoJ#FsxXla-4&WGi{1yR@H#KhET+Fidv#h@%U=226^NJIR#R6H-7B?$V7k6W&(`l1p*h=vh ze>KF0a=JjfmLs|x)GoAaCQh2+;`b|6oK>J`Zk2WTyp01}N=b}5Tf6(+_W~u%ZwJT| z`%Lu?-@uF7Q=4(F=C(BpeAW8%ddku`o``SRJXlY#WBUEFyp={HLO{sX2NhOq8x0(7 zNwSa&qhZWT{`U){l93aoXCFfJgRC6D5shRfksNLu6ElfA0zdQ`ek~a-kbH7LBE!7z z^NIuQ1KIfQ~AWw6zu8b&2 z)?|sdX6$A5lt~t>mnTQpo)zWpn7NJm^1+mkiAU=pWFeBaRG}F5U8JM^63zc`W|KfF z22X21La%o5`K~mNr9LJ#owRJ&pIFZ6X4!tOXR=3%JR+vZ!y?b|&O!}}rF7L>9rIzys_XYP1iixznn&ritG^P#1bP?&rdy6byG0WaZMpD!1 zd-zUaBnqxg&4P3plH!dqkHQdHxao(}5wod0nT{xBS4I&;9c?NjLrAV5u;(00d5gKK{GCYc*GI-5T;At%$>UH z0i>5s%V59*w2-WBFOhtM&^RNqJ6mGLl4rk2~WsH_D}k=uHkkwbxwiues+x8hoov zngcE{vqC&E(E1I^kId;^%v)au$u^2Us>rKVoMui0C6FT5B)o5Pg6KAt51DF$8>D-< z)HTZd=j^E!uJZ+-b@S>UZnjO4G=24&unUoAH&xin2 z+R&lILyUix%0J{XiDg15{IyJ^m)TL{Q)vAp#sbRMq1m*ee$#P=$cVu;hpt7<$(Uyp zUMPCI{}2`vF3Ck?7d=^^aRyw@fT2T_ZVyu#F|FR`)KVV3Q zeT(x@pbxVS=$sjkHQR~IlxPCBR6_K#-L^9?r#HoRALUmbHzb-Q4J9;v_E4!~qDqj# zmsTt>ANWE5c^4q0Ekpa>nRZs<)u!e#cI%}$s*x*mI9eW=`h+j9tV#7`Bq98CBeP0dpklXT?;OZ&jPk($S@mE?I;i#pa5ngC^PWT#l zJybJv(FAo`md}#bt>W6nzDA8=;|+O*y9_HwEe}f_VD!tLadJ&w^=*U-cc@QiY%otT z_Ixh25UAyxg*W7mUV^u_dkhK16AzV-OKT_X!316jm?l))#VcTabt1x7m01<}!;SoYzbCvG#y1#hd+z3;OHHywH<6=Je@Bi6 zrg;BJ&_NR#@O>o?_QC$4!}|lECmfahBfMXE5rMcHD5xKZM%9S`Ah041nf zyWs=wcAJ0*(SIb6tfnM~qOu&YOBR;)zbMrJ0T6bRQ2z4)2Muke!npGU=!a(Fn+Yjg z8gRL|+59C7y3kB4egkOv4fxNr^)K8F?828Odm@?VbB{~smh&%(k)VSy3#o%B|e2r4b^pCqVrD=9Eu zj-a_kWVX`WRSxFJJd8wc`rr7Q6ZFI|_Cb9skcQ(HpwdcsSKz-VlHlOzVFaRa-r_Mk zVDW$K-~N|E#ea+Mr@{KG8S;I(>a}ky3;4~U`IiBF11sE;u!u3CwCzZD6|<+ce_ag^ zglR{BgA;_|;49s7vPiL@b{(*uz}0?Jb(Emr0D`|XvGnWz5f4Db09WLKk*c5xkOb~( z(2r#-7YAtl3m7YMz+hSRZXaO$1qG^${r5v)-jKl%5es6Clw;4J}ar_OI7NH@b&<-695u zXzx~p36A*J+#8jF&XyNOB9-57JTla#q(`1WSM0~Pee5R7xEOQBH}0&1QBrYQ;|kSOYwqhLw-cfr#KZuBIHy+!Q0 zqd?osi2sxfGeLrpj2{1+NAkC1&fXizq;LKw`Th&MKPAhLSZ~JzI;R1x3|qBCN&n+D z_2b=b4w%6zj5eol?{IF$uS75v$aUMsU(;?u&O;0*cgqHWhV4>*^L=8X<&;m8^0H!pX4FHxN z`v2K@q$IziKj26hKtCu9L)veKPK1j>CHi1Y0}3bd_6C0eJV*!z|D_emT`Te@9_PAU z=VpKbl=qLmIjJ3b&;-0d{r$85-!akM!443(YN+qSXGcGWKIKHYEd?(Jc$%x@MeBQhehvj%jw z5EMa478DEy2nY%a2u%D*DhYuQ_5UJXZmv%eARr*kBq3!SoJ;KN3+yMCM;BnA|2y<9 z_TNx74$S`<`v1^8@&81h)c*seSU_xJ{ok!)`(f*7AV5G)U_d}(Kq-&o_{e0c-mY%u z_DU{}PUbFd*5At_8?!lDNKXID*fDkk<-7 zH^vja;2X8c#t6haV%N({0GeE7J0^22;R`9b!khUfvWhne$$hKgZFM_T>c$~WD%|=t z5mNKnGZo6Hw>F~rjJ@>!OW00Iuc(?Q;|+MgWKPaAtOi!PuE?!wmjP9dX(lEF9Iiss zZxG;*15qk3f0YT~Pyke_pH)@1s~8fMch8t=R@#gSak$glNKCr_LC} zPLDK42;mdCOGY{Fbb$DGUm@3YNMU=NTgA}n67H-b%EhEsFF-4P5Y8cXnx7IE*V=fc z?N2x~Dg8Eu?$8{ty?Y`6RmPnq&4dS<5r#?~t1|K?!|J^kVTAX`f&VIIi;U>n}C z{>~UU5YXTM2*~>XvJE{24x0oZ>x62K_s>3?!n)g@Scq834k)ZuDVPdlB{+T4#7d_S zz6>0_V~Rt+doM0WUe=#T;Ofi$;Uceug_K?kavWPyaw3H1BnOyzt|cRril8jh-)@JpLbxCLRl5`jDg?q4!5REzXz zcIr8$o|ux5>NE`qcl8fe*695Tt?bT>NCRK3k~FR9R+pySGBbB}4hQPbUYoIPMza?a zVO&*SHhudUvsIBIS?1JS8{|qwXDF2IVh6lzY*ZKeyE-ddA39S2PFAsbRXDyZRW}1) zJKJo`-;YCYcGH#20%Bc{oH?unzrR_|@(Co(KvAN<$G+?NN{hqsl@O_by>EQI;B zSJiY2h(3JR@xpaw+jA1Q+tm{ZvX&H_8SR}Bt8njzk%2?JLcHMo$nq#S+ zSO|s{uzMO+gZTtyuo(xF8=W7`@TqNlG}_G2jAp1zVsD!OnhUX$SeT(y43n9IF#)|~ zC+58*#w#-78HY{d=6a(w0eZ=gI0hL>gQ@|%A{&4zcMchop0e&@}^MJ%`RuA_L- zP^VHebrSMY% zo`MHQvnZPG)IPNmH6iA@_RD0l8?E*+Oe_?99-id2+F;P%+2BeaN|<(@>2OB*5GsX? z^6PBc*$S#jCX^P^etV8&^>DxIYLM9&d->Ab^V=W*dR-bpr?=3`$PMyYb#Gw}&GmI0 z0cz5kcuI|Vb@<^91Z=rM>l$h_|17pX!-juLrKS(=MFL_*e`l&|!~0p?W6edkn9UK@k@olXZyaCps?2Lct6dYtS=OeclmxIej}q=*a5HzjC$URNJQ z8NgGlp!b7!)Ts-DoJ{b72|?6qp}~6PO5E^WJyE;VYI*6$2Gj-t@N07d)C5qF{gRN3 zZT;hnM_FnDKX7xbmDpA=by?%vot!s$B2)$dg7PaK$-j3C#xxCxzFkUXc277Qdi}hl zH*9l-9)(~;tx5S|Q`B}aVcrzQb1g>gySuLV?kLCT#>Kl=SY#9v6i0imNcA5i1bPM( zUaSV(ij7Ycte@QF&M{ftVF!8sgaO0&=f52-8QfU|kv{c3neSAtn}c$kD$UT4nU5g= zzOoO_I>N6{$17yGB%a`jL`l|z(U8ti4Y^|^{TEf8u=BVFLc}$7Smpl9~=af zb+If1#PJy8y@&%SE5xZMOlg0(KPjp7{*h4|s9Z&9yzC*j(+@zEVerrFowQzfR@(H& zLz3x*bGnC_F@o1rp0(T1$AMBx-;(zwgXbVCgUMD%|oLxCL;0PBZ# z+9pWZv$aJxLW?v!Z7W9N9l?o%1TUSqiIn)jM0925nH+mVdN-deR;Lpqv(~SqyQEQ@ zU_!61EWJpzY2)AiR@Bzkwx(h0czxT~_x`rO^F{Lxu<+o_F+)0L9Qd&~^UXc~mwWGL zXz=s3A{k{x3_vOrn9mrej3bEy7-k29umM|-PgUR)vJf;pfFVBSEPzuDqMF+!v!#IQ zO{$h=;T)3WV-)gsYGq%-K!UQRSx$au!7MyI-vBKb%O9P?#iSD(0i)+Uy#WLG=u&9TEUK0J$rVNS)40cS7BmIA5@GNxPsU6H=&v3dA~ zCiSn+Fy8fahpt}gfeWY4AYL`9UhaYEA(-P=W!8!1%2c(;Fy~+1buVnxenGp*pK`OwJ7&x6Ky~N0z&1xQ==;cp z8b>?B86Tf2pPh2qG-0m*pf=~8;UXaWa}U9YAG?n((Lc9zhYTFzH8VfGs`<#s6xB|2 zLimkj*&keYXSAi#RJm}A3J$5`$x+l{p47Xjg$`M`=LXt~l+gy*hczEIpS?Q!0`vl1 zI!Csd!SHdmR~(#<-K|Ac?knTvYdM_kb&g$bqI>0Sa$$2B*F7(Q>_~#^TI)6xi&|Ik zm5;w(bk>*5wlM_K!cVqYeo0=I6-h?fOL&TI>uwY;PNS8V(ieMs62;|P1fwzlu6@*) z(O0GwyN)P%_)F)bSa%aJyIs?R2#d)&~>_(JH9gJko!=%SUR{yTMZ{<}WNwQHH2^JH{ysF(e`u+$$*( zRB23g{n_q-KnkZVBM%&DSq%>;i0Ed%Y?D1|ST}h7XytHqzuT&K1mS@Fw>7B#H-SLm z4!3d;CU-KUYyp|-wKtl@%q?pz_ofF7Qe%&o(`_45J_IRxtfB*}R|(=Ou8lQ#36@l* z9(+Wf5etwjITsBhK9v=_Z65_(0$~5WEUHC`S$%Fm?hcK1&rA|Oe(bBhgq5X=UrVc7 zYb)g%gmbkRKI34x&!o&H^3DqQlot&$i=9^jt4~i{qIu{oGGH_aO>JfOD6ytmQ<3b9 z*rGk5HHXt=lqu1&nA(b+*R(rL2CGD$DY-mGx||ZerU|pKc{Ca~R%}&{0(>&EHfgmT zYQ_wJL3-iYXdKFk%b+S}RAevgG zg{)XRinT2HOr!WH3SN{7zgEUnkYlJXHP>8dE|WfT4Y#V;Pxt|C(9n!WM}M zU7T)0ovsUFX~ihWU6*#14PQqoQfJ4|*$KyBOdG?@A3kn6TkG8)6V*$H&OkTy@N{T3 zlcFbVX>aaaQ#eOBUVG`a$tqudehCK13>0~)^T_?s{Nx4{EfevVEW5v8r_~qPs{4cz zs9#F-6U0uaIZYQYRJZ0hC|&yetsU-B?+?3@%quw7fQ2YeY{8UJL zf7G93xHAtK_N@74|LvZBe+WpsKXsIHEtr0fK|!qiCi;QH9a=n&tI9N&h_f5E=)UpO zrKIoBSUk%hJGVXROjFXuFzi?+p=`jfpBWqYE~9qYWLNEbeheA}np5ocZy z0VGc)jnMC!Y3W(bD)j01i@K&3i$;WxR%A`cn>Yqn-4)dCSGr&H!`B_KnCF&D_L?ni z`ODYT^-)@6U&Pc}y4_&oisn4&2hVNyqPQ@0{Bh{^D0}f5)ysx7@(2JC+u5si*;%2^ zD8;0BsOjX{`GHoKp%Af~H_J0^@nU?{m)U7j>R<7xp(hA34A>1S!i7}FR&Gy~JVN~)29Wa$Poa<~A4#;R}7ERL@eTaNOl zzg{#b?V|kNDBw-Hj*(bCDo*87U0Nn63X-E)_#6^nZtNPhCC)5oW#gfk7n+0 zr+L$Ye>Tv6lcojF3M#)<13*pr704@}a^MOE#snp?uXKJZUx@#S9CfJwmKC08ek+VG zmrS#z0pJOr9l-#+tdcyYKaGFweH>)w@lgbOX&t;3%LJDsVg6~H9z4F%3@mt(bmV*K z3Vr5WCM8PFby(d%p2k|*zn^}`98Ae%gYnF zyz8w|368g6B^Yc$i?vlzuBb)h4ZQg?7;sC!S$&B4S*3$@MvUNJSpE4kbILBbtmEYV z#P|m⪼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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index efc12cb..b6517bb 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Thu Sep 29 00:03:59 AEST 2016 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.2.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip diff --git a/gradlew b/gradlew index 27309d9..cccdd3d 100755 --- a/gradlew +++ b/gradlew @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh ############################################################################## ## @@ -33,11 +33,11 @@ DEFAULT_JVM_OPTS="" # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" -warn ( ) { +warn () { echo "$*" } -die ( ) { +die () { echo echo "$*" echo @@ -154,11 +154,19 @@ if $cygwin ; then esac fi -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " } -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" +APP_ARGS=$(save "$@") -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 832fdb6..e95643d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,90 +1,84 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega From ece1a27365fb7a199f09fd7dda08a5fc4548ce9b Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Fri, 15 Dec 2017 15:42:08 +1000 Subject: [PATCH 09/17] Use JDK from RH Container Catalog for client --- client/Dockerfile | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/client/Dockerfile b/client/Dockerfile index 775e958..dfc4320 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -1,13 +1,12 @@ -FROM openjdk:8-jre-alpine +# https://access.redhat.com/containers/?tab=overview&platform=docker#/registry.access.redhat.com/redhat-openjdk-18/openjdk18-openshift +FROM registry.access.redhat.com/redhat-openjdk-18/openjdk18-openshift:1.2-6 -ADD build/libs/client*-fat.jar /app/client.jar -RUN chgrp -R 0 /app/ && chmod -R 775 /app/ +ADD build/libs/client*-fat.jar /deployments/ -WORKDIR /app/ - -USER 64738 +# NB run-java.sh will scale heap size to 50% of container memory size # NB must use the exec form of ENTRYPOINT if you want to add arguments with CMD # https://docs.docker.com/engine/reference/builder/#exec-form-entrypoint-example -ENTRYPOINT ["java", "-Xmx32M", "-jar", "client.jar"] +# see https://github.com/fabric8io-images/run-java-sh +ENTRYPOINT ["/opt/run-java/run-java.sh"] From 30b34dce64d32feeae1f9d59cf04c4562dff4fab Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Fri, 15 Dec 2017 16:41:40 +1000 Subject: [PATCH 10/17] Refactor http proxy code --- .../zanata/proxyhook/client/ProxyHookClient.kt | 16 +++++++++++++--- .../zanata/proxyhook/client/IntegrationTest.kt | 10 +++++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/client/src/main/java/org/zanata/proxyhook/client/ProxyHookClient.kt b/client/src/main/java/org/zanata/proxyhook/client/ProxyHookClient.kt index 0c0e46a..4126b35 100644 --- a/client/src/main/java/org/zanata/proxyhook/client/ProxyHookClient.kt +++ b/client/src/main/java/org/zanata/proxyhook/client/ProxyHookClient.kt @@ -65,8 +65,17 @@ import java.net.UnknownHostException * @param ready optional Future which will complete when deployment is complete. * @author Sean Flanigan [sflaniga@redhat.com](mailto:sflaniga@redhat.com) */ -class ProxyHookClient(var ready: Future? = null, var args: List? = null, val internalHttpProxy: Int? = null) : AbstractVerticle() { - constructor(ready: Future?, vararg args: String, internalHttpProxy: Int? = null) : this(ready, args.asList(), internalHttpProxy) +class ProxyHookClient( + var ready: Future? = null, + var args: List? = null, + val internalHttpProxyHost: String? = null, + val internalHttpProxyPort: Int? = null) : AbstractVerticle() { + constructor( + ready: Future?, + vararg args: String, + internalHttpProxyHost: String?, + internalHttpProxyPort: Int? = null) : + this(ready, args.asList(), internalHttpProxyHost, internalHttpProxyPort) companion object { private val APP_NAME = ProxyHookClient::class.java.name @@ -167,6 +176,7 @@ class ProxyHookClient(var ready: Future? = null, var args: List? = private fun startClient(webSocketUrl: String, webhookUrls: List, startFuture: Future) { log.info("starting client for websocket: $webSocketUrl posting to webhook URLs: $webhookUrls") + log.info("Using internal http proxy: $internalHttpProxyHost:$internalHttpProxyPort") webhookUrls.forEach { this.checkURI(it) } @@ -195,7 +205,7 @@ class ProxyHookClient(var ready: Future? = null, var args: List? = val httpOptions = HttpClientOptions().apply { isVerifyHost = !sslInsecureDelivery isTrustAll = sslInsecureDelivery - internalHttpProxy?.let { portNum -> + internalHttpProxyPort?.let { portNum -> proxyOptions = ProxyOptions().apply { host = "localhost" port = portNum diff --git a/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt b/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt index 591ab24..c929bf6 100644 --- a/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt +++ b/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt @@ -78,7 +78,7 @@ class IntegrationTest { @Test fun rootDeploymentWithProxy() { - deliverProxiedWebhook(prefix = "", internalHttpProxy = proxyRule.httpPort) + deliverProxiedWebhook(prefix = "", httpProxyHost = "localhost", httpProxyPort = proxyRule.httpPort) // proxyClient.dumpToLogAsJSON(request()) proxyClient.verify(request(), once()) } @@ -93,7 +93,7 @@ class IntegrationTest { verify(request(), exactly(0)) } - private fun deliverProxiedWebhook(prefix: String, internalHttpProxy: Int? = null): Unit = runBlocking { + private fun deliverProxiedWebhook(prefix: String, httpProxyHost: String? = null, httpProxyPort: Int? = null): Unit = runBlocking { // this future will succeed if the test passes, // or fail if something goes wrong. val testFinished = CompletableFuture() @@ -105,7 +105,7 @@ class IntegrationTest { val websocketUrl = "ws://localhost:${serverPort.await()}$prefix/listen" val postUrl = "http://localhost:${serverPort.await()}$prefix/webhook" // wait for proxyhook server and webhook receiver before starting client - val client = startClient(testFinished, websocketUrl, receiveUrl, internalHttpProxy) + val client = startClient(testFinished, websocketUrl, receiveUrl, httpProxyHost, httpProxyPort) // wait for client login before sending webhook to proxyhook server client.await() @@ -146,10 +146,10 @@ class IntegrationTest { actualPort } - private fun startClient(testFinished: CompletableFuture, websocketUrl: String, receiveUrl: String, internalHttpProxy: Int?): Future { + private fun startClient(testFinished: CompletableFuture, websocketUrl: String, receiveUrl: String, internalHttpProxyHost: String?, internalHttpProxyPort: Int?): Future { log.info("deploying client") val clientReady = Future.future() - client.deployVerticle(ProxyHookClient(clientReady, websocketUrl, receiveUrl, internalHttpProxy = internalHttpProxy)) { + client.deployVerticle(ProxyHookClient(clientReady, websocketUrl, receiveUrl, internalHttpProxyHost = internalHttpProxyHost, internalHttpProxyPort = internalHttpProxyPort)) { if (it.failed()) { testFinished.completeExceptionally(it.cause()) } From 11ec71b23abb2a35cd33d9edd4cc9221c43a724d Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Fri, 15 Dec 2017 18:00:00 +1000 Subject: [PATCH 11/17] Support running with or without cluster --- .../proxyhook/client/IntegrationTest.kt | 48 ++++++++--- .../zanata/proxyhook/server/LocalAsyncMap.kt | 84 +++++++++++++++++++ .../proxyhook/server/ProxyHookServer.kt | 23 ++++- 3 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 server/src/main/java/org/zanata/proxyhook/server/LocalAsyncMap.kt diff --git a/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt b/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt index c929bf6..5516956 100644 --- a/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt +++ b/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt @@ -45,39 +45,62 @@ class IntegrationTest { @Before fun before() = runBlocking { - val serverOpts = VertxOptions().apply { - clusterManager = io.vertx.test.fakecluster.FakeClusterManager() -// clusterManager = io.vertx.ext.cluster.infinispan.InfinispanClusterManager() - clusterHost = "localhost" - clusterPort = 0 - isClustered = true - } - server = awaitResult { - Vertx.clusteredVertx(serverOpts, it) - } webhook = Vertx.vertx() client = Vertx.vertx() } @After - fun after() = runBlocking { + fun after() = runBlocking { // to minimise shutdown errors: // 1. we want the client to stop delivering before the webhook receiver stops // 2. we want the client to disconnect from server before server stops awaitResult { client.close(it) } awaitResult { webhook.close(it) } awaitResult { server.close(it) } - Unit } @Test fun rootDeployment() { + server = Vertx.vertx() + deliverProxiedWebhook(prefix = "") + proxyClient.verifyZeroInteractions() + } + + @Test + fun rootDeploymentInFakeCluster() { + server = runBlocking { + awaitResult { + val serverOpts = VertxOptions().apply { + isClustered = true + clusterManager = io.vertx.test.fakecluster.FakeClusterManager() + } + Vertx.clusteredVertx(serverOpts, it) + } + } + deliverProxiedWebhook(prefix = "") + proxyClient.verifyZeroInteractions() + } + + @Test + fun rootDeploymentInInfinispanCluster() { + server = runBlocking { + awaitResult { + val serverOpts = VertxOptions().apply { + isClustered = true + clusterManager = io.vertx.ext.cluster.infinispan.InfinispanClusterManager() + clusterHost = "localhost" + clusterPort = 0 + } + Vertx.clusteredVertx(serverOpts, it) + } + } deliverProxiedWebhook(prefix = "") proxyClient.verifyZeroInteractions() } @Test fun rootDeploymentWithProxy() { + server = Vertx.vertx() deliverProxiedWebhook(prefix = "", httpProxyHost = "localhost", httpProxyPort = proxyRule.httpPort) // proxyClient.dumpToLogAsJSON(request()) proxyClient.verify(request(), once()) @@ -85,6 +108,7 @@ class IntegrationTest { @Test fun subPathDeployment() { + server = Vertx.vertx() deliverProxiedWebhook(prefix = "/proxyhook") proxyClient.verifyZeroInteractions() } diff --git a/server/src/main/java/org/zanata/proxyhook/server/LocalAsyncMap.kt b/server/src/main/java/org/zanata/proxyhook/server/LocalAsyncMap.kt new file mode 100644 index 0000000..b545c07 --- /dev/null +++ b/server/src/main/java/org/zanata/proxyhook/server/LocalAsyncMap.kt @@ -0,0 +1,84 @@ +package org.zanata.proxyhook.server + +import java.util.ArrayList +import java.util.HashMap +import java.util.Objects +import io.vertx.core.AsyncResult +import io.vertx.core.Future +import io.vertx.core.Future.succeededFuture +import io.vertx.core.Handler +import io.vertx.core.shareddata.AsyncMap +import io.vertx.core.shareddata.LocalMap + +// From https://github.com/eclipse/vert.x/issues/2137#issuecomment-330824746 +// Thanks to https://github.com/mr-bre +class LocalAsyncMap(private val map: LocalMap) : AsyncMap { + + override fun get(k: K, resultHandler: Handler>) { + resultHandler.handle(succeededFuture(map[k])) + } + + override fun put(k: K, v: V, completionHandler: Handler>) { + map.put(k, v) + completionHandler.handle(succeededFuture()) + } + + override fun put(k: K, v: V, ttl: Long, completionHandler: Handler>) { + put(k, v, completionHandler) + } + + override fun putIfAbsent(k: K, v: V, completionHandler: Handler>) { + completionHandler.handle(succeededFuture(map.putIfAbsent(k, v))) + } + + override fun putIfAbsent(k: K, v: V, ttl: Long, completionHandler: Handler>) { + putIfAbsent(k, v, completionHandler) + } + + override fun remove(k: K, resultHandler: Handler>) { + resultHandler.handle(succeededFuture(map.remove(k))) + } + + override fun removeIfPresent(k: K, v: V, resultHandler: Handler>) { + resultHandler.handle(succeededFuture(map.removeIfPresent(k, v))) + } + + override fun replace(k: K, v: V, resultHandler: Handler>) { + resultHandler.handle(succeededFuture(map.replace(k, v))) + } + + override fun replaceIfPresent(k: K, oldValue: V, newValue: V, resultHandler: Handler>) { + resultHandler.handle(succeededFuture(map.replaceIfPresent(k, oldValue, newValue))) + } + + override fun clear(resultHandler: Handler>) { + map.clear() + resultHandler.handle(succeededFuture()) + } + + override fun size(resultHandler: Handler>) { + resultHandler.handle(succeededFuture(map.size)) + } + + override fun keys(resultHandler: Handler>>) { + resultHandler.handle(succeededFuture(map.keys)) + } + + override fun values(resultHandler: Handler>>) { + val result = ArrayList(map.values) + resultHandler.handle(succeededFuture(result)) + } + + override fun entries(resultHandler: Handler>>) { + val result = entriesToMap(map.entries) + resultHandler.handle(succeededFuture(result)) + } + + private fun entriesToMap(entries: Set>): Map { + val map = HashMap(entries.size * 2) + for (entry in entries) { + map.put(entry.key, entry.value) + } + return map + } +} diff --git a/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt b/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt index 1500592..7abb237 100644 --- a/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt +++ b/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt @@ -20,7 +20,10 @@ */ package org.zanata.proxyhook.server +import io.vertx.core.AsyncResult import io.vertx.core.Future +import io.vertx.core.Future.succeededFuture +import io.vertx.core.Handler import io.vertx.core.Vertx import io.vertx.core.VertxOptions import org.mindrot.jbcrypt.BCrypt @@ -115,23 +118,35 @@ class ProxyHookServer( } private inner class ConnectionManager(val connections: AsyncMap, val connectionCount: Counter) + private val sharedData by lazy { vertx.sharedData() } private val eventBus: EventBus get() = vertx.eventBus() private val passhash: String? = getenv(PROXYHOOK_PASSHASH) // map of websockets which are connected directly to this verticle (not via clustering) // TODO a plain local HashMap might be more appropriate - private val localConnections: LocalMap by lazy { - vertx.sharedData().getLocalMap("localConnections") + private val localConnections by lazy { + sharedData.getLocalMap("localConnections") } private lateinit var manager: ConnectionManager + fun getAsyncMap(name: String, resultHandler: Handler>>) { + if (vertx.isClustered) { + log.info("Vert.x is in cluster mode: returning a cluster-wide map") + sharedData.getClusterWideMap(name, resultHandler) + } else { + log.info("Vert.x is not in cluster mode: wrapping a LocalMap") + val asyncMap = LocalAsyncMap(sharedData.getLocalMap(name)) + resultHandler.handle(succeededFuture(asyncMap)) + } + } + suspend override fun start() { // a counter of websocket connections across the vert.x cluster val connectionCount: Counter = awaitResult { - vertx.sharedData().getCounter("connectionCount", it) + sharedData.getCounter("connectionCount", it) } // a map of websocket connections across the vert.x cluster val connections: AsyncMap = awaitResult { - vertx.sharedData().getClusterWideMap("connections", it) + getAsyncMap("connections", it) } this.manager = ConnectionManager(connections, connectionCount) From f436095a54c6c3cff34052eaaf4a170dd0ce9b83 Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Tue, 6 Mar 2018 16:09:44 +1000 Subject: [PATCH 12/17] Upgrade Kotlin to 1.2.30 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 4cc24a5..be64919 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.2.10' + ext.kotlin_version = '1.2.30' repositories { mavenCentral() } From 15c54954ea48fc5aeeed357f30992c9fefd15933 Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Wed, 7 Mar 2018 16:12:07 +1000 Subject: [PATCH 13/17] Upgrade to vert.x 3.5.1 --- build.gradle | 9 +++++---- client/build.gradle | 10 ++++++---- server/build.gradle | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/build.gradle b/build.gradle index be64919..331a43d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,6 @@ buildscript { ext.kotlin_version = '1.2.30' + ext.vertx_version = '3.5.1' repositories { mavenCentral() } @@ -28,10 +29,10 @@ subprojects { dependencies { compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version" - compile 'io.vertx:vertx-core:3.5.0' - compile 'io.vertx:vertx-web:3.5.0' -// testCompile 'io.vertx:vertx-unit:3.5.0' - compile 'io.vertx:vertx-lang-kotlin-coroutines:3.5.0' + compile "io.vertx:vertx-core:$vertx_version" + compile "io.vertx:vertx-web:$vertx_version" +// testCompile "io.vertx:vertx-unit:$vertx_version" + compile "io.vertx:vertx-lang-kotlin-coroutines:$vertx_version" testCompile 'junit:junit:4.12' testCompile 'net.wuerl.kotlin:assertj-core-kotlin:0.2.1' } diff --git a/client/build.gradle b/client/build.gradle index f5ff7ce..c438661 100644 --- a/client/build.gradle +++ b/client/build.gradle @@ -10,21 +10,23 @@ sourceSets { dependencies { compile project(':common') + compile 'com.xenomachina:kotlin-argparser:2.0.4' + compile "io.vertx:vertx-shell:$vertx_version" testCompile project(':server') testCompile 'org.jetbrains.kotlinx:kotlinx-coroutines-core:0.18' testCompile 'org.asynchttpclient:async-http-client:2.1.0-alpha20' testRuntime 'org.slf4j:slf4j-simple:1.7.25' testCompile 'org.mock-server:mockserver-netty:3.11' // for FakeClusterManager: https://github.com/eclipse/vert.x/issues/2191 - testCompile 'io.vertx:vertx-core:3.5.0:tests' + testCompile "io.vertx:vertx-core:$vertx_version:tests" // Enabling this should cause the IDE to download the source for // FakeClusterManager. (You'll still have to select Choose Sources on // FakeClusterManager.class, then browse to the test-sources jar in a // nearby directory.) -// testSourcesCompile 'io.vertx:vertx-core:3.5.0:test-sources' +// testSourcesCompile "io.vertx:vertx-core:$vertx_version:test-sources" -// compile 'io.vertx:vertx-hazelcast:3.5.0' -// compile 'io.vertx:vertx-infinispan:3.5.0' +// compile "io.vertx:vertx-hazelcast:$vertx_version" +// compile "io.vertx:vertx-infinispan:$vertx_version" // // http://vertx.io/docs/vertx-infinispan/java/#_configuring_for_kubernetes_or_openshift_3 // compile 'org.infinispan:infinispan-cloud:9.1.2.Final' // compile 'org.jgroups.kubernetes:jgroups-kubernetes:1.0.3.Final' diff --git a/server/build.gradle b/server/build.gradle index e39d081..5d05cf3 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -6,7 +6,7 @@ plugins { dependencies { compile project(':common') compile 'de.svenkubiak:jBCrypt:0.4.1' - runtime 'io.vertx:vertx-infinispan:3.5.0' + runtime "io.vertx:vertx-infinispan:$vertx_version" // // http://vertx.io/docs/vertx-infinispan/java/#_configuring_for_kubernetes_or_openshift_3 runtime 'org.infinispan:infinispan-cloud:9.1.2.Final' } From 83964968b38106807e80c8b1e42321a2a14e335c Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Wed, 7 Mar 2018 17:59:22 +1000 Subject: [PATCH 14/17] Allow client to use multiple websockets --- client/build.gradle | 24 +-- .../proxyhook/client/ProxyHookClient.kt | 173 +++++++++--------- .../proxyhook/client/IntegrationTest.kt | 2 +- 3 files changed, 93 insertions(+), 106 deletions(-) diff --git a/client/build.gradle b/client/build.gradle index c438661..fa8b56f 100644 --- a/client/build.gradle +++ b/client/build.gradle @@ -32,36 +32,14 @@ dependencies { // compile 'org.jgroups.kubernetes:jgroups-kubernetes:1.0.3.Final' } -mainClassName = 'io.vertx.core.Launcher' -def mainVerticleName = 'org.zanata.proxyhook.client.ProxyHookClient' +mainClassName = 'org.zanata.proxyhook.client.ProxyHookClient' // enable debugger on a random port applicationDefaultJvmArgs = ['-Xdebug', '-Xrunjdwp:transport=dt_socket,address=0,server=y,suspend=n', '-Dsun.net.inetaddr.ttl=0', '-Dsun.net.inetaddr.negative.ttl=0'] - -// Vert.x watches for file changes in all subdirectories -// of src/ but only for files with .kt extension -// NB this won't pick up changes in :common -def watchForChange = 'src/**/*.kt' - -// Vert.x will call this task on changes -def doOnChange = 'gradlew classes' - -//noinspection GroovyAssignabilityCheck -run { - def urls = System.getProperty('urls') ?: '' - args = ['run', mainVerticleName] + urls.tokenize() - // redeploy doesn't stop the old code, for some reason -// args = ['run', mainVerticleName, "--redeploy=$watchForChange", "--launcher-class=$mainClassName", "--on-redeploy=$doOnChange"] + urls.tokenize() -} - shadowJar { classifier = 'fat' - manifest { - attributes "Main-Verticle": mainVerticleName - } - mergeServiceFiles { include 'META-INF/services/io.vertx.core.spi.VerticleFactory' } diff --git a/client/src/main/java/org/zanata/proxyhook/client/ProxyHookClient.kt b/client/src/main/java/org/zanata/proxyhook/client/ProxyHookClient.kt index 4126b35..7612d65 100644 --- a/client/src/main/java/org/zanata/proxyhook/client/ProxyHookClient.kt +++ b/client/src/main/java/org/zanata/proxyhook/client/ProxyHookClient.kt @@ -20,8 +20,12 @@ */ package org.zanata.proxyhook.client +import com.xenomachina.argparser.ArgParser +import com.xenomachina.argparser.DefaultHelpFormatter +import com.xenomachina.argparser.mainBody import io.netty.buffer.Unpooled import io.vertx.core.AbstractVerticle +import io.vertx.core.CompositeFuture import io.vertx.core.Future import io.vertx.core.MultiMap import io.vertx.core.Vertx @@ -32,12 +36,12 @@ import io.vertx.core.http.HttpClientResponse import io.vertx.core.http.WebSocket import io.vertx.core.http.impl.FrameType import io.vertx.core.http.impl.ws.WebSocketFrameImpl +import io.vertx.core.json.DecodeException import io.vertx.core.json.JsonObject import io.vertx.core.logging.LoggerFactory import io.vertx.core.net.ProxyOptions import org.zanata.proxyhook.common.Constants.EVENT_ID_HEADERS import org.zanata.proxyhook.common.Constants.MAX_FRAME_SIZE -import org.zanata.proxyhook.common.Constants.PATH_WEBSOCKET import org.zanata.proxyhook.common.Constants.PROXYHOOK_PASSWORD import org.zanata.proxyhook.common.Keys.BUFFER import org.zanata.proxyhook.common.Keys.BUFFER_TEXT @@ -65,20 +69,22 @@ import java.net.UnknownHostException * @param ready optional Future which will complete when deployment is complete. * @author Sean Flanigan [sflaniga@redhat.com](mailto:sflaniga@redhat.com) */ +// TODO add http proxy class ProxyHookClient( - var ready: Future? = null, - var args: List? = null, + val ready: Future? = null, + val webSocketUrls: List, + val webhookUrls: List, val internalHttpProxyHost: String? = null, val internalHttpProxyPort: Int? = null) : AbstractVerticle() { - constructor( - ready: Future?, - vararg args: String, - internalHttpProxyHost: String?, - internalHttpProxyPort: Int? = null) : - this(ready, args.asList(), internalHttpProxyHost, internalHttpProxyPort) + + init { + if (webSocketUrls.isEmpty() || webhookUrls.isEmpty()) { + throw StartupException("Must provide at least one websocket and at least one webhook") + } + } companion object { - private val APP_NAME = ProxyHookClient::class.java.name + private val APP_NAME = ProxyHookClient::class.java.simpleName private val log = LoggerFactory.getLogger(ProxyHookClient::class.java) private val sslInsecureServer: Boolean by lazy { @@ -140,9 +146,24 @@ class ProxyHookClient( // deliberately not included: Connection, Host, Origin, If-*, Cache-Control, Proxy-Authorization, Range, Upgrade .map { it.toLowerCase() } - @JvmStatic fun main(args: Array) { - // TODO add http proxy - Vertx.vertx().deployVerticle(ProxyHookClient(ready = null, args = args.toList()), { result -> + @JvmStatic fun main(args: Array) = mainBody(APP_NAME) { + class MyArgs (parser: ArgParser) { + val webSocketUrls: List by parser.adding("-s", "--websocket", help = "connect to websocket (proxyhook server), eg wss://proxyhook.example.com/"); + val webhookUrls: List by parser.adding("-k", "--webhook", help = "deliver webhooks to web server, eg http://target1.example.com/webhook") + } + + val helpFormatter = DefaultHelpFormatter( + prologue = "ProxyHookClient connects to a ProxyHook server, receives proxied webhooks over a websocket, then forwards them to a specified web server", + epilogue = "Note that at least one WEBSOCKET and at least one WEBHOOK must be provided.") + val argParser = ArgParser( + args = if (args.isEmpty()) arrayOf("--help") else args, + helpFormatter = helpFormatter) + val opts = argParser.parseInto(::MyArgs) + + if (opts.webSocketUrls.isEmpty()) throw StartupException("Must specify at least one websocket") + if (opts.webhookUrls.isEmpty()) throw StartupException("Must specify at least one webhook") + + Vertx.vertx().deployVerticle(ProxyHookClient(ready = null, webSocketUrls = opts.webSocketUrls, webhookUrls = opts.webhookUrls), { result -> result.otherwise { e -> exit(e) } @@ -150,80 +171,65 @@ class ProxyHookClient( } } - // TODO use http://vertx.io/docs/vertx-core/java/#_vert_x_command_line_interface_api - // not this mess. - // Command line is of the pattern "vertx run [options] main-verticle [verticle_args...]" - // so strip off everything up to the Verticle class name. - private fun findArgs(): List { - args?.let { return it } - val processArgs = vertx.orCreateContext.processArgs() ?: listOf() - log.debug("processArgs: " + processArgs) - val n = processArgs.indexOf(javaClass.name) - val argsAfterClass = processArgs.subList(n + 1, processArgs.size) - val result = argsAfterClass.filter { arg -> !arg.startsWith("-") } - log.debug("args: " + result) - args = result - return result - } - override fun start(startFuture: Future) { - val args = findArgs() - if (args.size < 2) { - throw StartupException("Usage: wss://proxyhook.example.com/$PATH_WEBSOCKET http://target1.example.com/webhook [http://target2.example.com/webhook ...]") - } - startClient(args[0], args.subList(1, args.size), startFuture) + startClient(startFuture) } - private fun startClient(webSocketUrl: String, webhookUrls: List, startFuture: Future) { - log.info("starting client for websocket: $webSocketUrl posting to webhook URLs: $webhookUrls") + private fun startClient(startFuture: Future) { + log.info("starting client for websockets: $webSocketUrls posting to webhook URLs: $webhookUrls") log.info("Using internal http proxy: $internalHttpProxyHost:$internalHttpProxyPort") webhookUrls.forEach { this.checkURI(it) } - - val wsUri = parseUri(webSocketUrl) - val webSocketRelativeUri = getRelativeUri(wsUri) - val useSSL = getSSL(wsUri) - val wsOptions = HttpClientOptions().apply { - // 60s timeout based on pings from every 50s (both directions) - idleTimeout = 60 - connectTimeout = 10_000 - defaultHost = wsUri.host - defaultPort = getWebsocketPort(wsUri) - maxWebsocketFrameSize = MAX_FRAME_SIZE - isSsl = useSSL - isVerifyHost = !sslInsecureServer - isTrustAll = sslInsecureServer - // this doesn't appear to affect websocket connections + val wsUris = webSocketUrls.map { parseUri(it) } + + CompositeFuture.all(wsUris.map { wsUri -> + val future = Future.future() + val webSocketRelativeUri = getRelativeUri(wsUri) + val useSSL = getSSL(wsUri) + val wsOptions = HttpClientOptions().apply { + // 60s timeout based on pings from every 50s (both directions) + idleTimeout = 60 + connectTimeout = 10_000 + defaultHost = wsUri.host + defaultPort = getWebsocketPort(wsUri) + maxWebsocketFrameSize = MAX_FRAME_SIZE + isSsl = useSSL + isVerifyHost = !sslInsecureServer + isTrustAll = sslInsecureServer + // this doesn't appear to affect websocket connections // externalHttpProxy?.let { portNum -> // proxyOptions = ProxyOptions().apply { // host = "localhost" // port = portNum // } // } - } - val wsClient = vertx.createHttpClient(wsOptions) - val httpOptions = HttpClientOptions().apply { - isVerifyHost = !sslInsecureDelivery - isTrustAll = sslInsecureDelivery - internalHttpProxyPort?.let { portNum -> - proxyOptions = ProxyOptions().apply { - host = "localhost" - port = portNum + } + val wsClient = vertx.createHttpClient(wsOptions) + val httpOptions = HttpClientOptions().apply { + isVerifyHost = !sslInsecureDelivery + isTrustAll = sslInsecureDelivery + internalHttpProxyPort?.let { portNum -> + proxyOptions = ProxyOptions().apply { + host = "localhost" + port = portNum + } } } - } - val httpClient = vertx.createHttpClient(httpOptions) + val httpClient = vertx.createHttpClient(httpOptions) - connect(webhookUrls, webSocketRelativeUri, wsClient, httpClient, startFuture) + connect(webSocketRelativeUri, wsClient, httpClient, future) + future + }).setHandler { res -> if (res.succeeded()) startFuture.complete() else startFuture.fail(res.cause()) } + // TODO this would be better, but so far I can't get the types right without casting +// }).setHandler(startFuture.completer() as Handler>) } - private fun connect(webhookUrls: List, - webSocketRelativeUri: String, wsClient: HttpClient, - httpClient: HttpClient, startFuture: Future? = null) { + private fun connect(webSocketRelativeUri: String, wsClient: HttpClient, + httpClient: HttpClient, wsFuture: Future<*>? = null) { wsClient.websocket(webSocketRelativeUri, { webSocket -> var password: String? = getenv(PROXYHOOK_PASSWORD) if (password == null) password = "" - log.info("trying to log in") + log.info("trying to log in to ${webSocket.remoteAddress()}") val login = JsonObject() login.put(TYPE, LOGIN) login.put(PASSWORD, password) @@ -239,55 +245,58 @@ class ProxyHookClient( sendPingFrame(webSocket) } webSocket.handler { buf: Buffer -> - handleWebSocket(webhookUrls, buf, webSocket, wsClient, httpClient, startFuture) + handleWebSocket(buf, webSocket, wsClient, httpClient, wsFuture) } webSocket.closeHandler { log.info("websocket closed") vertx.cancelTimer(periodicTimer) vertx.setTimer(300) { - connect(webhookUrls, - webSocketRelativeUri, wsClient, httpClient) + connect(webSocketRelativeUri, wsClient, httpClient) } } webSocket.exceptionHandler { e -> log.error("websocket stream exception", e) vertx.cancelTimer(periodicTimer) vertx.setTimer(2000) { - connect(webhookUrls, - webSocketRelativeUri, wsClient, httpClient) + connect(webSocketRelativeUri, wsClient, httpClient) } } }) { e -> log.error("websocket connection exception", e) vertx.setTimer(2000) { - connect(webhookUrls, - webSocketRelativeUri, wsClient, httpClient) + connect(webSocketRelativeUri, wsClient, httpClient) } } } // TODO too many params - private fun handleWebSocket(webhookUrls: List, buf: Buffer, webSocket: WebSocket, wsClient: HttpClient, httpClient: HttpClient, startFuture: Future?) { - val msg = buf.toJsonObject() + private fun handleWebSocket(buf: Buffer, webSocket: WebSocket, wsClient: HttpClient, httpClient: HttpClient, startFuture: Future<*>?) { + val msg: JsonObject + try { + msg = buf.toJsonObject() + } catch (e: DecodeException) { + log.warn("Invalid JSON from ${webSocket.remoteAddress()}: \n${buf.bytes.joinToString()}") + return + } log.debug("payload: {0}", msg) val type = msg.getString(TYPE) val messageType = MessageType.valueOf(type) when (messageType) { MessageType.SUCCESS -> { - log.info("logged in") + log.info("logged in to ${webSocket.remoteAddress()}") ready?.complete() startFuture?.complete() } MessageType.FAILED -> { webSocket.close() wsClient.close() - startFuture?.fail("login failed") + startFuture?.fail("login failed for ${webSocket.remoteAddress()}") } MessageType.WEBHOOK -> handleWebhook(webhookUrls, httpClient, msg) MessageType.PING -> { val pingId = msg.getString(PING_ID) - log.debug("received PING with id {}", pingId) + log.debug("received PING with id {} from ${webSocket.remoteAddress()}", pingId) val pong = JsonObject() pong.put(TYPE, PONG) pong.put(PING_ID, pingId) @@ -296,14 +305,14 @@ class ProxyHookClient( PONG -> { val pongId = msg.getString(PING_ID) // TODO check ping ID - log.debug("received PONG with id {}", pongId) + log.debug("received PONG with id {} from ${webSocket.remoteAddress()}", pongId) } else -> { // TODO this might happen if the server is newer than the client // should we log a warning and keep going, to be more robust? webSocket.close() wsClient.close() - startFuture?.fail("unexpected message type: " + type) + startFuture?.fail("unexpected message type: $type from ${webSocket.remoteAddress()}") } } } @@ -407,7 +416,7 @@ class ProxyHookClient( try { return URI(uri) } catch (e: URISyntaxException) { - throw StartupException("Invalid URI: " + uri) + throw StartupException("Invalid URI: $uri") } } diff --git a/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt b/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt index 5516956..354411c 100644 --- a/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt +++ b/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt @@ -173,7 +173,7 @@ class IntegrationTest { private fun startClient(testFinished: CompletableFuture, websocketUrl: String, receiveUrl: String, internalHttpProxyHost: String?, internalHttpProxyPort: Int?): Future { log.info("deploying client") val clientReady = Future.future() - client.deployVerticle(ProxyHookClient(clientReady, websocketUrl, receiveUrl, internalHttpProxyHost = internalHttpProxyHost, internalHttpProxyPort = internalHttpProxyPort)) { + client.deployVerticle(ProxyHookClient(clientReady, listOf(websocketUrl), listOf(receiveUrl), internalHttpProxyHost = internalHttpProxyHost, internalHttpProxyPort = internalHttpProxyPort)) { if (it.failed()) { testFinished.completeExceptionally(it.cause()) } From 967821c1dd8827ed8aac30674afcef7d1f88bc8b Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Fri, 9 Mar 2018 17:29:08 +1000 Subject: [PATCH 15/17] Simplify using AsyncMap/HashMap, no LocalMap/Counter --- .../zanata/proxyhook/server/LocalAsyncMap.kt | 84 ----------------- .../proxyhook/server/ProxyHookServer.kt | 94 +++++++------------ 2 files changed, 34 insertions(+), 144 deletions(-) delete mode 100644 server/src/main/java/org/zanata/proxyhook/server/LocalAsyncMap.kt diff --git a/server/src/main/java/org/zanata/proxyhook/server/LocalAsyncMap.kt b/server/src/main/java/org/zanata/proxyhook/server/LocalAsyncMap.kt deleted file mode 100644 index b545c07..0000000 --- a/server/src/main/java/org/zanata/proxyhook/server/LocalAsyncMap.kt +++ /dev/null @@ -1,84 +0,0 @@ -package org.zanata.proxyhook.server - -import java.util.ArrayList -import java.util.HashMap -import java.util.Objects -import io.vertx.core.AsyncResult -import io.vertx.core.Future -import io.vertx.core.Future.succeededFuture -import io.vertx.core.Handler -import io.vertx.core.shareddata.AsyncMap -import io.vertx.core.shareddata.LocalMap - -// From https://github.com/eclipse/vert.x/issues/2137#issuecomment-330824746 -// Thanks to https://github.com/mr-bre -class LocalAsyncMap(private val map: LocalMap) : AsyncMap { - - override fun get(k: K, resultHandler: Handler>) { - resultHandler.handle(succeededFuture(map[k])) - } - - override fun put(k: K, v: V, completionHandler: Handler>) { - map.put(k, v) - completionHandler.handle(succeededFuture()) - } - - override fun put(k: K, v: V, ttl: Long, completionHandler: Handler>) { - put(k, v, completionHandler) - } - - override fun putIfAbsent(k: K, v: V, completionHandler: Handler>) { - completionHandler.handle(succeededFuture(map.putIfAbsent(k, v))) - } - - override fun putIfAbsent(k: K, v: V, ttl: Long, completionHandler: Handler>) { - putIfAbsent(k, v, completionHandler) - } - - override fun remove(k: K, resultHandler: Handler>) { - resultHandler.handle(succeededFuture(map.remove(k))) - } - - override fun removeIfPresent(k: K, v: V, resultHandler: Handler>) { - resultHandler.handle(succeededFuture(map.removeIfPresent(k, v))) - } - - override fun replace(k: K, v: V, resultHandler: Handler>) { - resultHandler.handle(succeededFuture(map.replace(k, v))) - } - - override fun replaceIfPresent(k: K, oldValue: V, newValue: V, resultHandler: Handler>) { - resultHandler.handle(succeededFuture(map.replaceIfPresent(k, oldValue, newValue))) - } - - override fun clear(resultHandler: Handler>) { - map.clear() - resultHandler.handle(succeededFuture()) - } - - override fun size(resultHandler: Handler>) { - resultHandler.handle(succeededFuture(map.size)) - } - - override fun keys(resultHandler: Handler>>) { - resultHandler.handle(succeededFuture(map.keys)) - } - - override fun values(resultHandler: Handler>>) { - val result = ArrayList(map.values) - resultHandler.handle(succeededFuture(result)) - } - - override fun entries(resultHandler: Handler>>) { - val result = entriesToMap(map.entries) - resultHandler.handle(succeededFuture(result)) - } - - private fun entriesToMap(entries: Set>): Map { - val map = HashMap(entries.size * 2) - for (entry in entries) { - map.put(entry.key, entry.value) - } - return map - } -} diff --git a/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt b/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt index 7abb237..61276db 100644 --- a/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt +++ b/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt @@ -20,10 +20,7 @@ */ package org.zanata.proxyhook.server -import io.vertx.core.AsyncResult import io.vertx.core.Future -import io.vertx.core.Future.succeededFuture -import io.vertx.core.Handler import io.vertx.core.Vertx import io.vertx.core.VertxOptions import org.mindrot.jbcrypt.BCrypt @@ -38,8 +35,6 @@ import io.vertx.core.http.WebSocketBase import io.vertx.core.json.JsonObject import io.vertx.core.logging.LoggerFactory import io.vertx.core.shareddata.AsyncMap -import io.vertx.core.shareddata.Counter -import io.vertx.core.shareddata.LocalMap import io.vertx.ext.web.Route import io.vertx.ext.web.Router import io.vertx.ext.web.RoutingContext @@ -116,39 +111,20 @@ class ProxyHookServer( } } } - private inner class ConnectionManager(val connections: AsyncMap, val connectionCount: Counter) private val sharedData by lazy { vertx.sharedData() } private val eventBus: EventBus get() = vertx.eventBus() private val passhash: String? = getenv(PROXYHOOK_PASSHASH) // map of websockets which are connected directly to this verticle (not via clustering) - // TODO a plain local HashMap might be more appropriate - private val localConnections by lazy { - sharedData.getLocalMap("localConnections") - } - private lateinit var manager: ConnectionManager - - fun getAsyncMap(name: String, resultHandler: Handler>>) { - if (vertx.isClustered) { - log.info("Vert.x is in cluster mode: returning a cluster-wide map") - sharedData.getClusterWideMap(name, resultHandler) - } else { - log.info("Vert.x is not in cluster mode: wrapping a LocalMap") - val asyncMap = LocalAsyncMap(sharedData.getLocalMap(name)) - resultHandler.handle(succeededFuture(asyncMap)) - } - } + private val localConnections = HashSet() + // map of websocket IDs to TRUE (used like a Set) + private lateinit var connections: AsyncMap - suspend override fun start() { - // a counter of websocket connections across the vert.x cluster - val connectionCount: Counter = awaitResult { - sharedData.getCounter("connectionCount", it) - } + override suspend fun start() { // a map of websocket connections across the vert.x cluster - val connections: AsyncMap = awaitResult { - getAsyncMap("connections", it) + connections = awaitResult { + sharedData.getAsyncMap("connections", it) } - this.manager = ConnectionManager(connections, connectionCount) if (passhash != null) { log.info("password is set") @@ -171,7 +147,7 @@ class ProxyHookServer( vertx.setPeriodic(50_000) { // in a cluster, we should only ping websockets connected to this verticle directly - localConnections.keys.forEach(this::pingConnection) + localConnections.forEach(this::pingConnection) } val router = Router.router(vertx) @@ -222,16 +198,22 @@ class ProxyHookServer( } } + // The prefix "fetch" is because this is a bit expensive + private suspend fun fetchConnectionCount(): Int { + return awaitResult { connections.size(it) } + } + private suspend fun rootHandler(context: RoutingContext) { - val count = awaitResult { manager.connectionCount.get(it) } + val count = fetchConnectionCount() context.response().setStatusCode(HTTP_OK).end(APP_NAME + " (" + describe(count) + ")") } private suspend fun readyHandler(context: RoutingContext) { - val count = awaitResult { manager.connectionCount.get(it) } + val count = fetchConnectionCount() context.response() - // if there are no connections, webhooks won't be delivered, thus HTTP_SERVICE_UNAVAILABLE - .setStatusCode(if (count == 0L) HTTP_SERVICE_UNAVAILABLE else HTTP_OK) + // if there are no connections, webhooks won't be delivered, thus + // HTTP_SERVICE_UNAVAILABLE (allows web pingers to check if it's all working) + .setStatusCode(if (count == 0) HTTP_SERVICE_UNAVAILABLE else HTTP_OK) .end(APP_NAME + " (" + describe(count) + ")") } @@ -242,7 +224,7 @@ class ProxyHookServer( EVENT_ID_HEADERS .filter { headers.contains(it) } .forEach { log.info("{0}: {1}", it, headers.getAll(it)) } - val listeners = awaitResult> { manager.connections.keys(it) } + val listeners = awaitResult> { connections.keys(it) } log.info("handling POST for {0} listeners", listeners.size) val statusCode: Int if (!listeners.isEmpty()) { @@ -330,31 +312,25 @@ class ProxyHookServer( val id = webSocket.textHandlerID() val clientIP = getClientIP(webSocket) log.info("Adding connection. ID: $id IP: $clientIP") - localConnections.put(id, true) - val connections = manager.connections - awaitResult { connections.put(id, true, it) } - log.info("Connection registered with cluster") - val connectionCount = manager.connectionCount - val incCount = awaitResult { connectionCount.incrementAndGet(it) } - log.info("New connection counted: cluster has {0} connections", incCount) + localConnections.add(id) log.info("Total local connections: {0}", localConnections.size) + val connections = connections + awaitResult { connections.put(id, true, it) } + log.info("Connection added: now {0} connections to cluster", fetchConnectionCount()) + webSocket.closeCoroutineHandler { log.info("Connection closed. ID: {0} IP: {1}", id, clientIP) localConnections.remove(id) - awaitResult { connections.remove(id, it) } - log.info("Connection removed from cluster") - val decCount = awaitResult { connectionCount.decrementAndGet(it) } - log.info("Closed connection counted: cluster has {0} connections", decCount) log.info("Total local connections: {0}", localConnections.size) + awaitResult { connections.remove(id, it) } + log.info("Connection removed: now {0} connections to cluster", fetchConnectionCount()) } webSocket.exceptionCoroutineHandler { e -> log.warn("Connection error. ID: {0} IP: {1}", e, id, clientIP) localConnections.remove(id) - awaitResult { connections.remove(id, it) } - log.info("Broken connection removed from cluster") - val decCount = awaitResult { connectionCount.decrementAndGet(it) } - log.info("Broken connection counted: cluster has {0} connections", decCount) log.info("Total local connections: {0}", localConnections.size) + awaitResult { connections.remove(id, it) } + log.info("Broken connection removed: now {0} connections to cluster", fetchConnectionCount()) } } @@ -424,12 +400,10 @@ private val localHostName: String by lazy { internal fun describe(size: Int): String = describe(size.toLong()) // visible for testing -internal fun describe(size: Long): String { - if (size == 1L) { - return "1 listener" - } else { - return "" + size + " listeners" - } +internal fun describe(size: Long): String = if (size == 1L) { + "1 listener" +} else { + "$size listeners" } private fun getClientIP(webSocket: ServerWebSocket): String { @@ -469,16 +443,16 @@ internal fun treatAsUTF8(contentType: String?): Boolean { .filter { it.matches("charset=(utf-?8|ascii)".toRegex()) } .forEach { return true } // otherwise we infer charset based on the content type: - when (contentType) { + return when (contentType) { // JSON only allows Unicode. "application/json", // XML defaults to Unicode. // An XML doc could specify another (non-Unicode) charset internally, but we don't support this. "application/xml", // Defaults to ASCII: - "text/xml" -> return true + "text/xml" -> true // If in doubt, treat as non-Unicode (or binary) - else -> return false + else -> false } } From 6297650a61f7072b61003852a65bd8f65d60e7a6 Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Fri, 9 Mar 2018 17:47:49 +1000 Subject: [PATCH 16/17] Remove FakeClusterManager --- client/build.gradle | 12 ------------ .../zanata/proxyhook/client/IntegrationTest.kt | 15 --------------- 2 files changed, 27 deletions(-) diff --git a/client/build.gradle b/client/build.gradle index fa8b56f..4e03710 100644 --- a/client/build.gradle +++ b/client/build.gradle @@ -3,11 +3,6 @@ plugins { id 'com.github.johnrengelman.shadow' version '1.2.3' } -sourceSets { - // Just for testSourcesCompile (see below) - testSources -} - dependencies { compile project(':common') compile 'com.xenomachina:kotlin-argparser:2.0.4' @@ -17,13 +12,6 @@ dependencies { testCompile 'org.asynchttpclient:async-http-client:2.1.0-alpha20' testRuntime 'org.slf4j:slf4j-simple:1.7.25' testCompile 'org.mock-server:mockserver-netty:3.11' - // for FakeClusterManager: https://github.com/eclipse/vert.x/issues/2191 - testCompile "io.vertx:vertx-core:$vertx_version:tests" - // Enabling this should cause the IDE to download the source for - // FakeClusterManager. (You'll still have to select Choose Sources on - // FakeClusterManager.class, then browse to the test-sources jar in a - // nearby directory.) -// testSourcesCompile "io.vertx:vertx-core:$vertx_version:test-sources" // compile "io.vertx:vertx-hazelcast:$vertx_version" // compile "io.vertx:vertx-infinispan:$vertx_version" diff --git a/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt b/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt index 354411c..1e9041c 100644 --- a/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt +++ b/client/src/test/java/org/zanata/proxyhook/client/IntegrationTest.kt @@ -66,21 +66,6 @@ class IntegrationTest { proxyClient.verifyZeroInteractions() } - @Test - fun rootDeploymentInFakeCluster() { - server = runBlocking { - awaitResult { - val serverOpts = VertxOptions().apply { - isClustered = true - clusterManager = io.vertx.test.fakecluster.FakeClusterManager() - } - Vertx.clusteredVertx(serverOpts, it) - } - } - deliverProxiedWebhook(prefix = "") - proxyClient.verifyZeroInteractions() - } - @Test fun rootDeploymentInInfinispanCluster() { server = runBlocking { From 8fa886a5e5e6859051280d93e4301d1fe1d1b1a9 Mon Sep 17 00:00:00 2001 From: Sean Flanigan Date: Fri, 9 Mar 2018 18:26:01 +1000 Subject: [PATCH 17/17] Clean up for codacy --- client/Dockerfile | 3 +- .../proxyhook/client/ProxyHookClient.kt | 3 ++ server/Dockerfile | 6 ++-- .../proxyhook/server/ProxyHookServer.kt | 30 ++----------------- 4 files changed, 12 insertions(+), 30 deletions(-) diff --git a/client/Dockerfile b/client/Dockerfile index dfc4320..544f8f0 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -1,7 +1,8 @@ +LABEL maintainer="sflaniga@redhat.com" # https://access.redhat.com/containers/?tab=overview&platform=docker#/registry.access.redhat.com/redhat-openjdk-18/openjdk18-openshift FROM registry.access.redhat.com/redhat-openjdk-18/openjdk18-openshift:1.2-6 -ADD build/libs/client*-fat.jar /deployments/ +COPY build/libs/client*-fat.jar /deployments/ # NB run-java.sh will scale heap size to 50% of container memory size diff --git a/client/src/main/java/org/zanata/proxyhook/client/ProxyHookClient.kt b/client/src/main/java/org/zanata/proxyhook/client/ProxyHookClient.kt index 7612d65..96919af 100644 --- a/client/src/main/java/org/zanata/proxyhook/client/ProxyHookClient.kt +++ b/client/src/main/java/org/zanata/proxyhook/client/ProxyHookClient.kt @@ -146,6 +146,9 @@ class ProxyHookClient( // deliberately not included: Connection, Host, Origin, If-*, Cache-Control, Proxy-Authorization, Range, Upgrade .map { it.toLowerCase() } + /** + * Main method, used to launch proxyhook client with CLI arguments + */ @JvmStatic fun main(args: Array) = mainBody(APP_NAME) { class MyArgs (parser: ArgParser) { val webSocketUrls: List by parser.adding("-s", "--websocket", help = "connect to websocket (proxyhook server), eg wss://proxyhook.example.com/"); diff --git a/server/Dockerfile b/server/Dockerfile index 71a56e5..92711a9 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,7 +1,8 @@ +LABEL maintainer="sflaniga@redhat.com" # https://access.redhat.com/containers/?tab=overview&platform=docker#/registry.access.redhat.com/redhat-openjdk-18/openjdk18-openshift FROM registry.access.redhat.com/redhat-openjdk-18/openjdk18-openshift:1.1-13 -ADD build/libs/server*-fat.jar /deployments/ +COPY build/libs/server*-fat.jar /deployments/ EXPOSE 8080 # http://vertx.io/docs/vertx-infinispan/java/#_configuring_for_kubernetes_or_openshift_3 @@ -17,4 +18,5 @@ ENV JAVA_OPTIONS "-Dhttp.address=0.0.0.0 \ # https://docs.docker.com/engine/reference/builder/#exec-form-entrypoint-example # see https://github.com/fabric8io-images/run-java-sh -ENTRYPOINT ["/opt/run-java/run-java.sh", "-cluster"] +ENTRYPOINT ["/opt/run-java/run-java.sh"] +# NB run with arg "-cluster" if you want OpenShift/Kubernetes clustering diff --git a/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt b/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt index 61276db..202e6ae 100644 --- a/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt +++ b/server/src/main/java/org/zanata/proxyhook/server/ProxyHookServer.kt @@ -21,8 +21,6 @@ package org.zanata.proxyhook.server import io.vertx.core.Future -import io.vertx.core.Vertx -import io.vertx.core.VertxOptions import org.mindrot.jbcrypt.BCrypt import io.vertx.core.buffer.Buffer import io.vertx.core.eventbus.EventBus @@ -67,7 +65,6 @@ import org.zanata.proxyhook.common.MessageType.PONG import org.zanata.proxyhook.common.MessageType.SUCCESS import org.zanata.proxyhook.common.MessageType.WEBHOOK import org.zanata.proxyhook.common.StartupException -import org.zanata.proxyhook.common.exit import org.zanata.proxyhook.common.multiMapToJson import java.lang.System.getenv import java.net.InetAddress @@ -90,28 +87,6 @@ class ProxyHookServer( private val prefix: String = getenv("PROXYHOOK_PREFIX") ?: "", var actualPort: Future? = null) : CoroutineVerticle() { - companion object { - // Try these JVM arguments: -Djava.net.preferIPv4Stack=true -Djgroups.bind_addr=127.0.0.1 - @JvmStatic fun main(args: Array) { - Vertx.clusteredVertx(VertxOptions().apply { -// clusterHost = "localhost" -// clusterPort = 0 -// isClustered = true - }) { res -> - if (res.succeeded()) { - res.result().deployVerticle(ProxyHookServer(port = null), { result -> - result.otherwise { e -> - exit(e) - } - } - ) - } else { - exit(res.cause()) - } - } - } - } - private val sharedData by lazy { vertx.sharedData() } private val eventBus: EventBus get() = vertx.eventBus() private val passhash: String? = getenv(PROXYHOOK_PASSHASH) @@ -345,7 +320,7 @@ class ProxyHookServer( eventBus.send(connection, obj.encode()) } - /** + /* * Extension methods to simplify coroutine usage for various WebSocket handlers */ private fun WebSocketBase.coroutineHandler(fn : suspend (Buffer) -> Unit) { @@ -456,9 +431,10 @@ internal fun treatAsUTF8(contentType: String?): Boolean { } } -/** +/* * An extension method for simplifying coroutines usage with Vert.x Web routers */ +@Suppress("Detekt.TooGenericExceptionCaught") private fun Route.coroutineHandler(fn : suspend (RoutingContext) -> Unit) { handler { ctx -> launch(ctx.vertx().dispatcher()) {