diff --git a/daml-script/test/src/com/digitalasset/daml/lf/engine/script/test/JsonApiIt.scala b/daml-script/test/src/com/digitalasset/daml/lf/engine/script/test/JsonApiIt.scala index a4eb5152c1..d4adcdcee7 100644 --- a/daml-script/test/src/com/digitalasset/daml/lf/engine/script/test/JsonApiIt.scala +++ b/daml-script/test/src/com/digitalasset/daml/lf/engine/script/test/JsonApiIt.scala @@ -96,6 +96,7 @@ trait JsonApiFixture "localhost", 0, None, + None, None)( jsonApiActorSystem, jsonApiMaterializer, diff --git a/docs/source/json-api/index.rst b/docs/source/json-api/index.rst index 407936295c..bd76a7a0b1 100644 --- a/docs/source/json-api/index.rst +++ b/docs/source/json-api/index.rst @@ -72,7 +72,9 @@ From a DAML project directory: --address IP address that HTTP JSON API service listens on. Defaults to 127.0.0.1. --http-port - HTTP JSON API service port number + HTTP JSON API service port number. A port number of 0 will let the system pick an ephemeral port. Consider specifying `--port-file` option with port number 0. + --port-file + Optional unique file name where to write the allocated HTTP port number. If process terminates gracefully, this file will be deleted automatically. Used to inform clients in CI about which port HTTP JSON API listens on. Defaults to none, that is, no file gets created. --application-id Optional application ID to use for ledger registration. Defaults to HTTP-JSON-API-Gateway --package-reload-interval diff --git a/ledger-service/http-json/BUILD.bazel b/ledger-service/http-json/BUILD.bazel index b2f9b626e9..d1fb5b646d 100644 --- a/ledger-service/http-json/BUILD.bazel +++ b/ledger-service/http-json/BUILD.bazel @@ -40,6 +40,7 @@ da_scala_library( "//ledger/ledger-api-auth", "//ledger/ledger-api-common", "//libs-scala/auth-utils", + "//libs-scala/ports", "@maven//:com_chuusai_shapeless_2_12", "@maven//:com_github_scopt_scopt_2_12", "@maven//:com_lihaoyi_sourcecode_2_12", @@ -81,6 +82,7 @@ da_scala_binary( "//ledger/ledger-api-auth", "//ledger/ledger-api-common", "//libs-scala/auth-utils", + "//libs-scala/ports", "@maven//:ch_qos_logback_logback_classic", "@maven//:com_chuusai_shapeless_2_12", "@maven//:com_github_scopt_scopt_2_12", diff --git a/ledger-service/http-json/src/main/scala/com/digitalasset/http/Config.scala b/ledger-service/http-json/src/main/scala/com/digitalasset/http/Config.scala index 8c0655c48c..acf926f125 100644 --- a/ledger-service/http-json/src/main/scala/com/digitalasset/http/Config.scala +++ b/ledger-service/http-json/src/main/scala/com/digitalasset/http/Config.scala @@ -23,6 +23,7 @@ private[http] final case class Config( ledgerPort: Int, address: String = InetAddress.getLoopbackAddress.getHostAddress, httpPort: Int, + portFile: Option[Path] = None, applicationId: ApplicationId = ApplicationId("HTTP-JSON-API-Gateway"), packageReloadInterval: FiniteDuration = HttpService.DefaultPackageReloadInterval, maxInboundMessageSize: Int = HttpService.DefaultMaxInboundMessageSize, diff --git a/ledger-service/http-json/src/main/scala/com/digitalasset/http/HttpService.scala b/ledger-service/http-json/src/main/scala/com/digitalasset/http/HttpService.scala index e09bfcc124..8da6a458e1 100644 --- a/ledger-service/http-json/src/main/scala/com/digitalasset/http/HttpService.scala +++ b/ledger-service/http-json/src/main/scala/com/digitalasset/http/HttpService.scala @@ -37,6 +37,7 @@ import com.daml.ledger.client.configuration.{ import com.daml.ledger.client.services.pkg.PackageClient import com.daml.ledger.service.LedgerReader import com.daml.ledger.service.LedgerReader.PackageStore +import com.daml.ports.{Port, PortFiles} import com.typesafe.scalalogging.StrictLogging import io.grpc.netty.NettyChannelBuilder import scalaz.Scalaz._ @@ -61,6 +62,7 @@ object HttpService extends StrictLogging { applicationId: ApplicationId, address: String, httpPort: Int, + portFile: Option[Path], wsConfig: Option[WebsocketConfig], accessTokenFile: Option[Path], contractDao: Option[ContractDao] = None, @@ -167,6 +169,8 @@ object HttpService extends StrictLogging { Http().bindAndHandleAsync(allEndpoints, address, httpPort, settings = settings), ) + _ <- either(portFile.cata(f => createPortFile(f, binding), \/-(()))): ET[Unit] + } yield binding bindingEt.run: Future[Error \/ ServerBinding] @@ -284,4 +288,11 @@ object HttpService extends StrictLogging { case NonFatal(e) => \/.left(Error(s"Cannot connect to the ledger server, error: ${e.description}")) } + + private def createPortFile( + file: Path, + binding: akka.http.scaladsl.Http.ServerBinding): Error \/ Unit = { + import util.ErrorOps._ + PortFiles.write(file, Port(binding.localAddress.getPort)).liftErr(Error) + } } diff --git a/ledger-service/http-json/src/main/scala/com/digitalasset/http/Main.scala b/ledger-service/http-json/src/main/scala/com/digitalasset/http/Main.scala index 88395390f4..3a4104dd2a 100644 --- a/ledger-service/http-json/src/main/scala/com/digitalasset/http/Main.scala +++ b/ledger-service/http-json/src/main/scala/com/digitalasset/http/Main.scala @@ -3,7 +3,8 @@ package com.daml.http -import java.nio.file.Paths +import java.io.File +import java.nio.file.{Path, Paths} import akka.actor.ActorSystem import akka.http.scaladsl.Http.ServerBinding @@ -44,6 +45,7 @@ object Main extends StrictLogging { logger.info( s"Config(ledgerHost=${config.ledgerHost: String}, ledgerPort=${config.ledgerPort: Int}" + s", address=${config.address: String}, httpPort=${config.httpPort: Int}" + + s", portFile=${config.portFile: Option[Path]}" + s", applicationId=${config.applicationId.unwrap: String}" + s", packageReloadInterval=${config.packageReloadInterval.toString}" + s", maxInboundMessageSize=${config.maxInboundMessageSize: Int}" + @@ -85,6 +87,7 @@ object Main extends StrictLogging { applicationId = config.applicationId, address = config.address, httpPort = config.httpPort, + portFile = config.portFile, wsConfig = config.wsConfig, accessTokenFile = config.accessTokenFile, contractDao = contractDao, @@ -155,7 +158,19 @@ object Main extends StrictLogging { opt[Int]("http-port") .action((x, c) => c.copy(httpPort = x)) .required() - .text("HTTP JSON API service port number") + .text( + "HTTP JSON API service port number. " + + "A port number of 0 will let the system pick an ephemeral port. " + + "Consider specifying `--port-file` option with port number 0.") + + opt[File]("port-file") + .action((x, c) => c.copy(portFile = Some(x.toPath))) + .optional() + .text( + "Optional unique file name where to write the allocated HTTP port number. " + + "If process terminates gracefully, this file will be deleted automatically. " + + "Used to inform clients in CI about which port HTTP JSON API listens on. " + + "Defaults to none, that is, no file gets created.") opt[String]("application-id") .action((x, c) => c.copy(applicationId = ApplicationId(x))) diff --git a/ledger-service/http-json/src/test/scala/com/digitalasset/http/HttpServiceTestFixture.scala b/ledger-service/http-json/src/test/scala/com/digitalasset/http/HttpServiceTestFixture.scala index d57d309f78..7d43c1bb14 100644 --- a/ledger-service/http-json/src/test/scala/com/digitalasset/http/HttpServiceTestFixture.scala +++ b/ledger-service/http-json/src/test/scala/com/digitalasset/http/HttpServiceTestFixture.scala @@ -67,16 +67,18 @@ object HttpServiceTestFixture { contractDao <- contractDaoF httpService <- stripLeft( HttpService.start( - "localhost", - ledgerPort.value, - applicationId, - "localhost", - 0, - Some(Config.DefaultWsConfig), - None, - contractDao, - staticContentConfig, - doNotReloadPackages)) + ledgerHost = "localhost", + ledgerPort = ledgerPort.value, + applicationId = applicationId, + address = "localhost", + httpPort = 0, + portFile = None, + wsConfig = Some(Config.DefaultWsConfig), + accessTokenFile = None, + contractDao = contractDao, + staticContentConfig = staticContentConfig, + packageReloadInterval = doNotReloadPackages + )) } yield httpService val clientF: Future[LedgerClient] = for { diff --git a/libs-scala/ports/BUILD.bazel b/libs-scala/ports/BUILD.bazel index 0d00e9994c..37f866a02c 100644 --- a/libs-scala/ports/BUILD.bazel +++ b/libs-scala/ports/BUILD.bazel @@ -14,6 +14,9 @@ da_scala_library( visibility = [ "//visibility:public", ], + deps = [ + "@maven//:org_scalaz_scalaz_core_2_12", + ], ) da_scala_test( @@ -21,5 +24,6 @@ da_scala_test( srcs = glob(["src/test/suite/scala/**/*.scala"]), deps = [ ":ports", + "@maven//:org_scalaz_scalaz_core_2_12", ], ) diff --git a/libs-scala/ports/src/main/scala/com/digitalasset/ports/PortFiles.scala b/libs-scala/ports/src/main/scala/com/digitalasset/ports/PortFiles.scala new file mode 100644 index 0000000000..06a426e114 --- /dev/null +++ b/libs-scala/ports/src/main/scala/com/digitalasset/ports/PortFiles.scala @@ -0,0 +1,44 @@ +// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.daml.ports + +import java.nio.file.{Files, Path} + +import scalaz.{Show, \/} + +import scala.collection.JavaConverters._ + +object PortFiles { + sealed abstract class Error extends Serializable with Product + final case class FileAlreadyExists(path: Path) extends Error + final case class CannotWriteToFile(path: Path, reason: String) extends Error + + object Error { + implicit val showInstance: Show[Error] = Show.shows { + case FileAlreadyExists(path) => + s"Port file already exists: ${path.toAbsolutePath: Path}" + case CannotWriteToFile(path, reason) => + s"Cannot write to port file: ${path.toAbsolutePath: Path}, reason: $reason" + } + } + + /** + * Creates a port and requests that the created file be deleted when the virtual machine terminates. + * See [[java.io.File#deleteOnExit()]]. + */ + def write(path: Path, port: Port): Error \/ Unit = + \/.fromTryCatchNonFatal { + writeUnsafe(path, port) + }.leftMap { + case _: java.nio.file.FileAlreadyExistsException => FileAlreadyExists(path) + case e => CannotWriteToFile(path, e.toString) + } + + private def writeUnsafe(path: Path, port: Port): Unit = { + import java.nio.file.StandardOpenOption.CREATE_NEW + val lines: java.lang.Iterable[String] = List(port.value.toString).asJava + val created = Files.write(path, lines, CREATE_NEW) + created.toFile.deleteOnExit() + } +} diff --git a/libs-scala/ports/src/test/suite/scala/com/digitalasset/ports/PortFilesSpec.scala b/libs-scala/ports/src/test/suite/scala/com/digitalasset/ports/PortFilesSpec.scala new file mode 100644 index 0000000000..399a4aa96a --- /dev/null +++ b/libs-scala/ports/src/test/suite/scala/com/digitalasset/ports/PortFilesSpec.scala @@ -0,0 +1,39 @@ +// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.daml.ports + +import java.nio.file.{Path, Paths} +import java.util.UUID + +import com.daml.ports.PortFiles.FileAlreadyExists +import org.scalatest.{FreeSpec, Inside, Matchers} +import scalaz.{-\/, \/-} + +@SuppressWarnings(Array("org.wartremover.warts.Any")) +class PortFilesSpec extends FreeSpec with Matchers with Inside { + + "Can create a port file with a unique file name" in { + val path = uniquePath() + inside(PortFiles.write(path, Port(1024))) { + case \/-(()) => + } + path.toFile.exists() shouldBe true + } + + "Cannot create a port file with a nonunique file name" in { + val path = uniquePath() + inside(PortFiles.write(path, Port(1024))) { + case \/-(()) => + } + inside(PortFiles.write(path, Port(1024))) { + case -\/(FileAlreadyExists(p)) => + p shouldBe path + } + } + + private def uniquePath(): Path = { + val fileName = s"${this.getClass.getSimpleName}-${UUID.randomUUID().toString}.dummy" + Paths.get(fileName) + } +}